From e19cd1863dd6560b0e473f1814ea45230245e6fb Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:20:42 -0400 Subject: [PATCH 01/15] feat(studio): connect logo contract for authorize (#48161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Feature + docs. Closes [DEPR-604](https://linear.app/supabase/issue/DEPR-604/define-connect-logo-asset-and-variant-contract). ## What is the current behavior? `/authorize` logo resolution trusted self-asserted requester `name` (and similar) for curated MCP marks, fell back to a letter tile when there was no usable icon, and always used theme-reactive tile chrome. This includes the scenario when pairing against unclassified uploaded OAuth app bitmaps. ## What is the new behavior? - [Documents the Connect logo asset/variant contract](https://design-system-git-danny-depr-604-connect-logo-contract-supabase.vercel.app/design-system/docs/ui-patterns/connect-interstitials#logos) (default to light, keep pairs matched, no theme-recolour of vendor SVGs). - Resolves curated partner logos from allowlisted `redirect_uri` hosts only (`claude.ai` / `anthropic.com`, `cursor.com` / `cursor.sh`, `chatgpt.com` / `openai.com`, `perplexity.ai`). - Unknown / missing / failed requester icons show `SupabaseLogo` alone (no letter tile). - Uploaded organisation OAuth app icons (unclassified bitmaps) pair with fixed light tile chrome (`border-black/10 bg-white` / `SupabaseLogo forceLight`) on both sides across Studio themes. - Curated partners keep theme-reactive tiles and may use dark assets when available. ### To test Real MCP clients (Claude, Cursor, etc.) only send users to **production** `/authorize`, so you cannot drive a local or preview Studio build from those tools. Use a Network override instead: 1. Start Studio and sign in (`pnpm dev:studio`, or use the Vercel preview once available). 2. Open `/dashboard/authorize?auth_id=foo` (any `auth_id` is fine — the real response may 404). 3. DevTools → **Network** → find `GET …/platform/oauth/authorizations/foo` (or whatever id you used). 4. Right-click → **Override content** (enable Local Overrides / pick a folder if prompted). 5. Paste one of the payloads below (status **200**), save, then reload the authorize page. 6. Keep `expires_at` in the future so the request does not look expired. The fields that matter for this PR are `name`, `icon`, and `redirect_uri`. #### Curated pair (allowlisted redirect) Expect Cursor mark + Supabase pair. Toggle light/dark: curated dark assets may swap; tiles stay theme-reactive (`bg-surface-75`). ```json { "name": "Cursor", "website": "https://cursor.com", "icon": null, "domain": "cursor.com", "redirect_uri": "https://cursor.com/callback", "expires_at": "2099-01-01T00:00:00.000Z", "scopes": ["organizations:read", "projects:read"], "approved_at": null, "registration_type": "dynamic" } ``` #### Unknown → Supabase alone Expect Supabase bolt alone. No letter tile. No curated mark even if `name` says Claude. ```json { "name": "Acme", "website": "https://acme.example", "icon": null, "domain": "acme.example", "redirect_uri": "https://acme.example/callback", "expires_at": "2099-01-01T00:00:00.000Z", "scopes": ["organizations:read", "projects:read"], "approved_at": null, "registration_type": "dynamic" } ``` #### Spoofed trusted name, non-allowlisted redirect (logo only) Expect Supabase alone (no Claude mark). This PR does **not** show the impersonation caution (that is coming in #48162). ```json { "name": "Claude", "website": "https://claude.ai", "icon": null, "domain": "claude.ai", "redirect_uri": "https://evil.com/callback", "expires_at": "2099-01-01T00:00:00.000Z", "scopes": ["organizations:read", "projects:read"], "approved_at": null, "registration_type": "dynamic" } ``` #### Uploaded OAuth app icon → forced-light pair Expect remote icon + Supabase pair with forced-light tiles (`border-black/10 bg-white`) on both sides in light and dark Studio themes. The icon URL below is the checked-in solid-colour Acme bitmap on this branch. ```json { "name": "Acme", "website": "https://acme.example", "icon": "https://raw.githubusercontent.com/supabase/supabase/danny/depr-604-connect-logo-contract/apps/design-system/public/img/icons/acme-oauth-icon.png", "domain": "acme.example", "redirect_uri": "https://acme.example/callback", "expires_at": "2099-01-01T00:00:00.000Z", "scopes": ["organizations:read", "projects:read"], "approved_at": null, "registration_type": "static" } ``` ## Summary by CodeRabbit * **New Features** * Improved authorization interstitial branding with trusted requester logos and safer fallback behavior. * Added support for consistent light-theme treatment of uploaded OAuth app icons. * Added examples and documentation for unknown requesters, uploaded logos, and wrong-account states. * **Bug Fixes** * Prevented unverified or unavailable requester icons from being presented as trusted. * Ensured logo pairing remains visually consistent across light and dark themes. * **Tests** * Added coverage for trusted-host validation, fallback branding, icon loading failures, and theme behavior. --------- Co-authored-by: Joshen Lim --- apps/design-system/__registry__/index.tsx | 22 ++++ .../ui-patterns/connect-interstitials.mdx | 94 +++++++++++++--- .../public/img/icons/acme-oauth-icon.png | Bin 0 -> 137 bytes .../connect-interstitial-logo-unknown.tsx | 25 +++++ .../connect-interstitial-logo-uploaded.tsx | 40 +++++++ .../example/connect-interstitial-shared.tsx | 4 +- apps/design-system/registry/examples.ts | 10 ++ .../ApiAuthorization.Approved.tsx | 12 +-- .../ApiAuthorization.Form.tsx | 12 +-- .../OAuthApps/AuthorizeRequesterDetails.tsx | 95 ++++++++-------- .../OAuthApps/OAuthApps.utils.test.ts | 91 ++++++++++++++++ .../Organization/OAuthApps/OAuthApps.utils.ts | 101 ++++++++++++++++++ .../OAuthApps/PublishAppSidePanel/index.tsx | 7 +- .../components/layouts/InterstitialLayout.tsx | 7 +- .../components/ApiAuthorization.test.tsx | 99 ++++++++++++++--- 15 files changed, 523 insertions(+), 96 deletions(-) create mode 100644 apps/design-system/public/img/icons/acme-oauth-icon.png create mode 100644 apps/design-system/registry/default/example/connect-interstitial-logo-unknown.tsx create mode 100644 apps/design-system/registry/default/example/connect-interstitial-logo-uploaded.tsx create mode 100644 apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.test.ts create mode 100644 apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.ts diff --git a/apps/design-system/__registry__/index.tsx b/apps/design-system/__registry__/index.tsx index 84d94abdbc4ae..3dbe1fb2acfc6 100644 --- a/apps/design-system/__registry__/index.tsx +++ b/apps/design-system/__registry__/index.tsx @@ -2634,6 +2634,28 @@ export const Index: Record = { subcategory: "undefined", chunks: [] }, + "connect-interstitial-logo-unknown": { + name: "connect-interstitial-logo-unknown", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/connect-interstitial-logo-unknown")), + source: "", + files: ["registry/default/example/connect-interstitial-logo-unknown.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "connect-interstitial-logo-uploaded": { + name: "connect-interstitial-logo-uploaded", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/connect-interstitial-logo-uploaded")), + source: "", + files: ["registry/default/example/connect-interstitial-logo-uploaded.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, "page-layout-auth-emails": { name: "page-layout-auth-emails", type: "components:example", diff --git a/apps/design-system/content/docs/ui-patterns/connect-interstitials.mdx b/apps/design-system/content/docs/ui-patterns/connect-interstitials.mdx index b5f1514af02b6..ceb6d36b189aa 100644 --- a/apps/design-system/content/docs/ui-patterns/connect-interstitials.mdx +++ b/apps/design-system/content/docs/ui-patterns/connect-interstitials.mdx @@ -81,10 +81,26 @@ project linking. ## Logos -Use `LogoPair` when the user is connecting two services, and `SupabaseLogo` -alone for first-party flows. `PartnerLogo` fills the 48px box edge-to-edge; -`LogoBox` is for custom inset marks or logos that need their own background. -Store new partner icons in `apps/studio/public/img/icons`. +Use `LogoPair` when the user is connecting two known services, and +`SupabaseLogo` alone for first-party flows or when the requester has no trusted +mark. `PartnerLogo` fills the 48px box edge-to-edge; `LogoBox` is for custom +inset marks or logos that need their own background. Store new partner icons in +`apps/studio/public/img/icons`. + +### Pairing + +| Requester logo | Header treatment | +| -------------------------------------------------------- | ---------------------------------------------------- | +| Curated partner / MCP client, or a trusted uploaded icon | `LogoPair` with requester left, `SupabaseLogo` right | +| Unknown, missing, blocked, or failed-to-load icon | `SupabaseLogo` alone. Do not invent an initial tile. | + +The user is usually arriving from the third-party app. The interstitial should +confirm they are connecting to Supabase. Put the requester name in the title and +description; do not manufacture a letter avatar to fill the left side of a pair. + +**Known services.** When both sides are curated (or otherwise known), pair them. +Theme-reactive tiles are fine when both marks have matching light/dark +treatment. +**No trusted requester mark.** If the icon is missing, blocked, or fails to +load, show `SupabaseLogo` alone. Do not invent an initial tile to fill the +pair. + -```tsx -const AwsLogo = () => ( - - AWS - -) +**Uploaded organisation OAuth icons.** Icons published via Studio’s OAuth app +builder are unclassified bitmaps — we do not know if they were authored for +light or dark. Treat the pair as light on both Studio themes: fixed light tile +chrome (`border-black/10 bg-white`, `SupabaseLogo forceLight`) on both sides. +Do not invent a dark variant for the upload. Toggle the docs theme to dark to +see the light tiles hold against the Studio chrome. -} right={} /> -``` + + +### Assets + +Treat Connect logos as assets, not theme tokens. + +**Default to light.** Prefer a single static light mark inside `LogoBox`. +Light assets read fine on both light and dark Studio themes. That is the +default for Connect tiles. + +**Keep pairs matched.** In a `LogoPair`, both marks must use the same +treatment: both light, or both dark. Do not mix a light partner tile with a +dark-theme-only Supabase treatment, or the reverse. Theme-aware dark variants +are fine for curated partners that already have them, but then both sides of +the pair should use the dark set together. + +**What not to do** + +- Do not invent light/dark pairs for arbitrary remote OAuth icons. +- Do not recolour vendor SVGs with theme CSS. Monochrome identity-provider + masks (for example GitHub on sign-in) stay a separate pattern. + +**Where logos come from on `/authorize`** + +- Curated partner logos resolve from allowlisted `redirect_uri` hosts only, + not from self-asserted `name` or `website`. Those pairs may use theme tiles + and dark assets when the partner has them. +- Published organisation OAuth app icons uploaded in Studio remain trusted + remote images, paired with forced-light tiles on both sides. +- Everything else falls back to `SupabaseLogo` alone. ## Account row @@ -150,6 +206,16 @@ Keep loading, invalid, error, and success states inside the same card when the route can explain them. Use `ShimmeringLoader` for loading, and `Admonition` for warning, error, note, and success copy. + + ## Copy Use sentence case. Prefer `sign in` over `login`. Titles and primary actions diff --git a/apps/design-system/public/img/icons/acme-oauth-icon.png b/apps/design-system/public/img/icons/acme-oauth-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..6cf13412dfbc077db8bc28cf78e9b2a5e3c1b68c GIT binary patch literal 137 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLGJM08bakkcwMx&pUE5FmSMJ@P2;% qALE-umivbiSf{P4)MX+KOlLLwvM@@#f67Uq*$kepelF{r5}E*C^BU>^ literal 0 HcmV?d00001 diff --git a/apps/design-system/registry/default/example/connect-interstitial-logo-unknown.tsx b/apps/design-system/registry/default/example/connect-interstitial-logo-unknown.tsx new file mode 100644 index 0000000000000..3255c7f54729d --- /dev/null +++ b/apps/design-system/registry/default/example/connect-interstitial-logo-unknown.tsx @@ -0,0 +1,25 @@ +import { Button } from 'ui' + +import { + AccountRow, + InterstitialShell, + SignOutButton, + SupabaseLogo, +} from './connect-interstitial-shared' + +export default function ConnectInterstitialLogoUnknown() { + return ( + } + title="Authorize Acme" + description="Acme is requesting access to your organization" + > +
+ } /> + +
+
+ ) +} diff --git a/apps/design-system/registry/default/example/connect-interstitial-logo-uploaded.tsx b/apps/design-system/registry/default/example/connect-interstitial-logo-uploaded.tsx new file mode 100644 index 0000000000000..5aaf43fab0217 --- /dev/null +++ b/apps/design-system/registry/default/example/connect-interstitial-logo-uploaded.tsx @@ -0,0 +1,40 @@ +import { Button } from 'ui' + +import { + AccountRow, + InterstitialShell, + LogoBox, + LogoPair, + SignOutButton, + SupabaseLogo, +} from './connect-interstitial-shared' + +/** Stand-in uploaded OAuth icon: checked-in solid-colour bitmap (not a real brand). */ +function UploadedAppLogo() { + return ( + + Acme + + ) +} + +export default function ConnectInterstitialLogoUploaded() { + return ( + } right={} />} + title="Authorize Acme" + description="Acme is requesting access to your organization" + > +
+ } /> + +
+
+ ) +} diff --git a/apps/design-system/registry/default/example/connect-interstitial-shared.tsx b/apps/design-system/registry/default/example/connect-interstitial-shared.tsx index 24c35e4da0856..e9100175ae04e 100644 --- a/apps/design-system/registry/default/example/connect-interstitial-shared.tsx +++ b/apps/design-system/registry/default/example/connect-interstitial-shared.tsx @@ -45,9 +45,9 @@ export function StripeLogo() { ) } -export function SupabaseLogo() { +export function SupabaseLogo({ forceLight = false }: { forceLight?: boolean } = {}) { return ( - + } - right={} + } title={requester.name} @@ -40,7 +41,6 @@ export function ApiAuthorizationApprovedScreen({ /> } - right={} + } title={`Authorize ${requester.name}`} @@ -125,7 +126,6 @@ export function ApiAuthorizationMainView({ {showReadyContent && ( <> { + redirectUri?: string | null +}) => { const [failedIcon, setFailedIcon] = useState(null) const { resolvedTheme } = useTheme() const logo = useMemo( - () => getRequesterLogo({ icon, name, useDarkVariant: resolvedTheme === 'dark' }), - [icon, name, resolvedTheme] + () => + getRequesterLogo({ + icon, + redirectUri, + useDarkVariant: resolvedTheme === 'dark', + }), + [icon, redirectUri, resolvedTheme] ) - const showLetter = !logo.src || failedIcon === logo.src + const hasUsableLogo = Boolean(logo.src) && failedIcon !== logo.src + + if (!hasUsableLogo) { + return + } + + const forceLightPair = !logo.isKnownClient return ( - - {showLetter ? ( - {name.slice(0, 1)} - ) : ( - {name} setFailedIcon(logo.src)} - /> - )} - + + {name} setFailedIcon(logo.src)} + /> + + } + right={} + /> ) } diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.test.ts b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.test.ts new file mode 100644 index 0000000000000..cce0b1b7fe911 --- /dev/null +++ b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.test.ts @@ -0,0 +1,91 @@ +import { getMcpClientIconSrc } from 'ui-patterns/McpUrlBuilder' +import { describe, expect, test } from 'vitest' + +import { + findTrustedPartnerByRedirectUri, + getRedirectHostname, + getRequesterLogo, + hostMatchesAllowlist, + isLocalRedirectHost, +} from './OAuthApps.utils' + +describe('hostMatchesAllowlist', () => { + test('allows exact and subdomain hosts', () => { + expect(hostMatchesAllowlist('claude.ai', ['claude.ai'])).toBe(true) + expect(hostMatchesAllowlist('api.claude.ai', ['claude.ai'])).toBe(true) + }) + + test('rejects lookalike hosts', () => { + expect(hostMatchesAllowlist('claude.ai.evil.com', ['claude.ai'])).toBe(false) + expect(hostMatchesAllowlist('notclaude.ai', ['claude.ai'])).toBe(false) + expect(hostMatchesAllowlist('evilclaude.ai', ['claude.ai'])).toBe(false) + }) +}) + +describe('isLocalRedirectHost', () => { + test.each(['localhost', '127.0.0.1', '[::1]', '::1', 'app.localhost'])( + 'treats %s as local', + (host) => { + expect(isLocalRedirectHost(host)).toBe(true) + } + ) + + test('treats public hosts as remote', () => { + expect(isLocalRedirectHost('claude.ai')).toBe(false) + expect(isLocalRedirectHost('evil.com')).toBe(false) + }) +}) + +describe('getRedirectHostname', () => { + test('parses https redirect URIs', () => { + expect(getRedirectHostname('https://claude.ai/api/mcp/auth_callback')).toBe('claude.ai') + }) + + test('returns null for invalid URIs', () => { + expect(getRedirectHostname('not-a-url')).toBe(null) + expect(getRedirectHostname(null)).toBe(null) + }) +}) + +describe('findTrustedPartnerByRedirectUri', () => { + test('resolves Claude from redirect host', () => { + expect( + findTrustedPartnerByRedirectUri('https://claude.ai/api/mcp/auth_callback')?.displayName + ).toBe('Claude') + }) + + test('ignores localhost redirects', () => { + expect(findTrustedPartnerByRedirectUri('http://127.0.0.1:42813/callback')).toBe(null) + }) +}) + +describe('getRequesterLogo', () => { + test('uses curated assets only when redirect host is allowlisted', () => { + const trusted = getRequesterLogo({ + icon: null, + redirectUri: 'https://claude.ai/api/mcp/auth_callback', + useDarkVariant: false, + }) + expect(trusted).toEqual({ + src: getMcpClientIconSrc({ icon: 'claude', useDarkVariant: false }), + isKnownClient: true, + }) + + const namedOnly = getRequesterLogo({ + icon: null, + redirectUri: 'https://evil.com/callback', + useDarkVariant: false, + }) + expect(namedOnly).toEqual({ src: '', isKnownClient: false }) + }) + + test('falls back to the supplied icon URL when redirect is not trusted', () => { + expect( + getRequesterLogo({ + icon: 'https://example.com/icon.png', + redirectUri: 'https://evil.com/callback', + useDarkVariant: false, + }) + ).toEqual({ src: 'https://example.com/icon.png', isKnownClient: false }) + }) +}) diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.ts b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.ts new file mode 100644 index 0000000000000..bc0df5ef05ee4 --- /dev/null +++ b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.ts @@ -0,0 +1,101 @@ +import { getMcpClientIconSrc } from 'ui-patterns/McpUrlBuilder' + +export type TrustedOAuthPartner = { + displayName: string + icon: string + hasDistinctDarkIcon: boolean + /** Exact host or parent host for redirect_uri (subdomains allowed). */ + redirectHosts: readonly string[] +} + +/** + * High-traffic MCP / OAuth partners with curated Connect logos. + * Logos resolve from redirect_uri host only — never from self-asserted name/website. + */ +export const TRUSTED_OAUTH_PARTNERS: readonly TrustedOAuthPartner[] = [ + { + displayName: 'Claude', + icon: 'claude', + hasDistinctDarkIcon: false, + redirectHosts: ['claude.ai', 'anthropic.com'], + }, + { + displayName: 'Cursor', + icon: 'cursor', + hasDistinctDarkIcon: true, + redirectHosts: ['cursor.com', 'cursor.sh'], + }, + { + displayName: 'ChatGPT', + icon: 'openai', + hasDistinctDarkIcon: true, + redirectHosts: ['chatgpt.com', 'openai.com'], + }, + { + displayName: 'Perplexity', + icon: 'perplexity', + hasDistinctDarkIcon: true, + redirectHosts: ['perplexity.ai'], + }, +] + +const LOCAL_REDIRECT_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']) + +export function getRedirectHostname(redirectUri: string | null | undefined): string | null { + if (!redirectUri) return null + try { + const { hostname } = new URL(redirectUri) + return hostname.toLowerCase() || null + } catch { + return null + } +} + +export function isLocalRedirectHost(hostname: string | null | undefined): boolean { + if (!hostname) return false + const host = hostname.toLowerCase() + return LOCAL_REDIRECT_HOSTS.has(host) || host.endsWith('.localhost') +} + +export function hostMatchesAllowlist(hostname: string, allowedHosts: readonly string[]): boolean { + const host = hostname.toLowerCase() + return allowedHosts.some((allowed) => { + const entry = allowed.toLowerCase() + return host === entry || host.endsWith(`.${entry}`) + }) +} + +export function findTrustedPartnerByRedirectUri( + redirectUri: string | null | undefined +): TrustedOAuthPartner | null { + const hostname = getRedirectHostname(redirectUri) + if (!hostname || isLocalRedirectHost(hostname)) return null + + return ( + TRUSTED_OAUTH_PARTNERS.find((partner) => + hostMatchesAllowlist(hostname, partner.redirectHosts) + ) ?? null + ) +} + +export function getRequesterLogo({ + icon, + redirectUri, + useDarkVariant, +}: { + icon: string | null + redirectUri: string | null | undefined + useDarkVariant: boolean +}): { src: string; isKnownClient: boolean } { + const trusted = findTrustedPartnerByRedirectUri(redirectUri) + if (trusted) { + const customLogoUrl = getMcpClientIconSrc({ + icon: trusted.icon, + useDarkVariant, + hasDistinctDarkIcon: trusted.hasDistinctDarkIcon, + }) + if (customLogoUrl) return { src: customLogoUrl, isKnownClient: true } + } + + return { src: icon || '', isKnownClient: false } +} diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel/index.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel/index.tsx index 2f4dbd6b39ef8..0d47c4163ca54 100644 --- a/apps/studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel/index.tsx +++ b/apps/studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel/index.tsx @@ -449,12 +449,7 @@ export const PublishAppSidePanel = ({ - +

Select an organization to grant API access to

diff --git a/apps/studio/components/layouts/InterstitialLayout.tsx b/apps/studio/components/layouts/InterstitialLayout.tsx index d17b469956ec7..1764b546c7259 100644 --- a/apps/studio/components/layouts/InterstitialLayout.tsx +++ b/apps/studio/components/layouts/InterstitialLayout.tsx @@ -158,9 +158,12 @@ export const DestinationLogo = ({ icon, name }: { icon?: ReactNode; name: string ) +/** Fixed light tile chrome for Connect pairs with unclassified (uploaded) marks. */ +export const CONNECT_LOGO_LIGHT_TILE_CLASSNAME = 'border-black/10 bg-white' + /** Supabase symbol (not the wordmark) rendered inset inside a LogoBox. */ -export const SupabaseLogo = () => ( - +export const SupabaseLogo = ({ forceLight = false }: { forceLight?: boolean } = {}) => ( + Supabase ) diff --git a/apps/studio/tests/components/ApiAuthorization.test.tsx b/apps/studio/tests/components/ApiAuthorization.test.tsx index 5d45af7e14b17..477bc847fd108 100644 --- a/apps/studio/tests/components/ApiAuthorization.test.tsx +++ b/apps/studio/tests/components/ApiAuthorization.test.tsx @@ -10,7 +10,7 @@ import { ApiAuthorizationScreen, type ApiAuthorizationScreenProps, } from '@/components/interfaces/ApiAuthorization/ApiAuthorization' -import { RequesterLogo } from '@/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails' +import { AuthorizeConnectLogo } from '@/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails' import type { ApiAuthorizationResponse } from '@/data/api-authorization/api-authorization-query' import type { ProfileContextType } from '@/lib/profile' import { createMockOrganizationResponse } from '@/tests/helpers' @@ -122,28 +122,74 @@ function renderScreen(props: Partial = {}) { // --- Tests --- -describe('RequesterLogo', () => { +describe('AuthorizeConnectLogo', () => { test.each([ - ['Cursor', 'cursor'], - ['Claude', 'claude'], - ['ChatGPT', 'openai'], - ['OpenAI', 'openai'], - ['Perplexity', 'perplexity'], - ])('resolves %s to a shared MCP icon asset', (name, iconKey) => { - customRender() + ['Cursor', 'https://cursor.com/callback', 'cursor'], + ['Claude', 'https://claude.ai/api/mcp/auth_callback', 'claude'], + ['ChatGPT', 'https://chatgpt.com/callback', 'openai'], + ['OpenAI', 'https://openai.com/callback', 'openai'], + ['Perplexity', 'https://www.perplexity.ai/callback', 'perplexity'], + ])('pairs %s with Supabase when redirect host is allowlisted', (name, redirectUri, iconKey) => { + customRender() expect(screen.getByAltText(name)).toHaveAttribute( 'src', getMcpClientIconSrc({ icon: iconKey, useDarkVariant: false }) ) + expect(screen.getByAltText('Supabase')).toBeInTheDocument() }) - test('falls back to the requester initial when the icon fails to load', () => { - customRender() + test('does not use a curated logo from the requester name alone', () => { + customRender( + + ) + + expect(screen.getByAltText('Supabase')).toBeInTheDocument() + expect(screen.queryByAltText('Claude')).not.toBeInTheDocument() + }) + + test('shows Supabase alone when the requester has no icon', () => { + customRender() + + expect(screen.getByAltText('Supabase')).toBeInTheDocument() + expect(screen.queryByAltText('Acme')).not.toBeInTheDocument() + expect(screen.queryByText('A')).not.toBeInTheDocument() + }) + + test('shows Supabase alone when the requester icon fails to load', () => { + customRender( + + ) fireEvent.error(screen.getByAltText('Unknown App')) - expect(screen.getByText('U')).toBeInTheDocument() + expect(screen.getByAltText('Supabase')).toBeInTheDocument() + expect(screen.queryByAltText('Unknown App')).not.toBeInTheDocument() + expect(screen.queryByText('U')).not.toBeInTheDocument() + }) + + test('forces light tiles when pairing an uploaded OAuth app icon', () => { + customRender( + + ) + + expect(screen.getByAltText('Acme').parentElement).toHaveClass('bg-white') + expect(screen.getByAltText('Acme').parentElement).toHaveClass('border-black/10') + expect(screen.getByAltText('Supabase').parentElement).toHaveClass('bg-white') + expect(screen.getByAltText('Supabase').parentElement).toHaveClass('border-black/10') + }) + + test('keeps theme tiles for curated partners', () => { + customRender( + + ) + + expect(screen.getByAltText('Cursor').parentElement).toHaveClass('bg-surface-75') + expect(screen.getByAltText('Supabase').parentElement).toHaveClass('bg-surface-75') }) }) @@ -282,11 +328,40 @@ describe('ApiAuthorizationScreen', () => { renderScreen() await screen.findByText('Authorize API access for My OAuth App') expect(screen.getByAltText('Supabase')).toBeInTheDocument() + expect(screen.queryByText('M')).not.toBeInTheDocument() expect(screen.getByRole('combobox')).toBeInTheDocument() expect(screen.getByRole('button', { name: /Authorize My OAuth App/ })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument() }) + test('pairs curated MCP requesters with Supabase when redirect host is allowlisted', async () => { + mockBothEndpoints( + createMockAuthResponse({ + name: 'Cursor', + icon: null, + redirect_uri: 'https://cursor.com/callback', + }) + ) + renderScreen() + await screen.findByText('Authorize API access for Cursor') + expect(screen.getByAltText('Cursor')).toBeInTheDocument() + expect(screen.getByAltText('Supabase')).toBeInTheDocument() + }) + + test('shows Supabase alone when name looks trusted but redirect host is not allowlisted', async () => { + mockBothEndpoints( + createMockAuthResponse({ + name: 'Claude', + icon: null, + redirect_uri: 'https://evil.com/callback', + }) + ) + renderScreen() + await screen.findByText('Authorize API access for Claude') + expect(screen.queryByAltText('Claude')).not.toBeInTheDocument() + expect(screen.getByAltText('Supabase')).toBeInTheDocument() + }) + test('auto-selects the only organization when no organization_slug is provided', async () => { mockBothEndpoints() renderScreen() From 90a53a5ee20554a2bf0ba2fc0467310f036d3e21 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:21:41 -0400 Subject: [PATCH 02/15] feat(studio): add shadcn tweet to sign-in testimonials (#48204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Chore ## What is the current behavior? Sign-in testimonials are drawn from the weighted tweet pool in `packages/shared-data/tweets.ts`. shadcn's quote is not included. Resolves [FE-3978](https://linear.app/supabase/issue/FE-3978/add-shadcn-tweet-to-sign-in-page). ## What is the new behavior? Adds [@shadcn](https://x.com/shadcn/status/1672913636132790272)'s tweet ("Supabase is really good. ⚡") with weight `10`, plus the profile image under `twitter-profiles`. ## Additional context Weighted selection on the Studio sign-in page and `topTweets` on www both pick this up from the shared list. ## Summary by CodeRabbit * **Documentation** * Updated tweet module documentation to explain how tweet `weight` impacts Studio sign-in weighted random selection and the `topTweets` list (top 18 by weight). * **New Content** * Added a new tweet to the collection with an explicit `weight` of 10, making it eligible for weighted selection and top-ranked inclusion. --- .../images/twitter-profiles/TUYae5z7.jpg | Bin 0 -> 25313 bytes packages/shared-data/tweets.ts | 19 ++++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 apps/www/public/images/twitter-profiles/TUYae5z7.jpg diff --git a/apps/www/public/images/twitter-profiles/TUYae5z7.jpg b/apps/www/public/images/twitter-profiles/TUYae5z7.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6319b496ba60bc0d37b32d81977d10774181f244 GIT binary patch literal 25313 zcmbrlWpo@n(=IxW9b?QC$IQ&kF*AG25HmA#V&*Y3Gme>=nIUFoitU(Ui0|zEp8LJ` ztaaBqKTfGjdb-plb=OGhkyQP@^1cg@mGHDO2LKcl0CWHV03HAh^#$+|f}kMG28I7W znC}B70Qz6~KRN%Qf6)K%``(8<88Ii+6eQ9{#0&to6yv9;gOM3I1glfGR)=APOJ> z7z5k@RsdUoD}WJ_+Cyyi|F6gPubv{n0it69i5&r60B4AX1;7%b%K(wO0bBrPkklL^ zH-iLQ$Q2O!ukHV9Qx|jgf69YA@+VmU;N$-L`)^1)fXf5`-ooDBUkcve-wFW$=oJ8< zH|f9n4oMJmUP0t>|CLc>0|2N&06=s1e`UtS06+`G#-Fzwjhv1Ctp^$se=vu%xZ6?y z09hLVz=YTuQTP8g|3CJIl>JZnfWn^ufVvL=AU_2Fq-OvCRFL{m_1@P3!2p<#9}!>? z{vkwo1b9RwBt!^*LPUauPY^;z{wMx~ih>Hks1QO%K}SbNK}SPF$AkoQWGEOI7-*-e;c>)+=6CmQ8YoSZ3J1_3 zg+W8XefR(e`w`~f9|IzQLC5&`Pg!iDs<2oh#@OWSD$aokaAIol`2|K!KkE8$=C5z+ z`}f}00H2^CwL?K7VL(zlA`Er%l0xjh^aK}Wx2qpnAEt5Lxmhh~x|olw*1i!)&rv=P4FM=dBx zem`zCC+QtcjZ1@!T#eGz##UVA*P=3OO{A(@nT%sY4R@KRfE(3>-AVtscBMYau~5M- zRl-{($^_ot?;Xp=I-DA5rnbDGxEj^I$T|45BPC3eaW)62_WX*w8I+P2KbtU`VTW9x z*HD^o*5!q)7feu_n$*1Gv2#+i`aPq@EDR{PQ=>&?Ue13wDR=lh;G=yXM|&!^3mI-~ z{?r-&54Yy;UNwA(c!I>(#g;-)Rb{s=9cHw6I{ZqG0s%;>-rxwCkG%MfaRIlAI+AW3 zd4Wg*lIj`#^7Dp+{s{0U%UxAB`1(55Cosn>yoL@=z%g30@O#B+7>cQESFc|RH}Z&l z((=`DM=&2yp)=dmu>134v)fFW_#J*J+YfB;@R0W^P8riDXdU4CqYp`xxT*I$ZS3}z zTc}EJ%?wye58kW_!<@!s`j9oOsuy%|RJn|(^yV7h4dWv78^z3-x|;{(8d0<|$S?4| zn)I~s1>)`DfCRqX!hBCVRqo$U$ z#FczS1YV|lZe-(x_{jC9P{`*qJgC){)=jFtVzNp&AU_3VlyDD zljOliccryFgqEuL>W~D!*QtIhvr?I`#FeQlar-Y)t3R&xs+`VK&@E6XS#fH~J}}iE zYol9B!NOf+UW=W4^&jei*|=Bn=tiW|9vqD##ZEJ?SGUu_zjNgUf8fX9a#{fjRU(xY zpAVjUes5K4*T`ctWim`k>Wn}AEMhw!71(D8=ne3u;y%> zTQn}p+U|x%I^!p2=W(C@Zx<}Fm0eL;3Aa3>%Wetr4x)xKwXJm-R1s>FioOm}Guu#~HR6nkBrXE1cz| zgaJzu=<;`j7bzc0F3Y1c&DW5_cT%SLmqaa&odgeAl8X!3^(0!0Lu212pq7M9M5#&D z79D*j8}Y&D0kTu;y6m3Phvxm(RB0W&OyBU4+X|*6Rhkvg;;X!gDrJI8txE<4?CJWF zm=57bsVdUGTx#8>&9Q;*RO(|77je-N^((pR)QQjZ#`)vMah3HI{w9_EW%VzTFKm;Y zHC9u`Ec2INrqJ0P=Y$EK`xF!rYn+5^Tx4*`TKxf;Exkt@)9@6(SnJfaJw-nOPE^T% zR`61550T74No{P@@s-XPw6w%3jAsXTWDpVAj3BW}PZ*#@{iFAq`vLnX_M?uY?3eM$ zVLXCi?ucnI`#gF<0hS1&b@V?er6QVFqHL#=;wfu7sA!1N3&Z|XJs&d_LiUuiLB2UD zoyoMJG7%E&eq<~plT1Tr(5$zEGci4w4P8siyx!Hjc*&?UiXXzrxsQJ+gNbBFZCqm zNfTaBd_qr)49!?W5o;@9ejnfGL%erDxJ73b_!F0}!nCi#hE=nLErRCBl!-#nmtQ7< zAVIq9oYg&9YercQbYC`|yrEOn^`V!s1$xrNN(TG4yxI*Zr1HFw1sR-uMrQ`Qv{e=A z!JMno%$A|gu7G$j2KgI1NmsZg7^!BgM8&L=C6UaGO@eLYh6Y?K3x90iMh;i8 z`ex6Z&WHB$OrHSjO23c7K5Ev{bXp&$Vx9nC=RK(4@609+qCYCjm{DA20AKH@Io6U6 z#BzA@zBINWk7{^D8#(PY&M(OIOI`@9TqzQVObk7Rd`iNcP@rj1rjnMx*l`+UmQ(pUfMWtS~BLrL}nIiHIXiP6j z>{n|&v)d@7F2Xo~q06TvnhIQ2O}B1 zv)YGd!QC(_rp|p`KM+XvPnBi;oZb37vu?KjC;2K>sS>YVN6@W9p#$NOF01vPL*IgC z%r!RoO?zYJ8zS@HO7inkjKA9tvOsSp1lD1BVZ@*0ryqnH3$t@3uqk14cI$0jwsH53 z47^hb_A4uhqL=?7Laj+KYbdB^)gY?CX*d@5n(M>rWw0=Vy&s&`E{>M=jHH`d&F z>A6xF7@a?&f7Fxep!Dgx*$=B7KDm#kXmutjN33Q_7*RvBT3=poSt%={2`r3`q9&qb zz)pbQ8|p7)Wu&PdBxf0a2%l=;tZugLdIwOdYkU%oYYiLT68t6{8R$YVO#-q`2>uG& z&m}=iuyrM?@yWU#0OQ8ag8bcr3;H2(u4pCv9T1gz_zsY*rdCg?2Tl;MMs;h#HhQE| zOvAUuq29{E+&UXmIu$JVie~qAi}3R{?bs15*NDvlx$5C9%!jA&4d7S^a9l#}0+e7i zmjtG7R`Bb#UxutMOtfZ~zb`ie05|{-I>|Z?n1^uB&g4v`SQUpViUN3&yu|_|peiZO z-hQvXMY5-yMf|)uIAa;0AeAmylD%AlO_uqS%yriuSq5b?xgV%B~?%m6$@3!yJ_1=Z!0btNo6`@mA}xvRVv$E zH;ZS+GFg$kjvOn`eX##+F@oP*`}fxo@V1M2UJ>rlb*eh1+da`V`zm7O4`Iu7Lg#$- zVxlQonueO~WLT3QLOpFvX+1WK$r4dbp8;(tHhQz)Rs(_NMoPWlqf)LpH)Y86*ZY1ypLu(Y;bV-g!X0ya-58^Y>SxNoxH5plNc6i?b8E3u^n_$A7veC_k)mQaS zvqWYR|K%-5kWH4)cbS1u zb|wpgYY zF6ti95q!7S=)Wq|G@17bikp#Al2Z}{j3K$4ItfzJ_WNnc@isZtImM$ewW!J7%<^-g zY2?QHBx=rk>5^br_%y+B^k2-}PE53H{<4Bg`h991ndJ%bA&Ecb$=Ce~_X;YUq=c@R zCVxe!G)c0WGE(r;7a~tNob7ZoK#r0vn)dK3?{vl? z4AFN5sZD$iSGccx9sdFLrpw6--ysa{7VD?~Ry=H7`+^8Mb$thTZ&w-5of53AtTUis z)NQQUcRq^62oe5t^Orpi1IoyeR1O@V_p~5&S4ddWS$prUso`LFe3-5%s8~?4021zB zrg>{L(;i%7kEjUl4YQp;V<$>B_9uS+&HSyX%0Valt){n8AkUfjqka|&#^4BsqG)~u zm>%o1$_psL2^QliXBCgA`X)~23I@C0jBuNB7K4(!Lwi1kzF|{z&gxU4dx;QX$!|@q zV%ut)75kr#blp!{e;Y*99C6sVewcr+c&-M-QhvU{s6XY4kUn6;g^zR%6j>OabKYT` zqM#>z8Gco#Zcgm&NyvU=eFvZgsJcGDMe*p-j5$8!fPY;pBD+FIS3Q9vS5OhO0xX#X zSzje+30+X-Bhl}ixs%@_5>Ga+7@S+!%mr3!Y`Q{EO(TZvri%&}$A6lMa^%^rA<`Cj zJR|-tH@97tG_9bTN`MSYsl6J0ciJ9iPAl;K^Zl~3vvM%O0@3mK>RdjxRYBNuJ|gr{ zlJc>rMr#D~-F~MyC>KP{)+S31OSxDU72U=ASk4-1GFbNG$GR(0Z$IOnfwPe{q|Z{o zE|L45@_;u~O;n|`PmIwCE=&hEl{%K;MXZx@FB*tz&p-ODguUd{tQh@R8t4d9JHmmD z=%Pgfcx}EAEQx#O(4Nk>uto%+2F*8}aagW>aX%0E~4Ns>L*J1&yN7;+w4T;abbC5By41S9p}}=f5v`Tws?ZDJ$QGjU1)oT7VD&74IDV2qACsmA zPOE?(VTj}&NAnxJ1CSh8CnF$fS_JAS_0k)xXLRRBI2Qm=368N%%I< zk`Yren2xre`t*v~N&FE!g1iF)qVgWBV2+*|tUsywl_(v=C$zHLRN^dT#fy*8@5 zC@O_?RL*7y&m9pfZrnC5O$*K2mtIu|(rCj^;N#;pJyQ|51~niFphjMCz?ikfmgypSif*7 zOdQvKwBL;9#VN^kHLGRDIF+%hAbAm42gThpunHDe`l7(_M3DoPCuDEO$Xy)>Q<1O+ zqnKEf#6?Xyj$|?_@Fnp}^?j2hGGI;;YXe6edEy7Uk_FGRFm#X)5nj zE-fXn{=sjD8yd&aF7naub}+e7v9c-8(rKq+*Dzqm5F9T$I~nm(Mz#5|1$#)9WVuR{ znqxvA@#_ywWP6<=kN(I`5e8fTs-z~;J0OrBnw)i2YBxhd5au#FAKcVuLH)5zWS*0k z$Qj{_pDtRxg%Q7pLE&+J0%3E96vvjI5JGOaXHtxyZ+ug5tHw4{?(FyoOTGKC9T;}D zE%?*`U;Zbw3V+n-^YZ35je34^*U%{|C&6`Ddv7e9Yb7zQ%v8ktO;}2$aCSj~>g2+h z*2ZVi1Z}Dx-t&U(>>Ax&DWGpWUbwh=U0vLw0ERSfvN{3;Ha95PXR?w2B2TGTi zq>~di3KnSvT`J%e4h{m2G4rlXP6It+`1D(Z#FnF>ElE zp?Q?m+v9wJQIsbqEh zo@lVYmM2<@pVlpMFb12<=l%qu)R@|}mrv*sxN`?$H{o_b?NvH2!RomX!(=&`t}m+J z<6&<~kCA${y#wk&w)H@1lH%wo`c8gPzhEOyZ_zh(l$wkdvfMM=_hA)^Ntt@(#b`*TA z>s#(gViY%nMnqV9T(F<2cX%n$#F=E^+o(J|TwO6VrC?8HFdLZ2Ayy5C@P7^(0<{mt zL!Wh3Fb(~EM`~{jT;}aT6|`UeSdL||t_Zp_1F2jj>RdI|BWZC>^J~ez; z&7F7$&>u(NgM(9eQ#|)KGFvcxd`N0kPrJVlMY1koO`)sDUdDLe0wn~J=J{xKOcL0{ zGA@I$NiQE@*hc=y4JN|Y?9)*0#QFI44E6NsT~CTByZ;iSUS^-|GMf7vZ-G2;lqhuM z&Fky*By8jvnqV7S$6xwXZ6jG(L}Uk=;N)*wyt;0)PDOSO6R_hTpnU4Sm%Jy+u48s0 z9;i7Y?s>y)Eh#bGG#dDEl*W$~eNEvz$083*tES*&Sgo>u@v3_(d5O%_wyni76%`vq zq(O)Pj~AB@`MaZGh6S30|sWi7HMLEf!6E6a`L69o}|T8_*pkmm<1 zS=~+W5rDU<_Ak=6en)E^>Gg`34jUEUEi0o7+e zEm83!JbFtUef1F~ZCq9oX#!`X+D1LP)05lZV_HGFA?vN!r!dPo?vuma`_?XL9%c2x zA!AwUwTcw|7jlP#XeszfH!$W=@uwQVa&3W-W}?OV9h^s|Ri~DA*Y)(zFeXhfn0>J3 z^|l9Bj}l~~WkcAUiRC;ok0jr5AM9M~(n=kBC0En9TpFCFlMG$?} zl?3(oZ|wW`I^kC<+V}IfCh81MLIsi{Xj2T+pGlfPfugpG;zYeL8h^N7eHbxGSg1HAVp$8$#dJ>D>W`mekbjHz#zR@-vQQw7O+);yQ9^LJ&#^GZe z1JuL3B^O<7NRq6tY$)QSJ2idsKug6ox{ zZM78@Y?6@Yyqn|=jGVn73iNFvPM z0maLwD3mD6Ks-SVfch5ZJWNa?r4OvwdQ39|T}Ivw{Dscd-9e+b)ynq**ojt88O+5D zi)N4m0^0tfU~7%)ya`rv!SxJ5Zj-U#xV~o}n2{TH&?oYa>-&Vj9msb9Pq%6eUw__{#J|=aZd~qt*cg?xlDlhkb4^3$O;M2Z>+F z4mUw{zXBxw!B3VEnq8dWP61K8hY}NGdp2{dMiAwwMmS+jm z;|nrVg;&*I0dxittu=cQJOXKOTe9bdDnA3L?tvKHWp%M}*`)`?Hl@?AK^5ES>e24{ zF~-<%@!ItIWh^38gqPR0=Var)=gf zBX%KdiPf{a`iHOsKoIm)ng0?VqZJk%^=+jM8}2B18&KRBE17OIAU5bm7hE{omD9M#A2%?LDuBuLVL+Va-|54li#1$Qx$oMo@6d_ zvIMq|fXbgXOy;~|(oxpL>r~uZRh6^4?!8@u0THt&Vi`|G`fuj9GX7VEPF7CxW0~n7 z+ZX{PY4U?Y%Ay47x(LvoQehbapX{kRqbr&;D`~0fZ;cXa;@~fgxvwgB zbA3`scxcGs+|HZhLOLTop(lSTo3LqdadR2g$tW{FBPHGO8IgpBl0hX)#ev`akQ=OaA6TFoz;xC zbUZyjuyqta)$FeB`NT}4@$I$7$IS?N5$Ut0G(;1bhN#6)8m{K2Y+uR(-kZq&b zvvXY{EPR<;(acy|FFYHSgIy(LGcrhYR$xmqKsY0{q>rUB&B%*BTE(Uw!_RZubp1(~LxN8%l+@F7 zZvG&i3Ei?*?m5|3%=iw_(8RdNlxa#90;BV9T-ycgr1kJu9NJ#p>}+Y%o!UJWov^$E zm!i__Af`+Gh!!zhk$J2AM3j`=o?*H|`}u?&l+BHqZTf$I$uw20`^l3UA> zwTbqcH2?2JO@5|L!i7eoLrk2FNrinxrqzqUyX)|MMKvM;aedx^<0poiFI7KwFKp6V zM|_sE^MaVL;fil_r|Vx$U?MZI%S%n1C6`-BbdCc1a!~@ga!?^HB6(a_azC!*GEPI` zMRBPl;q$@gK_r`cY=_AXH=%*2Va<5%nqAUehae5dk@7t(lsM*M&^0eW&mNXDBKy*5NGS z&hD=2XF@EV6c3@l4heqTrIt3%4+}=rkc}LjoKV4{!ZhEB6CXVyc8)NPYjR78Bt0M8 zu<{!#7LUWCA@h=Zf=g2y@%&SEtf2G#;z7KG$(5ZceQbe>r<>i z+6Z>e-$lCpiZ3!GjOF4E3u|?+M8)%J;l%ez4!$T4kjW_)&ZQ7*_#pfQbC$*PWBF2% z<@_=HYZhIEo}muxTmQ#5>@cLhOdwIS6h4OYj}hafTnU2*^HNmmc`j|uYwu9CCAIXe zT}6uR7F6$h2DdmqK@&5OrhTpZWwM8URat4m)G*w0zyr7u6DmB7nTcD6DDHkWy@giO_D)z!?Q>~m7e$@U{)im32RDYpl zNxcdcygVCGF)eH3{*TBtTAKVqducLLMj~|6d*#|qo`G|~40WQwgXs!Wjl8EPiWQ2@ zcaoUA5XGuct%-B3+dqj4Dfo3mgqn3<>QSmY+ecopLsYF54ij&%rkpt9|9m<2ume)_ zvr_zC18&jbE+56Dtnu6;Jk71iN*Yx7{jyha&DjvSc$3-SII#0y$|Q;1HU7#Ch`)r7 zSR?!0f5vY@QOX!aXWE5zHI{&|u-gNpf*=A*r66aYxKvIkRZMXbuIFx{Z-wP#mx@`+ zbxNo$(^{;xDq(Ctp+sGzyI12{A6{0zzv`zZga?Giv%>oI(4=!lmoA6QzwKkKeW{cm zdp&j#kwebj5*5(y{16sJWZl8A*jU_emFcu{Nsj{^ws^|WUM5`lbsL=dN2d!n*isTZ zgnMdUiXpqL*uGvW8IwWF_#n>=&R0J*gq4tLi?I|$b0ZvCB&Ynx;FRF=?As4C{a5dzTgs_ zFi-r}p<)%wB@`Ukf*dNM7jy4+C^J-QE%2DK#iFdi7wLf_46BB-F9Y@mR#czhI%zEU zS=)~66Vcn<>lsJR8cdjfl>ah& zWbTNs^l79E?c|M>yCJOL1v^48B8I$6ozBgx&^Bw#BB{15Ik=8eHw>=S0{%C#u61r{ zDf+dgU7Mql*w-B)c(CKQHZH<5#7d0>_o1W%Ec-#G!*uezeClCR&^Nw*{#IWgS&h7T zpy)O=o|oPwDQD?IN%oV3qFBikHK_(8*I;JYx7MU9uBG`ghfPB!M-Zos3bSR6&Rs|G z?b5gUp;RE)hy2y>G-}FI5T&V6ecO-Rkb38NgppV*Qrd*EMj7$ouIM9y(xRM7a*xz5 zlLL=s50^xSWoDD(lJIpklDzrSZo}V1a$&Z7%XPLn>0g9m!pYi#v=2pw5mL`{VV{ls z6IMa@Izo8g`!K)!A-7HN=&%k@DdceLAI7PDT5T0kLyuX02B^;RT|U@q?g#66#L9B( zrLN9z@dS-rS8XZaRTliM=1v-URHO4VjTb!!&I;jr?i+m9)I$~DAB4}TCpqc(IT zEg!b%4q)kMQM2;f!uX3263qZBdaM`+TP^41J3u?7y5JtQJU@KmJ|0)}9iUkAcYFw? zu+iuj?kR&BU3x)Y^yoe*2`PbiXF`A!|LpexJ+BZuM#717QJ<^GaVB9cHEy26H`$>A zZ$brKys~nqB)CI#6ePz$oUg-D_|ElQp^eU#1Rs7cG0{_fb{V1i>LVaF-XKzXweukP zYh1$*tSZM*^ou6#A?9XCi^i?FjG!OE@pfobW4wUwgtL_0o#%!ia+3C0g7250;<6f? z_I%U2);31F@v}+juCmQRnQ7`78+rCGplWb%ott@Vq?(a?^wYDaYD}cJi+dQyGnRrK z`}h#E+wOFH*R<2@mi17f?JDczCMuu3+-8ubLjwb_Z^bvLjm+|Z zFc}F~ZpAcgW^RVK2)0Z2#9OqNp5ddqW5)oyo0(hog0`gvsDjME2I2uD(Vh4IuBTB< zK6%3m<@df^$Q9HobksPGAW|q?e-vCv9t9ovU@Ik1YjXfnTCbS#*cv?C|(^AMLqU#N7nG=jw3B~qS za`I}HAN~iC;oh9{yd*PB)z_;BeR>pPfdO_hC+oVMRyAv8f&RjRc3sU{w0TEh_7EQ+L+g4SiWm`QVCDT2K)V{=CGeB-c+< z#;bIAjsCcFvC$k}NDgb6e^RRF2T@XyVy3LzEAqi^9sFQzoLJtP{7)WGDz8&&M9EyX zJxCRxOQ>iB2-pr}vKO`B^G7+^qJ9 z>#+v^mIIuT(;XSsRhP}HY~|}v(G3FnPl+jRa;X#CIoBsG&lwviT`wDzo0Gd~`L^4u zS%&eJpbF<|sjIlibMt}sjIO1u?y$Sk^HwjCd+Lz_2yrstxb}j8&H#(+53zB4qW%@2UpNu>n;Uuu4eP z;YKRJy*0M`K?ZvYWsfPzu}&YL4XpJbGsAG&ylDKqv9J+)^vE#HyER=IuEt`b$_{2n z&EOi-($R2p99OBoxT+5YeF@pHUt-3VcaHh_A*!LCYYs54+Se?lCt+TX@~BtO22V~_ zz@vx0RaQUrhU4j@-rz3-yjO$FChnWboxz{NZm6+_I@4cTD9N}E-Ztpxyy*?-ug_$l zI)aV4|MIk8k5l`K-u?|cMeCz8RN`%7xY~boxM7x+BJo5uLa|zdUxQaHiYWo%p23uL zNb~e4)5`-D$bF2EIHC_LvIiJ3C5BW`BA+Bp9i@>?15!+jBtHfk z5{tKLX;>RN&q4lJ+e7m5K#hyZO&mJb5i*LGizk%OUwBgTJ&Ln!EKw@Xn6pLzMj1(9 z*wg06j}g0e6-J{BfB)X|1>#?|Q|vvO?QL?O{Q0x3B1d%dyy9!npn)J2vHUHOP`rLY zxN#eI&-&TPFpRCrZbfR!+;c{1F)e#=ad%{RGgfi9u$ZN4A;YjL{6UqOV|SBLU8)|O zp6ESyMIWtCzk7v$Jw5O;(HbUtqxVNpZU5fX`iFz)J?^?TVGp9qRk!Je-kMs~$5q3!?lj))CWSa(i5H!qMs&4q33?%`(cIao)he_Q ziu14TksI znzIE@tuJ)GkU(6u2vcs z#su8t6YN|;PQNCBr5V( zBenko@j41B^SCPk&F221MriuID6eExW%=MFm16|s5Wn$x-XDo^X`3&d@u17>gonU& z&D{TH%sfLvnX;Ub*mQO~fKy|V+2&GCj>-ABBccP;1e^@xviDGY$b^ci(~-CP9z{=v zz_Xp5Kaim!-B8E5O4oY0ZmUn+M<|lbjJL_=X)k`2Ar1{jZK6B*ai>k6GEv^a=9zgW z%CB?erf*t-)GT^>pp`V|w1p@tVC%kW2HPqz$0Z-r=&spXQJD1MCvp$gp+%6f8=-pq zy@3i5ahr#Ajcgle;hbcdyGC)B@)F| z02ZgMIHkvxR7^PH&?4CNszWdnGmt4=6|(XL0_2p)SSTp4L)>T9?Tj8^#^iRtA)B?W zK-vffd&eH+QxPkp_%&lrIZs#ps#wVY%d1|K$7yT;37_K5WH z^P(<4KX}{^C5i*Dc~ha0nJ=;)`lXxQ{A7UzDbOhm1iQet4U zA@d08KRkIOTzFU-IamqRI`hGBGs%VcHs)I$eOI&6!xb`aMnAF^PTs1x0n-K>XYjm9 z_|fMQgQ`a8C37{A#r_mMa(y!Ie zKxH>I_R0!FxgAs;Dix>Oi$budTNvLdmavbH>BcN*EnFuPsYY5c{x&^!sOzR=8gvOC zv%Ld!kv>|QPx&1`<Nok-V4Oqf;JOVQ7%!gAW=n}|S`N!Se zOQtKLO_5C0GZFXaQQEt=dX$Mlzfx+U$x|0w8C-#{eyZFV_41(CNs|QX&|ITqFMf*- z-*5K|appF+QSvsT--w`i{VDciM`g)TUhAC4^x=YbpWbKcBHeiVgF=6qVVhiD23I*4 zVb)OqT|YZ9g^NiJ@=l^VgC_~xgD^Zvd=5IhbFN2TaqWeax*N8bg}Hcoa$$tLHNlu} z%Fnw;YP2?_O~#M9z1pDDTOCFTezWLCr~%`8OHQ|eQzDPkU#XqLbs2irwgRwSC6+zx zqVZAEcv31KeVNXBrlrm(?&YbFS`a>@RWFicnXv4{c@KHFCBSDPb)6ZBs7tT4ds-Lp z7A9A7D_YXAM~78y5ye&P{j|xi8yCzgm!J;gn@8V6WTnNw3Wvm+}YE-DszZg7q{=*{0( z8Npm+YyU>HH(iGkTbqFpq)h3$Nq@i{)L!J7{Ng)=fs+^Mv@Q6^(5)x~rRi5w=2%dZdh8>-fP(>BLVt zV9FSQ!1}qxi=kHFw#b6y7cFNZ1~}ot1``RAEBZjZ^j_?O49l5s6rGXmW75q#=X&tG#wzic2n zBl{biDmuza7h0=jPwZtKy=ZO)W4D$8T-YId1Y)6yh?U(z_8`&p4=<%v?=i-*i5)i5 z)uY&>iYA<&al_}{V&+rYhw z=VG1z%}kus_=c4@vcQ>QRqTiAhajP8h(n!0C!I|tW3uH@(Yd%GYt&mK=bBmsRP66e z|IIs9k-_!%ILkmoc`4~w(TAIoTa2P#z}ZyRUBKY0N2`l!5Sq=4A*YfEmz91}+ES(m z{e@?uCpD4IQdUg(pYS71_a4vU5f!QW7P*A(`bnWT1%6}6txq@fxe>{ zR||9|E8nz~xjFZE*yFy6%@ObPk)|_7jRM>`xr?)0Gy}dg!C2Edo_Z~57XmIc&8kLW znf&>7fduX3r2QH0KTGz_gg*{f^kqF{7r7i7gp!R)!8259OCFlDK1ux*i}Pdh=R7`y zqm|eK?aQ2-`$w6{*lJsxCX4Qq>mN4g7JUM<~;C?PY2>i zuBC2O7)t$A^^;I+E39MbnWDwvuW$#0snvKMo`(I-<`Yt=#y4s#U6jz=^G$76pF;>i zs8Ca+Q~V-(h-_s`Y6>A76Ox?qPJX=W4R_W)sMU714C#3-oR9jojWNL_k^AkJ^F_*f z`XcB~oE84kGo{9^`8I~>z0f{R52Z=079_<5c4tHd`s(&hhXZS!`gI6s$0*T0a-ww30H}T6^Ye4~Fhwk7?t#)Zs#p_0olH zgyq>VtXB{I5-$C4N-<;p4)|!JMJ>(IUa@jW>;V6E#CU2ZNY88bC&lI=6inzv}ues}($cs2yPXP1Bosn2*Gwf`(c;9xgf) zC%G?f3J(*ryiADd*m7y~_L7S8$q-D>H~c4sL__-%kgS2+OgLjop~&g>y>qv){k5`- zCC9+okx5qvx&el`CqjpYbzqxZ0q5i{zW#-?(cjCT23v{;3hCU|RFj!aKB#%#=s3uN z5-k;#hFxdk%ME{vyJo@`X71x8C|^|?tKbT|s7w1;B6Yq%zVWkkOP|nA@3?I{*N9T6 zEqwDbB6-AQW0KcqIt)(0L$x?rrtzt4JI{VDvZr?)NUZC!36+E#NE3H&cwsdwA zX`K~F7hYs}>=RL8m$2rac2YLfprf;x$bH~65`{JI%#|d zNYCR;R6I`^TIGg#YqAJ<-N*XVsQi)0LcWt)P{DC^uY{H@BT3jS6!4)yZ+^g-(KOmQ;#RfU*oOYq znM=eyJA|ez>0ask2C?NYjWwNNb>vI}*x6*2+uq%tCLw)D7vuV8z=km7kU(xh&IvXf z8ibrI7dWBag8(1`Q?qbz6n|GbV#K}JvH&(YW{nwzgU{CI&qz5}3YJINd!J+FQqdg+ ze*|?_34tw6_5Ld1xj3#EzL7axqFguWl$A?nISUmd=xb8S_|Pde3O8pqLp;#ixca`O zO56NRDYMtn;~{{Rcm4`Sw+#M+!Pa?%~E$so4g4p;+o$U|Fl z;rZTzsBKGn1MwEUR-eW^9qK zZ{ikF@>k&Yc*&J?&B5&AOr%L1?2>fsXEhOft|<96ckPWfD!1gqq>WhQ6G-LA?A&gU zK{Gu_&Be(g^(61TYXDgU5L1YWH#Yv!!_ZXSG}`80v^vAmq7oug1cAeYy4}dGit5aQ z2qLbag5qyeRUIoc%JCaVfA}swnr3DW#O#EW3Y4bb07t#A%3QTHRxI zICdPNFk0sqK9ZmTQ3pR8QuLDTm2X(8IzQd#hf=jO5T}%840XQ+lNoWS>He%#gX-o< zVlI)aXp9}FZdG0SLS7oq9~A!pVk)il@k{%GR%|03#cS(hrGjTPPxsC0WLpz-R!X1i z)zePiamVJPSS}Zfx`MKrspPq^Nx%BH?m2pfqPe*St-;#Hg3>r;Jkm!P4{AeYHEG5) z@rV)i@bsl=C~5HMvQx&`V{aylEK;{eVKr4|E3x$t@}`f+*8J;GjnYZx!6!Yoca>(W z(PLK>O^40ZL7DMLcsTf7>ZrQ=N;YMp`g=qzDauLY+7bD9tZNI>n%o$O>H(>P^EGUE zb~8tdusSIk=XegswPn-O8Q&uv9;T)vmUtc6a;nRRUo>ZyEdxKw4ZkgE5O7P zj&YLoo2cU>g}o_*sb3cJ4ei~f_mmu14Mrs^YH?WvnLe(Szf{FFNrXF_h><0yIh$n>Z$6^&QF*HL zt@n0nOgH}k*-*4r8wHxtz$Mzy(O@h9u;c(hy29s{)aFSD)sIQhVxkTa z;_p7FQ5hMKj+amvxfBP@nT5`Xj*CMfGe?Qr-aGO;vl19wq1)uvZjk2wd- zrqobtq!;Rqu1r;@24=CPt?L*60D@2&?`BO|@+-QO00CevWox0tX|-*SSjM@#9MiLE zi~253@Ux2QXTdCub56$|N>T{QH@k`@Np2f0a;~cniH>}x9J_rauR6*+rg!RC`Nlv$Rep)IkmC0^BWJeCyKd;O=p(){{V_@jboyZ%)@CaU(1wXBt*j3 zbZEiJ#ha-;#c`PM@oQ@)cTzFO+Oq1}xhHnErP<%vkFvz_ILI!0++>mccb+7s74VF? z`&$%v)U>7|{VIO1LY4|*nYfL(vGEjzEkS#TcRmVHRYR<9IV&Xu?Q^2mb#5~x^(qUH zke3Q?>Fs}I9DyJa5cetCH`PSfIu?@BJ=aueXOaq?c48zB;&`pJ4VyRxnQ1d3*KT#l z-|o6`Ks!DbRn2Cn-4PKAuv6(2az^~)E5l8>hm}GuB;T1)@IJ>WPn5H|fTp63Cn6E* zOGhzbzaQ}nbkJOH%AxW}51gkSZ`O%mibD^sl7e{Wi0{Vh`WzB+jzeWLrbsl?iR1W} zwzPHuHCqAX3f&@OT;K`IehbaIIgLj1Pgx$;KaYL9E74I%*;?1M-LC+TNGcL$c2j<& zA}yJ>*_iOErj=+jv41~K;RSpR^HO#uZ{5hS3F+M=kAk8zOIr_KCogAF)n^$GSbV~S z(j6A0$D;yv<&~dRQ69--KGho{h|;y$D9**j8u660?}+aY@=@|r$1hopD__O*YQF`( zzCSCblP`6ET8!DwsP9~6Pd(Z=aFS`{|44C9j()->8JQOyjpLgC?ZmZrpUc;6c$ ztTJmJ$lI*(J=qqs9*IB>^NC5j3xr}=O&ks0lC6?Fcv%mg&G}I_VqC>?vbH7yCLVz& zM3Re2DE_vRo`1siUBvW*7Q)zNs>4KV9jmWiVhs+3{*U6Z4KJoMM-c-yT=|EgKBLp4 zoV-;gEq+rUvl1nMQ|aYaCz;gLPMOaMCkYsV_@s>nH&gYMk<&NyV>6Pm z=zI@3s^*@#-=u!!*3(5*Pgh|XW3R)wsZSvvbPu^zr>T~lu_y_Xv*PAUGNz=8mAN5} zu{3gEvT#DtevSw1eo6wn1eJ$0OeLq65(-j^zcUto1mcQqnT?APRb*GUFeAR{%#gk_ zXEDt_#Fdqiou7Lva7@p=l~K(sHawFGx^F0ETiVV1y)=1Gx=_5BCZ>w8?RV*i8NDC#rjXe2~wBC#fA4jhzbDTy|Tt!Zwt^4`qv0a9R#CHY^MCXVG7QHXsli0fJif{;EIdhYv2 zDb)w5Rc%h)qN#eVM$$_$aDr)5E=JqMbzGJmX3WGi@>3P3d84={aSOSi^GpToqZ3GQ zNDB__v)|sJk+HMMzjoHQ3V-J%vE#Xs8%admt zsn&Ja%IZZG+z-7OKw|iiq-I8MO(#*m;<`_ZUDDXQy_rQ~7?GzfjxDphhp3?J+Fk{t3oiS(!`Wpt+QKlV%nUZB1~Qu9hWRANLq)JxIhAO8TQVT!G< z8+TBip@FApH&yn62E)ZSbB*?+hMYOwi-i(@SxV4hBz#6aN%Ra3CEdyLh>BdLJ2fMa zM5&`QJ;lP9Y1-ToKtfUdWB&khwNvQ73`d-Ofmpe|ic=8gUKk zu{`f1rsdgR{*<91yrek(ygrlNECES@Ry12TyoGlwd^n_4L1pHZ6q zqjw(?y6BB_1cnX;%u>Tk3Gm{chNM%L#E*d2)@mx~F(_Fy4{XrNHaKkrESDU0-qu@S zzs+G<6{jagCbaHzs3voD&HJPGi*Uu<$=)g=aFQ9uXx`neMxms5C&l`EQ;I5GoTM+) z+Ps6(9VVt~I)FBZJ{Ny4^CVjwRnWDsXm;rKg`jkuN1lpDjxoA9efhF1E{6~?F}J_D zPd74EjdFn9_zUYJE)`(Z*I{0Aj(5oz;}eRQrPEi{43bsPO#cAZZl*Lipq%9@2O7g; zidIjT%}C%R9um6Z-w^{JnKRSOX4{z%fQ?cw#hR(052la}- z95Yay^v^kY#O@AO7%C#gSaxdWXBM6yuKgKLCrm&DuR4CM4O?*VABsuZL5RDJbwT#N z>8Qo){{Xg2@k}1l!diHQRNLABy|7Z_F%KeAqZ+UN*D5f_aS!d1D1;Y1xOk&`nLmh1 zYw7$zf5pg+{_$cz+THL_Xd&%`#Se8S@d;0jB*cHUAA$!-@e%(375P*GYcS@DRKU&B zyYA%vA%3M~{{VZjFYO?jpV-p1-+G zC>%N4?j7y^3UTiY1f_9$w%UDobWP@7p7nmjMYN!#VIvg>8P6w$`#+JFnTPOhYpqmj`$uVaf| z!V6qB7Lm*(nk*60ZplUTBceW${{Y@feT1A{{WA|plc`{pCIW><%fOQdTSPu?5B8)68S8~ z+m7G+F939vMTz6>tg57@eE4P!=3gs%y3e1;FZ<%k~VTFU*zv&vo+E` z&ByuNr8HXrj|9T$aQ%93*YVNH`7!eh%#)~lv(*#42es`0{q1k6~ICLrzvF1;HhW~r@$F`7!ZHW*vt zeA|s)hN8i$v^OOMT;5fL&hO~8pV;U7xqn9W<(`ePt!|KDepkCs4En!UTao&&&y{@# z(kU9;vCP3WHA5d9(L1X4r*l-1$QUaSRTQQ!3lP)nDgOXOS2^tZxK8)&FB`yj8|+@u z(sxNggkm^+@7GhwO7Ekpo%W3W&nM|vyj4o;uzD1YG}}y)b#i7|IPoc3S#?hbk>amJ zHnImo{w`??I#bwXPE%rTM-OLmc+RT&#TL=FjnpYx8B3aDf7U{BIV~(k`pEZWZ$9+K zwm}VBWg8hj>%G0|WjcmW?NG7IaFED6B$0c(p_ z*Bw`IN2-eLv=yi6I*U}qCEeVRy5-SJ0_LnJ?dn+{Fhel!MAeSuHDkFA=pbGPG}Z6T z?n^nXvTBkybPz!V5f@hpvR0kIO$qLipT#6X=1`N7o!ZE*wj#Q!xJE2$4yI1H8?N!$ zT9g!m;`VgVn#+nx3G;5Ba*azw%I|5CaabWPwVs>H4 zHm$`%{pY%ri2nc}_=@eWm8GRKp5~4z7dp$3)IJlsc>D_Bv%4fK z2pEN21YkEOX4X{-h;=x4fD&8RrwxX;^V~eKv~%|CuEukAe2R2NzX82 zf7a9<@WFHNQ0{hjT=Pjvjt1k&aBVkJEOo+&0Y0a9$tIlBJ)X(xI~|n&02D+Qd8rj5 z4gKr9Q5nD`nZ+e->1N)$t0-#9c!F1E$z7W(o`_%gHIZ8vWw6!KM_ClGOwlA$ zvC+uGv^kF!BjCO-X$BJf8xXCae_D#BSbuTL?Na%j{?Qm0(b`AlO>djUDWjYIY+hzh z?zKkIor=mXMU80WLaSd-e6r=%LhlRMzKq9;eKUjMb;Ra;kqvk2viZ->*Dsm4ElH-U zf==hJc=T0V%7KVr^7VhNQ~uuUzn?0cV#f(Q3;R^Qoh~?^i7SiYhPDZ4>V|4*+wWui zZC(?v!yScIR#Cj%($47b;vOaM3YZ;FBja}#8+&~#%-t*DqMs3lv8}UlCfG+`KIFq(6l|OYXIf%#g5Wn7?_EVfMwAyy z;%vHt2q1z8Ah_pQC9FkqX6Ax(Pd1`>taQ6ES6n>E<}sy?4sZGj zE~r`F=@~}q?48n^JK7Xv4J>F5;+(bFP5YAvO_bEK2%22(0cH5MkqDke$#;ZsXi_>s zjkX45zW)Fw_mz!FBcyS2joj`&)Jx*YSflv5Ju^g2Gs(-SqfFZ3U3-+yy06MzvvrN? zx6qAF{KJP2nzYh}3a6{rZE*@!{*0S4fKZsHe{KiXdd^1L385Nmhv z>a34HqN?2g0Of3JM(A@&4m*Ox$z7VFgUm_mG<99@S4};@{++<_N*i6%(n+iRYdqv% zovN8P8d0w}?wA$jc@dQSxsBM`Ozk!LbRwL;UcNY|NESY8puPBfF?c0_}AA>$gxjk9vOu z%?+_U5u=-WXgi+lt)^XU4R>jqj{g7!7?i{01;#t>$}R8YgprmT6s2I_6K>_A>2+IZ zMf;MmzQb=N(=hH$S5q9Fl#=2IAd0$z3ztMM2(IkQi;{3iB^+DzZWY*?--R4S?;AU6iq_ao=cp2(r6%?rwXv1(z@A#WV#wp1#c6_ zdd_xkdznq&(t?Q)yro1A?_GMqWO36aE<#F8aW`mi?Ohe~7I!0!NEatWmrjy4yQgR- zVyUa5Z0-$j7g%lB0$XUwt42v{K3BFVfpn5=0C8K60fMs%hTUnTwzdAB3X0KuCZ3s^ z?7~`y-tO=Pdq<3Kz15RWq+uMNsbD%)hj|ri%SPo#oNx05Qd?e{(QBz2FdNkG{Et|g zZ!1|etB(UD5J3c*Y@9^wr`n{Nb|_T-o&39(6R0HkCyGQB9G|thLg6Riokhf_38ZeZ z_NR$R-*R;u?p#3x5s}u{(B_kU2aUxPj{O&;=Lwqwu*Hx`zpAs29^%q+mpC$6^q%0HYEPEBAwpK+~BclCO z-gZC8*RKZX%qBw7NNu&)u)@z`tZMUR{rAp;}p!9 zjFLBIWQ`AFxjCn3KJ?yw-cSwe^)o0Z}*isdXuO84!>HnHA6YJHtyJpm8c*--TV?s2j-bO zS=5~*mf6g03e8k=wUc=N0KLspSnP)V%1)*4k^k8p7co6&3b1VUXT zq=SM5z%1*^r2EGr`9z*Kd{hs;e-&%@gT;)lNZS3Uc9 zttGobG9g=r$?9vo~sLRgxRs%#+gN-inAG5wl%7YkU%KE0;1xK?D(z z6Nsv%h~1Jow(d4lu<12?Tq+#vc($t2;_Dl?B&fY;y!9%I38-Oowcv;MRreN zo%v#{Y;}{c;8k2YLBYvZ%_lO06_dfJw`#JWtHO8puK7ckc8Onb%6)JB2WfB6__OfY zpO%*u>UddFyu;-VLA4Sw?2(Twd~Ob7hQikb-l!U2A#{||F^!kdANG&JMP36HQ0vfD zHQ%u=*;Sn`bqTG zkHZv>%{~_MNdExA_8*h`tLGn+H`283j`ZH6LaR*C`T9zXt*WQ-x5VFBc4|^_U5+9b zy80WZxDV6VHFX#lkiuk#^|0@?e9(#DfH{TrBS*A~k4R}*SwkqA7wq+z?+cgCzL4-w zp)t%tm^UnDU35RHo6aGpO|v2wN=i3nQ$a=R90wGcn75jpX<0?NIKJ^mt`^Gf>m(ZB z;j@}TdVt_ZGNbQg)=AlmazyvN)V5*Xozl(?#r6eVM`O!4E`?Kz-E)@qT&{;zA-7fA z!`M?4@}=&PqrJEID2(U5L)9K8PTeZUNj_jV>Q1h>Tmojgn(dQ~sxzIn6qJ+Sl_jnz zv&z@rk$D+7T)JxKl8OpKQp=L#PiIdOj5||dAYBg$zV>wKr5ApsD;tgdsmf;ozqve1 zk0mD)+)zO#_gH&UEOou>w_SM|p2`>-Cm^P%!OFuNmQ;6ZrwAm&*8|e~K}E7vbQ>h| z0*!QLb;~8Sh-(T;>zRT+(fpTgmJ(Zv8pMWRyL2|M0P$C%W+FA^y+uP{jGA#%W=V zow-=%wP0BGjLn2^>!GctF3IH-?#~`?3%o0K(=9uLNb21rlK%ko`d8(orTgF0$fy-m zHF`tl=_%b!PVcUJ_Za^G3R)_{AG9tM{whK~Ttth}W}clAuk-nxht_UXH^A=9s$-AZzmPNxbLg{K~%gKKcP5O1$nrs!a#`n!e)o9U^aYy-uctaY{JWC0=d?r}OZG)1a5o2O3N5y?K zb3?_54hP<|eIV%T48}u=kICjgX&&jim8aIfh$_2N>Hh$XMf#V7R8)=1;ug=OeHr4I zCTGUaE~X!8A6>t)vDwdf%I5W&pUF5(Hxg0M(@9QtG2u+y?4clKD2{$)MHX^>$-&9@ zE|Yb{5J@D^K?D~NK>$!e1YuJ&$BKIASwpqSLh`!BW>ZnkVX|=NUus8KkwF(UM<;dv E*@R#M-~a#s literal 0 HcmV?d00001 diff --git a/packages/shared-data/tweets.ts b/packages/shared-data/tweets.ts index 92120a4123b09..c3fa00d60b91a 100644 --- a/packages/shared-data/tweets.ts +++ b/packages/shared-data/tweets.ts @@ -1,3 +1,15 @@ +/** + * Shared tweet testimonials for Studio sign-in and www social proof. + * + * To add a tweet: + * 1. Drop the profile image in `apps/www/public/images/twitter-profiles/` (use the raw Twitter image URL/filename as given) + * 2. Append an entry below with `text`, `url`, `handle`, and `img_url` + * + * Weight (optional, default 1): + * - Higher weight = more likely on Studio sign-in (weighted random over the full list) + * - www `topTweets` shows the top 18 by weight + * - Use ~10 for high-signal quotes (notable people / partners), ~9 for strong ones, lower for the rest + */ const tweets = [ { text: "Really impressed with @supabase's Assistant.\n\nIt has helped me troubleshoot and solve complex CORS Configuration issues on Pinger.", @@ -304,6 +316,13 @@ const tweets = [ img_url: '/images/twitter-profiles/k0aPYRHF_400x400.jpg', weight: 6, }, + { + text: 'Supabase is really good. ⚡', + url: 'https://x.com/shadcn/status/1672913636132790272', + handle: 'shadcn', + img_url: '/images/twitter-profiles/TUYae5z7.jpg', + weight: 10, + }, ] export const getWeightedTweets = (count: number): typeof tweets => { From 2c72847fa68d72629c5d3b325d9c1c84fa0cfaef Mon Sep 17 00:00:00 2001 From: "Andrey A." <56412611+aantti@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:45:27 +0200 Subject: [PATCH 03/15] docs: add new non-dictionary words to rule003 (#48207) --- supa-mdx-lint/Rule003Spelling.toml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/supa-mdx-lint/Rule003Spelling.toml b/supa-mdx-lint/Rule003Spelling.toml index 45a1b6c9c896f..35a6e565bcd0d 100644 --- a/supa-mdx-lint/Rule003Spelling.toml +++ b/supa-mdx-lint/Rule003Spelling.toml @@ -61,7 +61,6 @@ allow_list = [ "[Dd]evs?", "[Dd]iff(s|ing|ed)?", "[Dd]ropdown", - "DuckDB", "EarlyDrop", "[Ee]m-dash", "[Ee]m-dashes", @@ -126,6 +125,8 @@ allow_list = [ "[Pp]oolers?", "[Pp]refetcher", "[Pp]resign(ed|ing)?", + "[Pp]reload", + "[Pp]reloaded", "[Pp]roxying", "[Pp]sycopg", "[Qq]uickstarts?", @@ -219,9 +220,11 @@ allow_list = [ "Dinesh", "Django", "Docker", + "[Dd]ockerfile", "[Dd]omainless", "dotenvx", "Drizzle", + "DuckDB", "ElevenLabs", "Elysia", "[Ee]nablement", @@ -297,6 +300,7 @@ allow_list = [ "Mailgun", "Mailpit", "Mailtrap", + "[Mm]akefile", "Mansueli", "Markprompt", "Metabase", @@ -305,6 +309,7 @@ allow_list = [ "Mixpeek", "Multiplatform", "MySQL", + "Nix", "[Nn]amespaces?", "[Nn]ano", "NestJS", @@ -322,7 +327,7 @@ allow_list = [ "[Oo]pen[Aa][Pp][Ii]", "OpenID", "OpenMetrics", - "[Oo]pen[Ss]l", + "[Oo]pen[Ss][Ss][Ll]", "Opsgenie", "OrbStack", "OrioleDB", From 4d793a708eed15a2c6458fae7340f9e56cb14ac6 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:32:55 -0400 Subject: [PATCH 04/15] test(sql-editor): shared renderHook harness with in-memory editor port (#48209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add `renderSqlEditorHook()` test harness that eliminates mocking Monaco by injecting a real, deterministic in-memory editor port (EditorController/DiffController backed by plain JS state) - Include `createInMemoryEditor()`, `resetSqlEditorStores()`, and `setupSqlEditorMocks()` utilities to provide isolation and mock-free network testing via MSW handlers - Export `CustomWrapper` from custom-render and add optional `editor`/`diff` injection points to SQLEditorProvider (production unaffected via null-coalesce fallback) This is **Step 3** of an in-progress SQL editor testability refactor (Step 2 finished EditorController/DiffController port; this harness has no consumers yet — hook tests land in a follow-up step). ## Test plan - [x] `pnpm --filter studio typecheck` passes (already verified) ## Summary by CodeRabbit * **Tests** * Added reusable SQL Editor test utilities for in-memory editing, selections, error highlighting, snippets, and diff content. * Added helpers for resetting editor state, configuring API mocks, and rendering SQL Editor hooks in a complete test environment. * Enabled SQL Editor providers to accept optional controller overrides for isolated testing. * Exported the shared test wrapper for reuse across test suites. --- .../interfaces/SQLEditor/SQLEditorContext.tsx | 23 +- apps/studio/tests/lib/custom-render.tsx | 2 +- .../tests/lib/sql-editor-test-utils.tsx | 365 ++++++++++++++++++ 3 files changed, 386 insertions(+), 4 deletions(-) create mode 100644 apps/studio/tests/lib/sql-editor-test-utils.tsx diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditorContext.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditorContext.tsx index a5be74739e1f3..f33ad16992f11 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditorContext.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditorContext.tsx @@ -48,7 +48,22 @@ export const useSQLEditorContext = () => { return context } -export const SQLEditorProvider = ({ children }: PropsWithChildren) => { +type SQLEditorProviderProps = PropsWithChildren<{ + /** + * Test-only seam: production omits these and gets the real Monaco-backed + * controllers built from `editorRef`/`diffEditorRef` below. Tests pass a + * real in-memory implementation (see `tests/lib/sql-editor-test-utils.tsx`) + * instead of mocking Monaco. + */ + editor?: EditorController + diff?: DiffController +}> + +export const SQLEditorProvider = ({ + children, + editor: editorProp, + diff: diffProp, +}: SQLEditorProviderProps) => { const editorRef = useRef(null) const monacoRef = useRef(null) const diffEditorRef = useRef(null) @@ -148,7 +163,7 @@ export const SQLEditorProvider = ({ children }: PropsWithChildren) => { [] ) - const editor = useMemo( + const builtEditor = useMemo( () => ({ isReady: isEditorReady, getValue: getEditorValue, @@ -172,6 +187,7 @@ export const SQLEditorProvider = ({ children }: PropsWithChildren) => { clearHighlights, ] ) + const editor = editorProp ?? builtEditor const isDiffMounted = useCallback(() => diffEditorRef.current !== null, []) @@ -194,7 +210,7 @@ export const SQLEditorProvider = ({ children }: PropsWithChildren) => { diffEditorRef.current = editorInstance }, []) - const diff = useMemo( + const builtDiff = useMemo( () => ({ isMounted: isDiffMounted, getModifiedValue, @@ -203,6 +219,7 @@ export const SQLEditorProvider = ({ children }: PropsWithChildren) => { }), [isDiffMounted, getModifiedValue, setDiff, attachDiffEditor] ) + const diff = diffProp ?? builtDiff const value = useMemo( () => ({ diff --git a/apps/studio/tests/lib/custom-render.tsx b/apps/studio/tests/lib/custom-render.tsx index 808bfbdb1a7e6..f182c109a3e60 100644 --- a/apps/studio/tests/lib/custom-render.tsx +++ b/apps/studio/tests/lib/custom-render.tsx @@ -10,7 +10,7 @@ import { ProfileContext, type ProfileContextType } from '@/lib/profile' type AdapterProps = Partial[0]> -const CustomWrapper = ({ +export const CustomWrapper = ({ children, queryClient, nuqs, diff --git a/apps/studio/tests/lib/sql-editor-test-utils.tsx b/apps/studio/tests/lib/sql-editor-test-utils.tsx new file mode 100644 index 0000000000000..3be4957d80b96 --- /dev/null +++ b/apps/studio/tests/lib/sql-editor-test-utils.tsx @@ -0,0 +1,365 @@ +import { untrustedSql } from '@supabase/pg-meta' +import type { QueryClient } from '@tanstack/react-query' +import { renderHook, type RenderHookOptions } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import type { ReactNode } from 'react' + +import type { + ContentDiff, + DiffController, + EditorController, +} from '@/components/interfaces/SQLEditor/SQLEditor.types' +import { + computeErrorHighlightLine, + createSqlSnippetSkeletonV2, +} from '@/components/interfaces/SQLEditor/SQLEditor.utils' +import { SQLEditorProvider } from '@/components/interfaces/SQLEditor/SQLEditorContext' +import { API_URL, OPT_IN_TAGS } from '@/lib/constants' +import type { ProfileContextType } from '@/lib/profile' +import { AiAssistantStateContext, createAiAssistantState } from '@/state/ai-assistant-state' +import { + createDatabaseSelectorState, + DatabaseSelectorStateContext, +} from '@/state/database-selector' +import { + createRoleImpersonationState, + RoleImpersonationStateContext, +} from '@/state/role-impersonation-state' +import { sidebarManagerState } from '@/state/sidebar-manager-state' +import { sqlEditorDiffRequestState } from '@/state/sql-editor/sql-editor-diff-request' +import { sqlEditorSessionState } from '@/state/sql-editor/sql-editor-session-state' +import { sqlEditorState } from '@/state/sql-editor/sql-editor-state' +import { CustomWrapper } from '@/tests/lib/custom-render' +import { addAPIMock, mswServer } from '@/tests/lib/msw' + +export type InMemoryEditor = { + editor: EditorController + diff: DiffController + setValue: (value: string) => void + setSelection: (value: string | undefined, startLineNumber?: number) => void + getHighlightedLine: () => number | undefined + getRevealedLine: () => number | undefined + getDiffOriginal: () => string + getDiffModified: () => string + getDiffRevealedLine: () => number | undefined + setDiffMounted: (mounted: boolean) => void +} + +export function createInMemoryEditor(initialValue: string = ''): InMemoryEditor { + let value = initialValue + let selectionValue: string | undefined + let selectionStartLine: number | undefined + let highlightedLine: number | undefined + let revealedLine: number | undefined + + let diffMounted = true + let diffOriginal = '' + let diffModified = '' + let diffRevealedLine: number | undefined + + const editor: EditorController = { + isReady: () => true, + getValue: () => value, + getSelectionStartLine: () => selectionStartLine, + getSql: (snippetContent) => untrustedSql(selectionValue || value || snippetContent || ''), + replaceAll: (text) => { + value = text + }, + focus: () => {}, + revealLineInCenter: (line) => { + revealedLine = line + }, + highlightErrorLine: (error, hasSelection) => { + const startLineNumber = hasSelection ? (selectionStartLine ?? 0) : 0 + const line = computeErrorHighlightLine(error, startLineNumber) + if (Number.isNaN(line)) return + highlightedLine = line + revealedLine = line + }, + clearHighlights: () => { + highlightedLine = undefined + }, + } + + const diff: DiffController = { + isMounted: () => diffMounted, + getModifiedValue: () => diffModified, + setDiff: (contentDiff: ContentDiff, revealLine: number) => { + diffOriginal = contentDiff.original + diffModified = contentDiff.modified + diffRevealedLine = revealLine + }, + attach: () => { + diffMounted = true + }, + } + + return { + editor, + diff, + setValue: (v) => { + value = v + }, + setSelection: (v, startLine = 1) => { + selectionValue = v + selectionStartLine = v === undefined ? undefined : startLine + }, + getHighlightedLine: () => highlightedLine, + getRevealedLine: () => revealedLine, + getDiffOriginal: () => diffOriginal, + getDiffModified: () => diffModified, + getDiffRevealedLine: () => diffRevealedLine, + setDiffMounted: (mounted) => { + diffMounted = mounted + }, + } +} + +/** Call in `beforeEach` for full isolation between SQL editor hook tests. */ +export function resetSqlEditorStores() { + for (const key of Object.keys(sqlEditorState.snippets)) { + delete sqlEditorState.snippets[key] + } + for (const key of Object.keys(sqlEditorState.folders)) { + delete sqlEditorState.folders[key] + } + sqlEditorState.needsSaving.clear() + sqlEditorState.pendingFolderSaves.clear() + + for (const key of Object.keys(sqlEditorSessionState.results)) { + delete sqlEditorSessionState.results[key] + } + sqlEditorSessionState.limit = 100 + + sqlEditorDiffRequestState.pending = undefined + + sidebarManagerState.sidebars = {} + sidebarManagerState.activeSidebar = undefined + sidebarManagerState.pendingSidebarOpen = undefined + sidebarManagerState.isMaximised = false +} + +export function seedSnippet({ + id, + projectRef = 'default', + name = 'Test query', + sql = '', + ownerId = 1, + projectId = 1, +}: { + id: string + projectRef?: string + name?: string + sql?: string + ownerId?: number + projectId?: number +}) { + const snippet = createSqlSnippetSkeletonV2({ + name, + sql, + owner_id: ownerId, + project_id: projectId, + idOverride: id, + }) + sqlEditorState.addSnippet({ projectRef, snippet }) + return snippet +} + +export function setupSqlEditorMocks({ + ref = 'default', + connectionString = 'postgresql://postgres@localhost:5432/postgres', + orgId = 1, + orgSlug = 'test-org', + optInTags = [OPT_IN_TAGS.AI_SQL], + queryRows = [] as unknown[], +}: { + ref?: string + connectionString?: string + orgId?: number + orgSlug?: string + optInTags?: string[] + queryRows?: unknown[] +} = {}) { + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref', + response: { + id: 1, + ref, + organization_id: orgId, + name: 'Test Project', + status: 'ACTIVE_HEALTHY', + cloud_provider: 'AWS', + region: 'us-east-1', + db_host: `db.${ref}.supabase.co`, + restUrl: `https://${ref}.supabase.co/rest/v1/`, + inserted_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + subscription_id: 'sub_123', + is_branch_enabled: false, + is_physical_backups_enabled: false, + high_availability: false, + integration_source: null, + connectionString, + is_hibernating: false, + }, + }) + + addAPIMock({ + method: 'get', + path: '/platform/organizations', + response: [ + { + id: orgId, + slug: orgSlug, + name: 'Test Org', + billing_email: 'test@supabase.io', + billing_partner: null, + integration_source: null, + is_owner: true, + opt_in_tags: optInTags, + organization_missing_address: false, + organization_missing_tax_id: false, + organization_requires_mfa: false, + plan: { id: 'free', name: 'Free' }, + restriction_data: null, + restriction_status: null, + stripe_customer_id: null, + subscription_id: null, + usage_billing_enabled: false, + }, + ], + }) + + addAPIMock({ + method: 'get', + path: '/platform/organizations/:slug/billing/subscription', + response: { + addons: [], + billing_cycle_anchor: 1700000000, + billing_via_partner: false, + current_period_end: 1700000000, + current_period_start: 1700000000, + next_invoice_at: 1700000000, + payment_method_type: 'card', + plan: { id: 'free', name: 'Free' }, + project_addons: [], + scheduled_plan_change: null, + usage_billing_enabled: false, + }, + }) + + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref/settings', + response: { + cloud_provider: 'AWS', + db_dns_name: `db.${ref}.supabase.co`, + db_host: `db.${ref}.supabase.co`, + db_ip_addr_config: 'ipv4', + db_name: 'postgres', + db_port: 5432, + db_user: 'postgres', + inserted_at: '2024-01-01T00:00:00Z', + name: 'Test Project', + ref, + region: 'us-east-1', + ssl_enforced: false, + status: 'ACTIVE_HEALTHY', + }, + }) + + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref/databases', + response: [ + { + identifier: ref, + connectionString, + cloud_provider: 'AWS', + db_host: `db.${ref}.supabase.co`, + db_name: 'postgres', + db_port: 5432, + db_user: 'postgres', + inserted_at: '2024-01-01T00:00:00Z', + region: 'us-east-1', + restUrl: `https://${ref}.supabase.co/rest/v1/`, + size: 'ci_micro', + status: 'ACTIVE_HEALTHY', + }, + ], + }) + + addAPIMock({ + method: 'post', + path: '/platform/pg-meta/:ref/query', + response: () => HttpResponse.json(queryRows), + }) + + // Internal Next API routes (not part of the platform OpenAPI spec), hit via + // raw fetch — same MSW server, just registered directly rather than through + // the typed `addAPIMock` helper. + mswServer.use( + http.post(`${API_URL}/ai/sql/title-v2`, async () => + HttpResponse.json({ title: 'Generated title', description: '' }) + ), + http.post(`${API_URL}/ai/code/complete`, async () => HttpResponse.json('select 1;')) + ) +} + +type NuqsAdapterProps = Partial[0]> + +type RenderSqlEditorHookOptions = { + initialProps?: TProps + queryClient?: QueryClient + nuqs?: NuqsAdapterProps + profileContext?: ProfileContextType + inMemoryEditor?: InMemoryEditor + aiAssistantState?: ReturnType + databaseSelectorState?: ReturnType + roleImpersonationState?: ReturnType +} + +export function renderSqlEditorHook( + hook: (props: TProps) => TResult, + options?: RenderSqlEditorHookOptions +) { + const inMemoryEditor = options?.inMemoryEditor ?? createInMemoryEditor() + const aiAssistantState = options?.aiAssistantState ?? createAiAssistantState() + const databaseSelectorState = options?.databaseSelectorState ?? createDatabaseSelectorState() + const roleImpersonationState = + options?.roleImpersonationState ?? + createRoleImpersonationState('default', { current: async () => ({}) }) + + const wrapper = ({ children }: { children: ReactNode }) => ( + + + + + + {children} + + + + + + ) + + const result = renderHook(hook, { + initialProps: options?.initialProps, + wrapper, + } as RenderHookOptions) + + return { + ...result, + inMemoryEditor, + aiAssistantState, + databaseSelectorState, + roleImpersonationState, + } +} From 6d7c3361dc906e37984b0b90bc2f6bb61dad650a Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Wed, 22 Jul 2026 11:49:03 -0600 Subject: [PATCH 05/15] fix(studio): hide support access toggle when no project is selected (#48206) ## Summary Support access is granted per-project, but the "Allow support access" toggle stayed visible and submittable even when "No specific project" was selected. This hides the toggle and forces `allowSupportAccess`/`allow_support_access` to `false` in that case, across the standalone support form, sidebar form, and link-ticket form. Addresses [FE-3979](https://linear.app/supabase/issue/FE-3979/support-form-allows-support-access-without-a-project-selected). ## Test plan - [x] Added/updated unit tests in `SupportFormPage.test.tsx` covering toggle visibility and submitted payload when no project is selected - [x] `pnpm vitest run components/interfaces/Support` passes (48 tests) - [x] Typecheck and lint pass on changed files ## Summary by CodeRabbit * **Bug Fixes** * Support access is now offered only when a valid project and eligible support category are selected. * Support access is automatically disabled when no specific project is selected. * Form submissions now prevent unsupported support-access requests from being enabled. * **Tests** * Added coverage for project clearing and scenarios without available projects or organizations. --- .../Support/LinkSupportTicketForm.tsx | 18 +++-- .../Support/SupportAccessToggle.tsx | 8 -- .../interfaces/Support/SupportForm.utils.tsx | 20 ++++- .../interfaces/Support/SupportFormV2.tsx | 12 +-- .../interfaces/Support/SupportFormV3.tsx | 14 ++-- .../__tests__/SupportFormPage.test.tsx | 80 ++++++++++++++++++- 6 files changed, 122 insertions(+), 30 deletions(-) diff --git a/apps/studio/components/interfaces/Support/LinkSupportTicketForm.tsx b/apps/studio/components/interfaces/Support/LinkSupportTicketForm.tsx index 66fc1037f3dd3..1d104342edb2d 100644 --- a/apps/studio/components/interfaces/Support/LinkSupportTicketForm.tsx +++ b/apps/studio/components/interfaces/Support/LinkSupportTicketForm.tsx @@ -14,8 +14,13 @@ import { } from './LinkSupportTicketForm.schema' import { OrganizationSelector } from './OrganizationSelector' import { ProjectAndPlanInfo } from './ProjectAndPlanInfo' -import { DISABLE_SUPPORT_ACCESS_CATEGORIES, SupportAccessToggle } from './SupportAccessToggle' -import { getOrgSubscriptionPlan, NO_ORG_MARKER, NO_PROJECT_MARKER } from './SupportForm.utils' +import { SupportAccessToggle } from './SupportAccessToggle' +import { + canAllowSupportAccess, + getOrgSubscriptionPlan, + NO_ORG_MARKER, + NO_PROJECT_MARKER, +} from './SupportForm.utils' import { useLinkSupportTicketMutation } from '@/data/feedback/link-support-ticket-mutation' import { useOrganizationsQuery } from '@/data/organizations/organizations-query' @@ -81,10 +86,9 @@ export const LinkSupportTicketForm = ({ ? values.projectRef : undefined, category: values.category, - allow_support_access: - values.category && !DISABLE_SUPPORT_ACCESS_CATEGORIES.includes(values.category) - ? values.allowSupportAccess - : false, + allow_support_access: canAllowSupportAccess(values.category, values.projectRef) + ? values.allowSupportAccess + : false, }) } @@ -146,7 +150,7 @@ export const LinkSupportTicketForm = ({ - {!!category && !DISABLE_SUPPORT_ACCESS_CATEGORIES.includes(category) && ( + {canAllowSupportAccess(category, projectRef) && ( <>
diff --git a/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx b/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx index 146d68a5dd5e1..674ed5a11d241 100644 --- a/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx +++ b/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx @@ -1,21 +1,13 @@ // End of third-party imports -import { SupportCategories } from '@supabase/shared-types/out/constants' import { ChevronRight } from 'lucide-react' import Link from 'next/link' import type { UseFormReturn } from 'react-hook-form' import { Badge, Collapsible, CollapsibleContent, CollapsibleTrigger, FormField, Switch } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import type { ExtendedSupportCategories } from './Support.constants' import type { SupportFormValues } from './SupportForm.schema' -export const DISABLE_SUPPORT_ACCESS_CATEGORIES: ExtendedSupportCategories[] = [ - SupportCategories.ACCOUNT_DELETION, - SupportCategories.SALES_ENQUIRY, - SupportCategories.REFUND, -] - interface SupportAccessToggleProps { form: UseFormReturn align?: 'left' | 'right' diff --git a/apps/studio/components/interfaces/Support/SupportForm.utils.tsx b/apps/studio/components/interfaces/Support/SupportForm.utils.tsx index d88874ffb46c0..b072b3f5fc116 100644 --- a/apps/studio/components/interfaces/Support/SupportForm.utils.tsx +++ b/apps/studio/components/interfaces/Support/SupportForm.utils.tsx @@ -1,5 +1,6 @@ // End of third-party imports +import { SupportCategories } from '@supabase/shared-types/out/constants' import { DocsSearchResultType as PageType, type DocsSearchResult as Page, @@ -17,7 +18,7 @@ import { type UseQueryStatesKeysMap, } from 'nuqs' -import { CATEGORY_OPTIONS } from './Support.constants' +import { CATEGORY_OPTIONS, type ExtendedSupportCategories } from './Support.constants' import { getProjectDetail } from '@/data/projects/project-detail-query' import { DOCS_URL } from '@/lib/constants' import type { Organization } from '@/types' @@ -25,6 +26,23 @@ import type { Organization } from '@/types' export const NO_PROJECT_MARKER = 'no-project' export const NO_ORG_MARKER = 'no-org' +export const DISABLE_SUPPORT_ACCESS_CATEGORIES: ExtendedSupportCategories[] = [ + SupportCategories.ACCOUNT_DELETION, + SupportCategories.SALES_ENQUIRY, + SupportCategories.REFUND, +] + +export function canAllowSupportAccess( + category: ExtendedSupportCategories | undefined, + projectRef: string +): boolean { + return ( + !!category && + !DISABLE_SUPPORT_ACCESS_CATEGORIES.includes(category) && + projectRef !== NO_PROJECT_MARKER + ) +} + export const formatMessage = ({ message, attachments = [], diff --git a/apps/studio/components/interfaces/Support/SupportFormV2.tsx b/apps/studio/components/interfaces/Support/SupportFormV2.tsx index 17d6ad0e88413..eeb9a7d5bfb22 100644 --- a/apps/studio/components/interfaces/Support/SupportFormV2.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormV2.tsx @@ -24,10 +24,11 @@ import { OrganizationSelector } from './OrganizationSelector' import { ProjectAndPlanInfo } from './ProjectAndPlanInfo' import { SubjectAndSuggestionsInfo } from './SubjectAndSuggestionsInfo' import { SubmitButton } from './SubmitButton' -import { DISABLE_SUPPORT_ACCESS_CATEGORIES, SupportAccessToggle } from './SupportAccessToggle' +import { SupportAccessToggle } from './SupportAccessToggle' import type { SupportFormValues } from './SupportForm.schema' import type { SupportFormActions, SupportFormState } from './SupportForm.state' import { + canAllowSupportAccess, formatMessage, formatStudioVersion, getOrgSubscriptionPlan, @@ -160,10 +161,9 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo ...values, organizationSlug: values.organizationSlug ?? NO_ORG_MARKER, projectRef: values.projectRef ?? NO_PROJECT_MARKER, - allowSupportAccess: - values.category && !DISABLE_SUPPORT_ACCESS_CATEGORIES.includes(values.category) - ? values.allowSupportAccess - : false, + allowSupportAccess: canAllowSupportAccess(values.category, values.projectRef) + ? values.allowSupportAccess + : false, library: values.category === SupportCategories.PROBLEM && selectedLibrary !== undefined ? selectedLibrary.key @@ -254,7 +254,7 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo )} - {!!category && !DISABLE_SUPPORT_ACCESS_CATEGORIES.includes(category) && ( + {canAllowSupportAccess(category, projectRef) && ( <> diff --git a/apps/studio/components/interfaces/Support/SupportFormV3.tsx b/apps/studio/components/interfaces/Support/SupportFormV3.tsx index 5235151c4ce14..d8629c4c5f49f 100644 --- a/apps/studio/components/interfaces/Support/SupportFormV3.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormV3.tsx @@ -25,10 +25,11 @@ import { OrganizationSelector } from './OrganizationSelector' import { PlanExpectationInfoContent, ProjectAndPlanInfo } from './ProjectAndPlanInfo' import { SubjectAndSuggestionsInfo } from './SubjectAndSuggestionsInfo' import { SubmitButton } from './SubmitButton' -import { DISABLE_SUPPORT_ACCESS_CATEGORIES, SupportAccessToggle } from './SupportAccessToggle' +import { SupportAccessToggle } from './SupportAccessToggle' import type { SupportFormValues } from './SupportForm.schema' import type { SupportFormActions, SupportFormState } from './SupportForm.state' import { + canAllowSupportAccess, formatMessage, formatStudioVersion, getOrgSubscriptionPlan, @@ -171,10 +172,9 @@ export const SupportFormV3 = ({ ...values, organizationSlug: values.organizationSlug ?? NO_ORG_MARKER, projectRef: values.projectRef ?? NO_PROJECT_MARKER, - allowSupportAccess: - values.category && !DISABLE_SUPPORT_ACCESS_CATEGORIES.includes(values.category) - ? values.allowSupportAccess - : false, + allowSupportAccess: canAllowSupportAccess(values.category, values.projectRef) + ? values.allowSupportAccess + : false, library: values.category === SupportCategories.PROBLEM && selectedLibrary !== undefined ? selectedLibrary.key @@ -265,7 +265,7 @@ export const SupportFormV3 = ({
{(DASHBOARD_LOG_CATEGORIES.includes(category) || - (!!category && !DISABLE_SUPPORT_ACCESS_CATEGORIES.includes(category)) || + canAllowSupportAccess(category, projectRef) || showPlanExpectationInfo || showDirectEmailInfo) && (
@@ -275,7 +275,7 @@ export const SupportFormV3 = ({ )} - {!!category && !DISABLE_SUPPORT_ACCESS_CATEGORIES.includes(category) && ( + {canAllowSupportAccess(category, projectRef) && ( )} diff --git a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx index ffeae097fba95..4fa7f81fc3d00 100644 --- a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx +++ b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx @@ -2,6 +2,7 @@ import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { platformComponents as components } from 'api-types' import dayjs from 'dayjs' +import { mockIntersectionObserver } from 'jsdom-testing-mocks' import { http, HttpResponse } from 'msw' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' @@ -15,6 +16,9 @@ import { customRender } from '@/tests/lib/custom-render' import { addAPIMock, mswServer, type APIErrorBody } from '@/tests/lib/msw' import { createMockProfileContext } from '@/tests/lib/profile-helpers' +// The project selector's infinite-scroll sentinel uses IntersectionObserver, which jsdom lacks +mockIntersectionObserver() + type ProjectDetailResponse = components['schemas']['ProjectDetailResponse'] type OrganizationProjectsResponse = components['schemas']['OrganizationProjectsResponse'] type OrganizationProjectsProject = OrganizationProjectsResponse['projects'][number] @@ -316,6 +320,19 @@ const getDashboardLogsToggle = (screen: Screen, type: 'find' | 'query' = 'find') : screen.queryByRole('switch', { name: labelMatcher }) } +const getSupportAccessToggle = (screen: Screen, type: 'find' | 'query' = 'find') => { + const labelMatcher = /allow support access to your project/i + return type === 'find' + ? screen.findByRole('switch', { name: labelMatcher }) + : screen.queryByRole('switch', { name: labelMatcher }) +} + +const selectNoSpecificProject = async (screen: Screen) => { + await userEvent.click(getProjectSelector(screen)) + const option = await screen.findByRole('option', { name: /no specific project/i }) + await userEvent.click(option) +} + const getSupportForm = () => { const form = document.querySelector('form#support-form') expect(form).not.toBeNull() @@ -634,6 +651,64 @@ describe('SupportFormPage', () => { ) }) + test('hides support access toggle and submits allowSupportAccess: false when no project is selected', async () => { + const submitSpy = vi.fn() + + addAPIMock({ + method: 'post', + path: '/platform/feedback/send', + response: async ({ request }) => { + submitSpy(await request.json()) + return HttpResponse.json({ result: 'ok' }) + }, + }) + + renderSupportFormPage() + + await waitFor( + () => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + expect(getProjectSelector(screen)).toHaveTextContent('Project 1') + }, + { timeout: 5_000 } + ) + + await selectCategoryOption(screen, 'Dashboard bug') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug') + }) + + // A project is selected, so the toggle to grant support access is available + await expect(getSupportAccessToggle(screen)).resolves.toBeInTheDocument() + + await selectNoSpecificProject(screen) + await waitFor(() => { + expect(getProjectSelector(screen)).toHaveTextContent('No specific project') + }) + + // Support access is granted on a per-project basis, so the toggle should disappear + // once no project is selected, rather than allowing it to be enabled with nothing to grant access to + await waitFor(() => { + expect(getSupportAccessToggle(screen, 'query')).not.toBeInTheDocument() + }) + + await fillField(getSummaryField(screen), 'Cannot access my account') + await fillField(getMessageField(screen), 'I need help accessing my Supabase account') + + await userEvent.click(getSubmitButton(screen)) + + await waitFor(() => { + expect(submitSpy).toHaveBeenCalledTimes(1) + }) + + const payload = submitSpy.mock.calls[0]?.[0] + expect(payload).toMatchObject({ + projectRef: NO_PROJECT_MARKER, + organizationSlug: 'org-1', + allowSupportAccess: false, + }) + }, 10_000) + test('loading a URL with an invalid project slug falls back to first organization and project', async () => { mswServer.use( http.get(`${API_URL}/platform/projects/:ref`, () => @@ -1805,6 +1880,9 @@ describe('SupportFormPage', () => { await userEvent.click(dashboardLogToggle!) expect(dashboardLogToggle).not.toBeChecked() + // Support access is per-project, so with no project selected the toggle shouldn't be offered + expect(getSupportAccessToggle(screen, 'query')).not.toBeInTheDocument() + await fillField(getSummaryField(screen), 'Cannot access my account') await fillField(getMessageField(screen), 'I need help accessing my Supabase account') @@ -1822,7 +1900,7 @@ describe('SupportFormPage', () => { organizationSlug: NO_ORG_MARKER, library: '', affectedServices: '', - allowSupportAccess: true, + allowSupportAccess: false, verified: true, tags: ['dashboard-support-form'], browserInformation: 'Chrome', From 2d5ec97df8253ed1046f571fe242ec426f6a61d8 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Thu, 23 Jul 2026 01:49:22 +0800 Subject: [PATCH 06/15] chore: split CLAUDE.md into root and studio-specific files (#48202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits agent guidance into a lean monorepo-wide root file and a studio-specific file that Claude Code lazy-loads when working under `apps/studio/`. This keeps every session's baseline context small while giving studio work much richer, enforceable guidance. **Changed:** - `.claude/CLAUDE.md` — now monorepo-wide only: corrected pnpm version (10 → 11), expanded workspace table (design-system, ui-library, lite-studio, ui-patterns, api-types, pg-meta, shared-data), commands (`format`, `generate:types`, `api:codegen`), CI gates + never-hand-edit generated files, monorepo-wide conventions (incl. the named-exports rule, which lives in the shared eslint preset and applies to all six apps), and monorepo-wide skill triggers. Studio detail is replaced by a pointer to the nested file. Also corrects a long-standing error inherited from the old file: the `_Shadcn_` convention was inverted — `Button_Shadcn_` is the only suffixed export left and is rarely the right choice; primitives are unsuffixed. - `.claude/skills/studio-ui-patterns/SKILL.md` — removed the same stale `_Shadcn_` claim from the forms section (this skill also feeds CodeRabbit reviews). - `apps/studio/components/README.md` — component template now uses a named export, matching the lint-enforced convention (was the one doc still showing `export default`). - `apps/studio/TANSTACK_MIGRATION.md` — cleanup checklist gains an item to remove the migration section from `apps/studio/CLAUDE.md` when the migration finishes. - `.gitignore` — removed the blanket `CLAUDE.md` ignore rule (added in #40231 for personal local files, no longer used that way). Nested `CLAUDE.md` files are now tracked by default, so shared guidance can't silently fail to land. For *personal* notes, use `CLAUDE.local.md` (Claude Code loads it automatically alongside `CLAUDE.md`, and it's now gitignored here) — or `.git/info/exclude` if you prefer a different filename. **Added:** - `apps/studio/CLAUDE.md` — studio guidance, loaded on demand: mandatory skill routing (always load `studio-best-practices`, plus a task → skill table), TanStack Start migration rules (pages/routes mirroring, when a manual mirror is needed, never delete `pages/**` files), data-layer/state orientation, a default-to-shipping-tests-with-changes policy, and a "defaults that differ here" list (ESLint warning ratchet + local `lint:ratchet` command, `copyToClipboard` await rule, `useParams` from `common`, dayjs/sonner, `ui` vs `ui-patterns` import split, `@tanstack/react-table` over `react-data-grid`, etc.). ## Accuracy Every factual claim in both files (62 total) was verified against the code by parallel review agents instructed to refute each one. Results: 54 correct as written, 2 wrong (the inherited `_Shadcn_` inversion, and a fabricated `useExecuteSqlQuery` hook name — the real export is `useExecuteSqlMutation`), 6 imprecise (e.g. dayjs plugins load in both runtime entries, the ratchet counts occurrences regardless of severity). All fixed in this PR. ## Context cost | File | Size | When it loads | % of a 200k window | |---|---|---|---| | `.claude/CLAUDE.md` | 70 lines, ~1.2k est. tokens | every session | ~0.6% | | `apps/studio/CLAUDE.md` | 53 lines, ~1.6k est. tokens | only when touching studio files | ~0.8% | The always-loaded footprint grew only ~0.2k est. tokens vs the old 45-line file — everything studio-heavy sits behind the lazy load, so docs/www sessions pay nothing for it. Both files are well under Claude Code's large-file warning threshold (~40k chars) and the <200-line adherence guidance, with room to roughly double before it's worth worrying about. ## To test - Open a fresh Claude Code session from the repo root and read any file under `apps/studio/` — `apps/studio/CLAUDE.md` should get pulled into context automatically. - `git check-ignore apps/studio/CLAUDE.md` exits 1 (not ignored); `git check-ignore CLAUDE.local.md` exits 0 (ignored). - Skim both files — every claim has been code-verified (see Accuracy above), but a human sanity pass on the *judgment* calls (what's included/omitted) is welcome. ## Summary by CodeRabbit * **Documentation** * Refreshed monorepo onboarding conventions with updated tooling requirements, expanded inventory, standardized common scripts, and clearer CI gating and checks. * Added/updated Studio contributor guidance, including the TanStack Start migration rules and Studio development/testing/UI conventions. * Updated Studio component documentation to use named exports. * Refreshed the “Forms” UI pattern guidance and adjusted the referenced UI primitives. * **Chores** * Updated ignore rules so the primary top-level onboarding document is tracked. --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> --- .claude/CLAUDE.md | 70 +++++++++++++++------- .claude/skills/studio-ui-patterns/SKILL.md | 2 +- .gitignore | 2 +- apps/studio/CLAUDE.md | 53 ++++++++++++++++ apps/studio/TANSTACK_MIGRATION.md | 1 + apps/studio/components/README.md | 6 +- 6 files changed, 107 insertions(+), 27 deletions(-) create mode 100644 apps/studio/CLAUDE.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index ca29fe71fbaa7..175aec83c51ee 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -1,42 +1,70 @@ # Supabase Monorepo -pnpm 10 + Turborepo monorepo. Requires Node >= 22. +pnpm 11 + Turborepo monorepo. Requires Node >= 22.13. ## Structure -| Directory | Purpose | -| ----------------- | ------------------------------------------------------------ | -| `apps/studio` | Supabase Studio/Dashboard — Next.js (pages router), React 19 | -| `apps/docs` | Documentation site | -| `apps/www` | Marketing website | -| `packages/ui` | Shared UI components (shadcn/ui based) | -| `packages/common` | Shared utilities and telemetry constants | -| `e2e/studio` | Playwright E2E tests for Studio | +| Directory | Purpose | +| ------------------------ | --------------------------------------------------------------------------- | +| `apps/studio` | Supabase Studio/Dashboard — has its own `apps/studio/CLAUDE.md` (see below) | +| `apps/docs` | Documentation site — Next.js app router, MDX (port 3001) | +| `apps/www` | Marketing website — Next.js, app + pages (port 3000) | +| `apps/design-system` | Component demos — source of truth for Studio UI patterns (port 3003) | +| `apps/ui-library` | shadcn-style registry site for Supabase UI blocks (port 3004) | +| `apps/lite-studio` | Lightweight Studio — different stack: React Router 7 + Vite + Tailwind v4 | +| `packages/ui` | Shared UI components (shadcn/ui based) — `import { Button } from 'ui'` | +| `packages/ui-patterns` | Composite components — subpath imports, e.g. `ui-patterns/AssistantChat` | +| `packages/common` | Shared utils, telemetry constants, feature flags | +| `packages/api-types` | Generated platform Management API types | +| `packages/pg-meta` | SQL builders for Postgres introspection (`SafeSqlFragment`) | +| `packages/shared-data` | Static data: pricing, plans, regions, error codes | +| `e2e/studio`, `e2e/docs` | Playwright E2E tests | +| `supabase/` | Local Supabase project: edge functions, migrations, config.toml | ## Common Commands ```bash -pnpm install # install dependencies -pnpm dev:studio # run Studio dev server -pnpm test:studio # run Studio unit tests (vitest) -pnpm --prefix e2e/studio run e2e # run Studio E2E tests (playwright) -pnpm build --filter=studio # build Studio -pnpm lint --filter=studio # lint Studio -pnpm typecheck # typecheck all packages +pnpm install # install dependencies +pnpm dev:studio # run Studio dev server → http://localhost:8082 +pnpm dev:docs # run docs dev server +pnpm dev:www # run www dev server +pnpm test:studio # Studio unit tests (vitest) +pnpm e2e # Studio E2E tests (playwright) +pnpm build --filter=studio # build Studio +pnpm lint --filter=studio # lint Studio +pnpm typecheck # typecheck all packages +pnpm format # Prettier write (check: pnpm test:prettier) +pnpm generate:types # local DB types → supabase/functions/common/database-types.ts +pnpm api:codegen # platform Management API types → packages/api-types ``` +## CI + +Every PR must pass typecheck + lint (one workflow), Prettier, and a typos check. Other checks are path-filtered: Studio unit tests/build and the lint ratchet (ESLint warning count must not increase) run on `apps/studio/**` changes; app-specific test suites run on their own paths. + +Never hand-edit generated files: `packages/api-types/types/**`, `**/routeTree.gen.ts`, `**/__generated__/**`, `apps/docs/features/docs/generated/**`, `apps/www/.generated/**`, `supabase/functions/common/database-types.ts`. + ## Conventions -**UI** — import from `'ui'`, use `_Shadcn_` suffixed variants for form primitives. Check `packages/ui/index.tsx` before creating new primitives. +**UI** — import from `'ui'`; primitives are shadcn/ui-based and exported unsuffixed (`Input`, `Select`, `Form`, …). Use `Button` — the in-house component and the standard everywhere (a raw shadcn `Button_Shadcn_` also exists but is rarely the right choice). Check `packages/ui/index.tsx` before creating new primitives. Higher-level patterns live in `packages/ui-patterns`. **Styling** — Tailwind only, semantic tokens (`bg-muted`, `text-foreground-light`), no hardcoded colors. +**Exports** — named exports only; default exports are allowed only where a framework requires them (`pages/**`, `app/**`, config files — the eslint preset has the exact carve-out list). Lint-enforced across all apps via `eslint-config-supabase` (severity `warn` everywhere; hard-enforced in Studio by the lint ratchet). + **Language** — Use U.S. English everywhere. -**Studio shortcuts** — when adding or changing repeated Studio UI actions, use the shared shortcut registry and primitives in `apps/studio/state/shortcuts/` and `apps/studio/components/ui/Shortcut*.tsx`. Prefer registered, discoverable shortcuts over one-off keyboard listeners; keep `G then ...` chords for navigation. +## Skills -## Studio +The skills in `.claude/skills/` are the source of truth for conventions — load the relevant ones before working, don't guess: + +- `copywriting` — any user-facing text, anywhere in the monorepo +- `docs-content` — anything under `apps/docs` +- `telemetry-standards` — PostHog events, `packages/common/telemetry-constants.ts` +- `dev-toolbar-review` — `packages/dev-tools`, `packages/common/posthog-client.ts`, `packages/common/feature-flags.tsx` +- `safe-sql-execution` — any code that builds or executes SQL against user databases +- `vitest` / `vercel-composition-patterns` — generic unit-testing and React composition references -Pages router. Co-locate sub-components with parent. Avoid barrel re-export files. +## Studio -See studio-\* skills for detailed studio conventions. +Before working on anything in `apps/studio`, read `apps/studio/CLAUDE.md` if it isn't already in context — it maps Studio tasks to required skills and covers the TanStack Start migration rules. diff --git a/.claude/skills/studio-ui-patterns/SKILL.md b/.claude/skills/studio-ui-patterns/SKILL.md index c929e767a1309..cc875ec57104a 100644 --- a/.claude/skills/studio-ui-patterns/SKILL.md +++ b/.claude/skills/studio-ui-patterns/SKILL.md @@ -34,7 +34,7 @@ Docs: `apps/design-system/content/docs/ui-patterns/forms.mdx` - Use `react-hook-form` + `zod` - Use `FormItemLayout` instead of manually composing `FormItem`/`FormLabel`/`FormMessage`/`FormDescription` -- Wrap inputs with `FormControl`; use `_Shadcn_` imports from `ui` for primitives +- Wrap inputs with `FormControl`; import primitives from `ui` Layout selection: diff --git a/.gitignore b/.gitignore index ce23cae5706e3..f66893690109d 100644 --- a/.gitignore +++ b/.gitignore @@ -123,8 +123,8 @@ next-env.d.ts !.claude/scripts/ !.claude/skills/ .claude/skills/me-* -CLAUDE.md !.claude/CLAUDE.md +CLAUDE.local.md #include template .env file for docker-compose !docker/.env diff --git a/apps/studio/CLAUDE.md b/apps/studio/CLAUDE.md new file mode 100644 index 0000000000000..e102a63417e9a --- /dev/null +++ b/apps/studio/CLAUDE.md @@ -0,0 +1,53 @@ +# Supabase Studio + +Next.js pages router + TanStack Start (mid-migration, see below), React 19. Dev server: `pnpm dev:studio` → http://localhost:8082. + +## Skills — load before working + +**Always load the `studio-best-practices` skill before writing or modifying any Studio code.** Then stack the area-specific skills: + +| Task | Additional skills | +| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | +| Query/mutation hooks, query keys (`data/**`) | `studio-queries` | +| UI: pages, forms, tables, charts, sheets, empty states | `studio-ui-patterns` | +| Displaying API errors | `studio-error-handling` | +| Tests (deciding, writing, reviewing) | `studio-testing`, then `studio-mock-api-tests` (component/MSW) or `studio-e2e-tests` (Playwright) | +| PostHog event tracking | `telemetry-standards` | +| SQL against user databases | `safe-sql-execution` | +| Logs Explorer SQL, `data/logs` | `clickhouse-logs-queries` | +| Component API design, boolean-prop refactors | `vercel-composition-patterns` | +| User-facing copy | `copywriting` | + +## TanStack Start migration + +Studio is migrating from the Next.js pages router (`pages/**`) to TanStack Start (`routes/**`). Both runtimes ship side-by-side; the `STUDIO_FRAMEWORK` env var selects which one `pnpm dev`/`build` runs (default: `next`, resolved in `scripts/dispatch.js`). Full route map and strategy: `TANSTACK_MIGRATION.md`. + +- **Never delete a page file.** Most `routes/**` files are thin wrappers re-exporting the default export of their `pages/**` counterpart, so the Next file is load-bearing for both runtimes until the final cleanup pass. +- Pure page-body edits propagate to the route automatically. Mirror a change by hand into the corresponding `routes/**` file only when it touches what the route duplicates: `getLayout`/layout wrapping, page titles or other `staticData` (incl. `skip*Layout` flags), `withAuth`, or redirect paths. +- A new page under `pages/**` needs a matching route under `routes/**` plus a checklist entry in `TANSTACK_MIGRATION.md`. +- New code uses native TanStack APIs — no `next/router` or `next/link`. The `compat/next/` shims exist only for legacy re-exported pages. +- `routeTree.gen.ts` is generated by the Vite plugin — never hand-edit. + +## Orientation + +- **Data layer** — all platform API calls go through `data/fetchers.ts` (`openapi-fetch`, typed by the generated `api-types` package) with `handleError`; never raw `fetch`. One folder per resource in `data/`, most with a `keys.ts` query-key factory. +- **State** — valtio for global state (`state/`), nuqs for URL state, react-hook-form + zod for forms. +- **Platform vs self-hosted** — `IS_PLATFORM` gates platform-only behavior; `withAuth` is a no-op when self-hosted. +- **Telemetry** — `useTrack()` from `lib/telemetry/track`; event types live in `packages/common/telemetry-constants.ts`. +- **Tests** — default to including relevant tests with any change: a couple of unit tests for extracted logic, component tests for UI behavior, E2E only when the scope demands it (`studio-testing` has the decision tree). Not every PR needs them, but "no tests" should be a considered choice, not the default. Tooling: vitest + MSW; component tests use `customRender` + `addAPIMock` from `tests/lib/`; unhandled network requests fail tests. Don't `vi.mock('@/data/...')`. +- **Shortcuts** — use the registry in `state/shortcuts/` and `components/ui/Shortcut*.tsx`; keep `G then …` chords for navigation; no one-off keyboard listeners. +- **Reuse first** — before writing a new hook or helper, search for an existing one (`hooks/`, `lib/`, `packages/common`, `packages/ui-patterns`). If you do need a new one, make it as reusable as possible: general naming, no page-specific coupling, placed where other callers can find it. +- Co-locate sub-components with their parent; avoid barrel re-export files. + +## Defaults that differ here + +- **ESLint warnings are ratcheted in CI**: the per-rule occurrence count must not increase, so a new `any`, unresolved `exhaustive-deps` warning, or default export fails the build even though it's "only a warning". Check locally with `pnpm --filter studio run lint:ratchet`. +- **Clipboard**: `copyToClipboard` from `'ui'`, and never `await` anything before calling it (Safari requires the write inside the user gesture; lint-enforced) — pass a Promise as the argument instead. +- **`useParams()` comes from `'common'`**, not `next/navigation` — it camelCases keys and returns `string | undefined`. +- **Permissions**: `useAsyncCheckPermissions` from `hooks/misc/useCheckPermissions` (returns `can: true` when self-hosted). +- **Gating**: `useIsFeatureEnabled` for product features, `useFlag` from `'common'` for feature flags — two different systems. +- **Dates**: `dayjs` (plugins pre-loaded at both entries, `pages/_app.tsx` and `routes/__root.tsx`), not `date-fns`. **Toasts**: `toast` from `'sonner'`. +- **Import split**: `'ui'` = primitives, `'ui-patterns'` = composed patterns (`ConfirmationModal`, …), `@ui/*` = alias into `packages/ui/src`. Icons come from `lucide-react`. +- **New tables** use `@tanstack/react-table`; `react-data-grid` is banned for new code. +- **Ad-hoc SQL** against the user's database goes through `executeSql` / `useExecuteSqlMutation` (`data/sql/execute-sql-mutation`). +- **Confirmations**: `ConfirmationModal` / `TextConfirmModal` from `ui-patterns`, never `window.confirm`. Disabled buttons needing an explanation use `ButtonTooltip`; inline warnings use `Admonition`. diff --git a/apps/studio/TANSTACK_MIGRATION.md b/apps/studio/TANSTACK_MIGRATION.md index 5afb7202e9e54..387e808c2cea5 100644 --- a/apps/studio/TANSTACK_MIGRATION.md +++ b/apps/studio/TANSTACK_MIGRATION.md @@ -599,4 +599,5 @@ for the Vite pipeline: - Delete `pages/_app.tsx`, `pages/_document.tsx`, `pages/_error.jsx`, `pages/500.tsx`, `pages/404.tsx` (Next-only catch-alls; TanStack equivalents on `__root.tsx`). - Drop the `dev:next` / `build:next` / `start:next` scripts from `apps/studio/package.json` once we're committed to TanStack. - Remove the `apps/studio/pages/**` `path_instructions` guardrail entry from `.coderabbit.yaml` (added in FE-3423; remove it as part of this FE-3106 cleanup) — it's only useful while both runtimes coexist. +- Remove the "TanStack Start migration" section from `apps/studio/CLAUDE.md` — it only applies while both runtimes coexist. - Delete this file. diff --git a/apps/studio/components/README.md b/apps/studio/components/README.md index c5d618a74b187..e9127e56d0bec 100644 --- a/apps/studio/components/README.md +++ b/apps/studio/components/README.md @@ -35,10 +35,8 @@ interface ComponentAProps { sampleProp: string } -// Name your component accordingly -const ComponentA = ({ sampleProp }: ComponentAProps) => { +// Name your component accordingly — use a named export, not a default export +export const ComponentA = ({ sampleProp }: ComponentAProps) => { return
ComponentA: {sampleProp}
} - -export default ComponentA ``` From ac714e81ba4e2a5abb0b8d899610104ecbe353a0 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Wed, 22 Jul 2026 12:00:05 -0600 Subject: [PATCH 07/15] docs(tanstack): add a proper SSR quick start with cookie-based auth (#48105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary TanStack Start's quickstart only ever wired up an anonymous `supabase-js` client — no cookies, no `@supabase/ssr`, no auth. This ports the real `@supabase/ssr` client/server split and password-based auth flow (already shipped in `apps/ui-library`) into the quickstart and adds a matching tab to the SSR guide. ## Where this changed - `apps/docs/content/guides/getting-started/quickstarts/tanstack.mdx` — quickstart now installs the cookie-based auth flow via the Supabase UI Library registry and queries data through the SSR-aware server client. - `apps/docs/content/guides/auth/server-side/creating-a-client.mdx` — new TanStack Start tab (client/server setup + protecting routes). - `examples/auth/tanstack/` (new) — source files backing the `$CodeSample` snippets above, ported from `apps/ui-library`'s registry. ## Test plan - [x] Scaffolded a real TanStack Start app and ran the quickstart commands end-to-end - [x] Confirmed SSR loader + protected-route redirect work as documented - [x] `pnpm lint:mdx` and `pnpm build:guides-markdown` pass ## Summary by CodeRabbit * **New Features** * Added TanStack Start SSR setup examples for Supabase, including browser and server client helpers with cookie-based session support. * Included a protected route example that checks authentication on the server and redirects unauthenticated users to the login page. * Added a server-side claims fetch helper for authorization checks. * **Documentation** * Expanded the “creating a client” guide with TanStack Start-specific route protection and environment variable examples. * Updated the TanStack Start quickstart to use the official CLI and refined server-side authorization guidance. --- .../auth/server-side/creating-a-client.mdx | 87 ++++++++++++++++++- .../getting-started/quickstarts/tanstack.mdx | 77 +++++++++++----- examples/auth/tanstack/lib/supabase/client.ts | 9 ++ .../lib/supabase/fetch-claims-server-fn.ts | 14 +++ examples/auth/tanstack/lib/supabase/server.ts | 31 +++++++ examples/auth/tanstack/routes/_protected.tsx | 17 ++++ 6 files changed, 211 insertions(+), 24 deletions(-) create mode 100644 examples/auth/tanstack/lib/supabase/client.ts create mode 100644 examples/auth/tanstack/lib/supabase/fetch-claims-server-fn.ts create mode 100644 examples/auth/tanstack/lib/supabase/server.ts create mode 100644 examples/auth/tanstack/routes/_protected.tsx diff --git a/apps/docs/content/guides/auth/server-side/creating-a-client.mdx b/apps/docs/content/guides/auth/server-side/creating-a-client.mdx index b89c58b9463c0..ed252782fd5a8 100644 --- a/apps/docs/content/guides/auth/server-side/creating-a-client.mdx +++ b/apps/docs/content/guides/auth/server-side/creating-a-client.mdx @@ -159,6 +159,14 @@ SUPABASE_URL=supabase_project_url SUPABASE_PUBLISHABLE_KEY=supabase_publishable_key ``` + + + +```bash .env.local +VITE_SUPABASE_URL=supabase_project_url +VITE_SUPABASE_PUBLISHABLE_KEY=supabase_publishable_key +``` + @@ -510,7 +518,7 @@ export async function loader({ request }: LoaderFunctionArgs) { } ) - // Use `supabase` here for server-side work, e.g. await supabase.auth.getUser() + // Use `supabase` here for server-side work, e.g. await supabase.auth.getClaims() // Return the environment variables so the browser can create its own client. return json( @@ -674,7 +682,7 @@ export async function loader({ request }: LoaderFunctionArgs) { } ) - // Use `supabase` here for server-side work, e.g. await supabase.auth.getUser() + // Use `supabase` here for server-side work, e.g. await supabase.auth.getClaims() // Return the env vars so the browser can create its own client. return data( @@ -823,6 +831,81 @@ language="typescript" + + + +### Write utility functions to create Supabase clients + +TanStack Start renders matched routes on the server by default, so `beforeLoad` and `loader` run server-side on the initial request. Unlike Next.js, this means you don't need a proxy or middleware layer to keep sessions fresh — the server client reads and writes the session cookie directly on each request. + +Create a `lib/supabase` folder at the root of your project, or inside the `./src` folder if you are using one, then add a file for each type of client: + +1. **Create a browser client in `lib/supabase/client.ts`.** Use it to access Supabase from components that run in the browser. +2. **Create a server client in `lib/supabase/server.ts`.** Use it to access Supabase from loaders, server functions, and other code that runs only on the server. + +<$Partial path="auth_methods.mdx" /> + +Copy the lib utility functions below into each file: + +
+ <$CodeTabs> + <$CodeSample + path="/auth/tanstack/lib/supabase/client.ts" + meta="name=lib/supabase/client.ts" + language="typescript" + /> + <$CodeSample + path="/auth/tanstack/lib/supabase/server.ts" + meta="name=lib/supabase/server.ts" + language="typescript" + /> + +
+ +### Protecting routes + +TanStack Start has no global middleware layer, so protect each route explicitly. + +To protect your routes: + +1. Write a server function, `fetchClaims`, that calls `supabase.auth.getClaims()` and returns the claims, or `null` if the session isn't valid. +1. Call `fetchClaims` from a layout route's `beforeLoad` hook — for example, `_protected.tsx` — before any nested route renders, and redirect to `/login` when it returns `null`. + + + +Skipping the check inside the server function exposes private data to unauthenticated users. `beforeLoad` runs on the server for the initial request and on the client for later navigation, but either way it only gates the route's render — it doesn't stop the server function from being called directly. Because there's no proxy re-checking every request, the server function is the only checkpoint that always runs, so it must call `supabase.auth.getClaims()` to authorize the request itself. + + + +`getClaims()` validates the JWT signature on every call, the same check the Next.js Proxy relies on. Calling it inside the server function gives TanStack Start's per-route check that same guarantee, because the function runs on every request to a protected route. + +
+ <$CodeTabs> + <$CodeSample + path="/auth/tanstack/lib/supabase/fetch-claims-server-fn.ts" + meta="name=lib/supabase/fetch-claims-server-fn.ts" + language="typescript" + /> + <$CodeSample + path="/auth/tanstack/routes/_protected.tsx" + meta="name=routes/_protected.tsx" + language="typescript" + /> + +
+ +Any other server function that returns or mutates private data needs this same check. Don't rely on a route being nested under `_protected` alone. + +## Congratulations + +You're done! To recap, you've successfully: + +- Set up a Supabase client utility to call Supabase from a browser component. You can use this if you need to call Supabase from the browser, for example to set up a realtime subscription. +- Set up a server client utility to call Supabase from loaders and server functions. +- Protected a route with `beforeLoad`, backed by a server function that authorizes the request itself. + +You can now use any Supabase features from your client or server code! +
diff --git a/apps/docs/content/guides/getting-started/quickstarts/tanstack.mdx b/apps/docs/content/guides/getting-started/quickstarts/tanstack.mdx index b59f80d39ad40..3fe6ac2a5daa3 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/tanstack.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/tanstack.mdx @@ -11,7 +11,7 @@ breadcrumb: 'Framework Quickstarts' Create a TanStack Start app using the official CLI. ```bash -npm create @tanstack/start@latest my-app -- --package-manager npm --toolchain biome +npx @tanstack/cli@latest create my-app ``` ## 4. Install Agent Skills (optional) @@ -24,55 +24,87 @@ To install, run the following command in the root of your project: npx skills add supabase/agent-skills ``` -## 5. Install the Supabase client library +## 5. Install the Supabase client libraries -The fastest way to get started is to use the `supabase-js` client library which provides a convenient interface for working with Supabase from a TanStack Start app. - -Navigate to the TanStack Start app and install `supabase-js`. +Navigate to the TanStack Start app and install `supabase-js` and `@supabase/ssr`, the helper package that manages cookie-based sessions for server-side rendering. ```bash -cd my-app && npm install @supabase/supabase-js +cd my-app && npm install @supabase/supabase-js @supabase/ssr ``` ## 6. Declare Supabase environment variables -Create a `.env` file in the root of your project and populate with your Supabase connection variables that you can get from the helper below, or [from the project **Connect** panel](/dashboard/project/_?showConnect=true): +Create a `.env.local` file in the root of your project and populate it with your Supabase connection variables. Get the values from the helper below, or [from the project **Connect** panel](/dashboard/project/_?showConnect=true&connectTab=frameworks&framework=tanstack). -```text name=.env +```text name=.env.local VITE_SUPABASE_URL= VITE_SUPABASE_PUBLISHABLE_KEY= ``` -<$Partial path="api_settings.mdx" variables={{ "framework": "", "tab": "" }} /> +<$Partial path="api_settings.mdx" variables={{ "framework": "tanstack", "tab": "frameworks" }} /> -## 7. Create a Supabase client utility +## 7. Create Supabase client utilities -Create a new file at `src/utils/supabase.ts` to initialize the Supabase client. +TanStack Start needs two Supabase clients: a browser client for components that run in the browser, and a server client for loaders and server functions. Create a `src/lib/supabase` folder with a file for each client. -```ts name=src/utils/supabase.ts -import { createClient } from '@supabase/supabase-js' +```ts name=src/lib/supabase/client.ts +/// +import { createBrowserClient } from '@supabase/ssr' -export const supabase = createClient( - import.meta.env.VITE_SUPABASE_URL, - import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY -) +export function createClient() { + return createBrowserClient( + import.meta.env.VITE_SUPABASE_URL!, + import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY! + ) +} +``` + +```ts name=src/lib/supabase/server.ts +import { createServerClient } from '@supabase/ssr' +import { getCookies, setCookie, setResponseHeader } from '@tanstack/react-start/server' + +export function createClient() { + return createServerClient( + process.env.VITE_SUPABASE_URL!, + process.env.VITE_SUPABASE_PUBLISHABLE_KEY!, + { + cookies: { + getAll() { + return Object.entries(getCookies()).map(([name, value]) => ({ name, value })) + }, + setAll(cookies, headers) { + cookies.forEach(({ name, value, options }) => { + setCookie(name, value, options) + }) + + Object.entries(headers).forEach(([name, value]) => { + setResponseHeader(name, value) + }) + }, + }, + } + ) +} ``` -## 8. Query data from the app +## 8. Query Supabase data from TanStack Start -Replace the contents of `src/routes/index.tsx` with the following code to add a loader function that fetches the instruments data and displays it on the page. +Replace the contents of `src/routes/index.tsx` with the following to add a loader that queries the `instruments` table through the server client. The loader runs on the server, so the data is part of the initial server-rendered response. ```tsx name=src/routes/index.tsx import { createFileRoute } from '@tanstack/react-router' -import { supabase } from '../utils/supabase' +import { createClient } from '@/lib/supabase/server' export const Route = createFileRoute('/')({ loader: async () => { + const supabase = createClient() const { data: instruments } = await supabase.from('instruments').select() return { instruments } }, @@ -102,7 +134,8 @@ npm run dev ## Next steps +- Learn how to [protect routes and check sessions](/docs/guides/auth/server-side/creating-a-client?queryGroups=framework&framework=tanstack) with the server client +- Set up a complete [login and sign-up flow](/ui/docs/tanstack/password-based-auth) from the Supabase UI Library - Explore [drop-in UI components](/ui) for your Supabase app -- Set up [Auth](/docs/guides/auth) for your app - [Insert more data](/docs/guides/database/import-data) into your database - Upload and serve static files using [Storage](/docs/guides/storage) diff --git a/examples/auth/tanstack/lib/supabase/client.ts b/examples/auth/tanstack/lib/supabase/client.ts new file mode 100644 index 0000000000000..3f34921e9f69d --- /dev/null +++ b/examples/auth/tanstack/lib/supabase/client.ts @@ -0,0 +1,9 @@ +/// +import { createBrowserClient } from '@supabase/ssr' + +export function createClient() { + return createBrowserClient( + import.meta.env.VITE_SUPABASE_URL!, + import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY! + ) +} diff --git a/examples/auth/tanstack/lib/supabase/fetch-claims-server-fn.ts b/examples/auth/tanstack/lib/supabase/fetch-claims-server-fn.ts new file mode 100644 index 0000000000000..69e151984e63f --- /dev/null +++ b/examples/auth/tanstack/lib/supabase/fetch-claims-server-fn.ts @@ -0,0 +1,14 @@ +import { createServerFn } from '@tanstack/react-start' + +import { createClient } from '@/lib/supabase/server' + +export const fetchClaims = createServerFn({ method: 'GET' }).handler(async () => { + const supabase = createClient() + const { data, error } = await supabase.auth.getClaims() + + if (error) { + return null + } + + return data.claims +}) diff --git a/examples/auth/tanstack/lib/supabase/server.ts b/examples/auth/tanstack/lib/supabase/server.ts new file mode 100644 index 0000000000000..04ce35c6a2888 --- /dev/null +++ b/examples/auth/tanstack/lib/supabase/server.ts @@ -0,0 +1,31 @@ +import { createServerClient } from '@supabase/ssr' +import { getCookies, setCookie, setResponseHeader } from '@tanstack/react-start/server' + +export function createClient() { + return createServerClient( + process.env.VITE_SUPABASE_URL!, + process.env.VITE_SUPABASE_PUBLISHABLE_KEY!, + { + cookies: { + getAll() { + return Object.entries(getCookies()).map( + ([name, value]) => + ({ + name, + value, + }) as { name: string; value: string } + ) + }, + setAll(cookies, headers) { + cookies.forEach(({ name, value, options }) => { + setCookie(name, value, options) + }) + + Object.entries(headers).forEach(([name, value]) => { + setResponseHeader(name, value) + }) + }, + }, + } + ) +} diff --git a/examples/auth/tanstack/routes/_protected.tsx b/examples/auth/tanstack/routes/_protected.tsx new file mode 100644 index 0000000000000..7ce91c4936637 --- /dev/null +++ b/examples/auth/tanstack/routes/_protected.tsx @@ -0,0 +1,17 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' + +import { fetchClaims } from '@/lib/supabase/fetch-claims-server-fn' + +export const Route = createFileRoute('/_protected')({ + beforeLoad: async () => { + const claims = await fetchClaims() + + if (!claims) { + throw redirect({ to: '/login' }) + } + + return { + claims, + } + }, +}) From ca2a390a3d2e6bf126cfed5226a7cd1dbbf062b7 Mon Sep 17 00:00:00 2001 From: Miranda Limonczenko Date: Wed, 22 Jul 2026 11:06:50 -0700 Subject: [PATCH 08/15] fix(docs): restore Supabase env vars to stop crash on every page load (#48213) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/user-attachments/assets/0b9e4bd1-e2b6-4a58-b47e-803e2b34a32e ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Bug fix. ## What is the current behavior? Every page in `apps/docs` crashes at runtime with `Error: supabaseUrl is required.` Regression from #46757, which flipped `NEXT_PUBLIC_IS_PLATFORM` to `"true"` in `apps/docs/.env.development` and, in the same diff, duplicated a `NEXT_PUBLIC_MARKETPLACE_API_URL`/`NEXT_PUBLIC_MARKETPLACE_PUBLISHABLE_KEY` block where `NEXT_PUBLIC_SUPABASE_URL`/`NEXT_PUBLIC_SUPABASE_ANON_KEY` should have been. With `IS_PLATFORM` now `true`, `Feedback.tsx` (rendered on every docs page) unconditionally calls `createClient()` with an undefined URL/key, throwing synchronously on every page load. Closes DOCS-1208 / FE-3980. ## What is the new behavior? - `apps/docs/.env.development`: renamed the mislabeled duplicate block back to `NEXT_PUBLIC_SUPABASE_URL`/`NEXT_PUBLIC_SUPABASE_ANON_KEY`. - `apps/docs/components/Feedback/Feedback.tsx`: widened the guard to `IS_PLATFORM && supabaseUrl && supabaseAnonKey`, mirroring the existing pattern in `app/api/ai/docs/route.ts`, so a future env misconfiguration degrades gracefully (feedback votes silently skipped) instead of crashing every page. Verified locally by running `pnpm dev:docs` with no GitHub credentials set: - No more `"supabaseUrl is required."` anywhere; the Feedback widget renders and fires its vote request instead of throwing. - A normal guide page renders fine. - `/guides/database/database-advisors` still shows its existing graceful fallback admonition. - `/guides/graphql` (federated content, absent on a clean checkout) returns a clean 404 rather than crashing — confirming the related goal of running docs dev locally without federated content already works (via #48205 + existing `notFound()` handling), no extra changes needed there. ## Additional context A related but separate gap was found in `apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx`. A new Linear issue is created: https://linear.app/supabase/issue/DOCS-1209/wrappers-guide-page-crashes-on-unhandled-github-fetch-failure-without ## Manual testing 1. Checkout branch locally and run `pnpm run dev:docs` with no GitHub credentials set. Confirm it starts without errors. 2. Open any guide page on docs locally and confirm no `supabaseUrl is required` error, and the Feedback widget renders and responds to clicks. 3. Open `/docs/guides/database/database-advisors`. Confirm it renders and does not crash. 4. Open `/docs/guides/graphql`. Confirm a clean 404, not a server error. ## Summary by CodeRabbit * **Bug Fixes** * Improved feedback functionality by safely handling missing configuration. * Feedback votes and comments are skipped when the required service configuration is unavailable, preventing errors. * **Chores** * Updated documentation-site configuration to use the appropriate content service settings. Co-authored-by: Claude Sonnet 5 --- apps/docs/.env.development | 4 ++-- apps/docs/components/Feedback/Feedback.tsx | 9 ++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/docs/.env.development b/apps/docs/.env.development index d10a8699d76fa..707983312562e 100644 --- a/apps/docs/.env.development +++ b/apps/docs/.env.development @@ -22,5 +22,5 @@ NEXT_PUBLIC_MARKETPLACE_API_URL="https://fgxbxpvumhvzrhqngsyu.supabase.co" NEXT_PUBLIC_MARKETPLACE_PUBLISHABLE_KEY="sb_publishable_VuF5ZvGqj6ODhZgN1J_vMw_YbiEs1R6" # Supabase project containing docs content information -NEXT_PUBLIC_MARKETPLACE_API_URL="https://otqhrpbxhxkrhrnjqbba.supabase.co/" -NEXT_PUBLIC_MARKETPLACE_PUBLISHABLE_KEY="sb_publishable_ZVVKKu1s88KsSBWVYlou-g_phb2OJVQ" +NEXT_PUBLIC_SUPABASE_URL="https://otqhrpbxhxkrhrnjqbba.supabase.co/" +NEXT_PUBLIC_SUPABASE_ANON_KEY="sb_publishable_ZVVKKu1s88KsSBWVYlou-g_phb2OJVQ" diff --git a/apps/docs/components/Feedback/Feedback.tsx b/apps/docs/components/Feedback/Feedback.tsx index dce8d456dda8c..9a26aea13a6ec 100644 --- a/apps/docs/components/Feedback/Feedback.tsx +++ b/apps/docs/components/Feedback/Feedback.tsx @@ -77,12 +77,11 @@ function Feedback({ className }: { className?: string }) { const pathname = usePathname() ?? '' const sendTelemetryEvent = useSendTelemetryEvent() + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL + const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY const supabase = useConstant(() => - IS_PLATFORM - ? createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! - ) + IS_PLATFORM && supabaseUrl && supabaseAnonKey + ? createClient(supabaseUrl, supabaseAnonKey) : undefined ) From 25658ab7331b9b8843a7a0d97cabfbff68cd076d Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Thu, 23 Jul 2026 02:29:41 +0800 Subject: [PATCH 09/15] chore(lint): ignore dist build output in shared ESLint config (#48216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #48202. The shared ESLint flat config only globally ignored `.next`, `public`, and `.contentlayer`, so with the TanStack Start migration, Studio's Vite build output in `dist/` was getting linted too — making `pnpm --filter studio run lint:ratchet` (and regular lint) far slower than it should be. ESLint flat config doesn't respect `.gitignore`, so being gitignored didn't help. **Changed:** - Added `dist` to the global `ignores` in `eslint-config-supabase/next` (applies to all apps extending the shared config) ## To test - In `apps/studio` (with a `dist/` folder present from a TanStack build), run `npx eslint dist/server/server.js` — it should report "File ignored because of a matching ignore pattern" - `pnpm --filter studio run lint:ratchet` no longer spends time linting `dist/**` ## Summary by CodeRabbit * **Chores** * Updated linting exclusions to ignore generated build output and static asset directories. * Generalized related configuration documentation for clarity. Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> --- packages/eslint-config-supabase/next.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/eslint-config-supabase/next.js b/packages/eslint-config-supabase/next.js index 049e34bacdd41..71265a3355704 100644 --- a/packages/eslint-config-supabase/next.js +++ b/packages/eslint-config-supabase/next.js @@ -48,8 +48,8 @@ const typescriptConfig = { } module.exports = defineConfig([ - // Global ignore for the .next folder - { ignores: ['.next', 'public', '.contentlayer'] }, + // Global ignore for build output and static assets + { ignores: ['.next', 'dist', 'public', '.contentlayer'] }, turboConfig, prettierConfig, tanstackQuery.configs['flat/recommended'], From 359974d0718b2f7a6fbacafd1e11dc447bb46608 Mon Sep 17 00:00:00 2001 From: Miranda Limonczenko Date: Wed, 22 Jul 2026 11:49:04 -0700 Subject: [PATCH 10/15] fix(docs) Fix local broken links (#48212) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes DOCS-1202 ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## Problem We have broken local links in docs. I ran locally tests that crawl through all of our docs and flags broken local links. ## Solution This PR fixes local links where they were errored. The report I generated had false-positives, so there are fewer fixes than initially thought. ## Preview checklist Docs preview: https://docs-git-docs-fix-broken-local-links-supabase.vercel.app WWW preview (redirects): https://zone-www-dot-com-git-docs-fix-broken-local-links-supabase.vercel.app | Page | Live (broken) | Preview (fixed) | Where to look | | --- | --- | --- | --- | | Amazon Bedrock | [Live](https://supabase.com/docs/guides/ai/integrations/amazon-bedrock) | [Preview](https://docs-git-docs-fix-broken-local-links-supabase.vercel.app/docs/guides/ai/integrations/amazon-bedrock) | **You'll also need** → `A Postgres database with the pgvector extension` | | Getting started | [Live](https://supabase.com/docs/guides/getting-started) | [Preview](https://docs-git-docs-fix-broken-local-links-supabase.vercel.app/docs/guides/getting-started) | Tutorial cards → **Expo React Native Social Auth** | | Product security | [Live](https://supabase.com/docs/guides/security/product-security) | [Preview](https://docs-git-docs-fix-broken-local-links-supabase.vercel.app/docs/guides/security/product-security) | **Database** list → `Superuser access and unsupported operations` | | OAuth flows | [Live](https://supabase.com/docs/guides/auth/oauth-server/oauth-flows) | [Preview](https://docs-git-docs-fix-broken-local-links-supabase.vercel.app/docs/guides/auth/oauth-server/oauth-flows) | End of page, before **Next steps** → `OAuth methods in supabase-js` | | ElevenLabs TTS | [Live](https://supabase.com/docs/guides/functions/examples/elevenlabs-generate-speech-stream) | [Preview](https://docs-git-docs-fix-broken-local-links-supabase.vercel.app/docs/guides/functions/examples/elevenlabs-generate-speech-stream) | **Dependencies** → ElevenLabs `JavaScript SDK` | | ElevenLabs STT | [Live](https://supabase.com/docs/guides/functions/examples/elevenlabs-transcribe-speech) | [Preview](https://docs-git-docs-fix-broken-local-links-supabase.vercel.app/docs/guides/functions/examples/elevenlabs-transcribe-speech) | **Dependencies** → ElevenLabs `JavaScript SDK` | | Realtime error codes | [Live](https://supabase.com/docs/guides/realtime/error_codes) | [Preview](https://docs-git-docs-fix-broken-local-links-supabase.vercel.app/docs/guides/realtime/error_codes) | `RealtimeDisabledForTenant` → reference link | | Expo social auth redirect (legacy) | [Live](https://supabase.com/docs/guides/with-expo-social-auth) | [Preview](https://zone-www-dot-com-git-docs-fix-broken-local-links-supabase.vercel.app/docs/guides/with-expo-social-auth) | Should land on the Expo social auth quickstart | | Expo social auth redirect (old tutorials path) | [Live](https://supabase.com/docs/guides/getting-started/tutorials/with-expo-social-auth) | [Preview](https://zone-www-dot-com-git-docs-fix-broken-local-links-supabase.vercel.app/docs/guides/getting-started/tutorials/with-expo-social-auth) | Should land on the Expo social auth quickstart | ### Manual testing 1. For each row, open the **Live** link and find the linked text in **Where to look**. 2. Click the link and confirm it 404s or lands on the wrong page. 3. Open the matching **Preview** link, find the same linked text, and click it. 4. Confirm the preview link resolves to the correct destination: - Amazon Bedrock → `/docs/guides/database/extensions/pgvector` - Getting started → `/docs/guides/auth/quickstarts/with-expo-react-native-social-auth` - Product security → `/docs/guides/database/postgres/roles-superuser` - OAuth flows → `/docs/reference/javascript/auth-admin-oauth-server` - ElevenLabs TTS / STT → `https://github.com/elevenlabs/elevenlabs-js` - Realtime error codes → `/docs/guides/troubleshooting/realtime-project-suspended-for-exceeding-quotas` - Redirect rows → `/docs/guides/auth/quickstarts/with-expo-react-native-social-auth` ## Summary by CodeRabbit * **Documentation** * Updated links for pgvector, OAuth, ElevenLabs SDK, and database security guidance. * Corrected the Expo React Native social authentication tutorial link. * Updated Realtime troubleshooting references to the current documentation path. * **Bug Fixes** * Fixed redirects for Expo social authentication guides so legacy URLs reach the correct quickstart. --- apps/docs/content/guides/ai/integrations/amazon-bedrock.mdx | 2 +- apps/docs/content/guides/auth/oauth-server/oauth-flows.mdx | 2 +- .../functions/examples/elevenlabs-generate-speech-stream.mdx | 2 +- .../functions/examples/elevenlabs-transcribe-speech.mdx | 2 +- apps/docs/content/guides/getting-started.mdx | 2 +- apps/docs/content/guides/security/product-security.mdx | 2 +- apps/docs/data/errorCodes/realtimeErrorCodes.json | 2 +- apps/www/lib/redirects.js | 4 ++-- packages/shared-data/error-codes.ts | 2 +- 9 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/docs/content/guides/ai/integrations/amazon-bedrock.mdx b/apps/docs/content/guides/ai/integrations/amazon-bedrock.mdx index dfb0b13813f55..457dc160e550c 100644 --- a/apps/docs/content/guides/ai/integrations/amazon-bedrock.mdx +++ b/apps/docs/content/guides/ai/integrations/amazon-bedrock.mdx @@ -23,7 +23,7 @@ pip install vecs boto3 You'll also need: - [Credentials to your AWS account](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html) -- [A Postgres Database with the pgvector extension](hosting.md) +- [A Postgres database with the pgvector extension](/docs/guides/database/extensions/pgvector) ## Create embeddings diff --git a/apps/docs/content/guides/auth/oauth-server/oauth-flows.mdx b/apps/docs/content/guides/auth/oauth-server/oauth-flows.mdx index 04291fe8018fc..eda6aa21efeea 100644 --- a/apps/docs/content/guides/auth/oauth-server/oauth-flows.mdx +++ b/apps/docs/content/guides/auth/oauth-server/oauth-flows.mdx @@ -849,7 +849,7 @@ It's a good practice to provide a settings page where users can view all authori -For complete API reference, see the [OAuth methods in supabase-js](/docs/reference/javascript/auth-oauth). +For complete API reference, see the [OAuth methods in supabase-js](/docs/reference/javascript/auth-admin-oauth-server). ## Next steps diff --git a/apps/docs/content/guides/functions/examples/elevenlabs-generate-speech-stream.mdx b/apps/docs/content/guides/functions/examples/elevenlabs-generate-speech-stream.mdx index c013b25d8fb3e..fc2fc0e67d6fa 100644 --- a/apps/docs/content/guides/functions/examples/elevenlabs-generate-speech-stream.mdx +++ b/apps/docs/content/guides/functions/examples/elevenlabs-generate-speech-stream.mdx @@ -88,7 +88,7 @@ ELEVENLABS_API_KEY=your_api_key The project uses a couple of dependencies: - The [@supabase/supabase-js](/docs/reference/javascript) library to interact with the Supabase database. -- The ElevenLabs [JavaScript SDK](/docs/quickstart) to interact with the text-to-speech API. +- The ElevenLabs [JavaScript SDK](https://github.com/elevenlabs/elevenlabs-js) to interact with the text-to-speech API. - The open-source [object-hash](https://www.npmjs.com/package/object-hash) to generate a hash from the request parameters. Since Supabase Edge Function uses the [Deno runtime](https://deno.land/), you don't need to install the dependencies, rather you can [import](https://docs.deno.com/examples/npm/) them via the `npm:` prefix. diff --git a/apps/docs/content/guides/functions/examples/elevenlabs-transcribe-speech.mdx b/apps/docs/content/guides/functions/examples/elevenlabs-transcribe-speech.mdx index 8ec31003e500f..b5b574b5e98ed 100644 --- a/apps/docs/content/guides/functions/examples/elevenlabs-transcribe-speech.mdx +++ b/apps/docs/content/guides/functions/examples/elevenlabs-transcribe-speech.mdx @@ -98,7 +98,7 @@ The project uses a couple of dependencies: - The open-source [grammY Framework](https://grammy.dev/) to handle the Telegram webhook requests. - The [@supabase/supabase-js](/docs/reference/javascript) library to interact with the Supabase database. -- The ElevenLabs [JavaScript SDK](/docs/quickstart) to interact with the speech-to-text API. +- The ElevenLabs [JavaScript SDK](https://github.com/elevenlabs/elevenlabs-js) to interact with the speech-to-text API. Since Supabase Edge Function uses the [Deno runtime](https://deno.land/), you don't need to install the dependencies, rather you can [import](https://docs.deno.com/examples/npm/) them via the `npm:` prefix. diff --git a/apps/docs/content/guides/getting-started.mdx b/apps/docs/content/guides/getting-started.mdx index 45a0a7892cc69..163c49283da6c 100644 --- a/apps/docs/content/guides/getting-started.mdx +++ b/apps/docs/content/guides/getting-started.mdx @@ -363,7 +363,7 @@ hideToc: true diff --git a/apps/docs/content/guides/security/product-security.mdx b/apps/docs/content/guides/security/product-security.mdx index f47e9b1d1c21c..d1e8ef2158989 100644 --- a/apps/docs/content/guides/security/product-security.mdx +++ b/apps/docs/content/guides/security/product-security.mdx @@ -24,7 +24,7 @@ Various products at Supabase have their own hardening and configuration guides, - [Managing Postgres roles](/docs/guides/database/postgres/roles) - [Managing secrets with Vault](/docs/guides/database/vault) - [Postgres connection logging](/docs/guides/platform/postgres-connection-logging) -- [Superuser access and unsupported operations](docs/guides/database/postgres/roles-superuser) +- [Superuser access and unsupported operations](/docs/guides/database/postgres/roles-superuser) ## Storage diff --git a/apps/docs/data/errorCodes/realtimeErrorCodes.json b/apps/docs/data/errorCodes/realtimeErrorCodes.json index 6b8f910648f39..3d39e335df8fd 100644 --- a/apps/docs/data/errorCodes/realtimeErrorCodes.json +++ b/apps/docs/data/errorCodes/realtimeErrorCodes.json @@ -51,7 +51,7 @@ "resolution": "Your project may have been suspended for exceeding usage quotas. Contact support with your project reference ID and a description of your Realtime use case.", "references": [ { - "href": "https://supabase.com/docs/troubleshooting/realtime-project-suspended-for-exceeding-quotas", + "href": "https://supabase.com/docs/guides/troubleshooting/realtime-project-suspended-for-exceeding-quotas", "description": "Troubleshooting guide for suspended projects" } ] diff --git a/apps/www/lib/redirects.js b/apps/www/lib/redirects.js index 7c52d199faa32..ef9162f9e5e4c 100644 --- a/apps/www/lib/redirects.js +++ b/apps/www/lib/redirects.js @@ -1926,7 +1926,7 @@ module.exports = [ { permanent: true, source: '/docs/guides/with-expo-social-auth', - destination: '/docs/guides/getting-started/tutorials/with-expo-react-native-social-auth', + destination: '/docs/guides/auth/quickstarts/with-expo-react-native-social-auth', }, { permanent: true, @@ -1936,7 +1936,7 @@ module.exports = [ { permanent: true, source: '/docs/guides/getting-started/tutorials/with-expo-social-auth', - destination: '/docs/guides/getting-started/tutorials/with-expo-react-native-social-auth', + destination: '/docs/guides/auth/quickstarts/with-expo-react-native-social-auth', }, { permanent: true, diff --git a/packages/shared-data/error-codes.ts b/packages/shared-data/error-codes.ts index 235a90f1a0aca..52f1fe7496e13 100644 --- a/packages/shared-data/error-codes.ts +++ b/packages/shared-data/error-codes.ts @@ -433,7 +433,7 @@ export const ERROR_CODES: Record Date: Wed, 22 Jul 2026 14:53:46 -0400 Subject: [PATCH 11/15] test(sql-editor): add mock-free hook tests (Step 4) (#48214) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Step 4 of the SQL editor testability plan: **mock-free hook tests** for the extracted SQL editor hooks, built on the Step 3 renderHook harness (`tests/lib/sql-editor-test-utils.tsx`) — in-memory editor port + real valtio stores + MSW. **Zero `vi.mock`.** | File | Tests | Covers | |------|-------|--------| | `useSqlEditorExecution.test.tsx` | 8 | destructive-query gating (`potentialIssues` vs. forced run), auto-limit suffixing, connection-string → `x-connection-encrypted` header, `onSuccess`/`onError` session-store writes, error-line highlight, diff-open short-circuit | | `useSqlEditorAi.test.tsx` | 7 | one-shot diff-request drain (empty vs. non-empty editor), drain-exactly-once across remounts, accept/discard diff, `onDebug` opening the assistant chat + debug prompt | | `usePrettifyQuery.test.tsx` | 2 | in-place format + write-back, diff-open no-op | | `useSnippetIdentity.test.tsx` | 2 | generated identity + store-driven loading state | | `useSnippetTitleGenerator.test.tsx` | 2 | untitled-snippet naming via the title endpoint | Every test exercises real dependencies at the seam where they're real: network via MSW, stores used real and reset per test, Monaco via the in-memory editor port. ## Test plan - [x] `pnpm test:studio -- SQLEditor` → **286/286 passing** (21 new tests included) - [x] `pnpm --filter studio typecheck` clean - [x] Confirmed zero `vi.mock` in the new files ## Summary by CodeRabbit * **Tests** * Added comprehensive automated coverage for SQL query formatting, snippet identity, and AI-generated titles. * Added coverage for AI-assisted SQL editing, including diff acceptance, rejection, debugging, and request handling. * Added coverage for query execution, result persistence, safety checks, replica selection, error highlighting, and diff-state behavior. --- .../SQLEditor/usePrettifyQuery.test.tsx | 67 +++++ .../SQLEditor/useSnippetIdentity.test.tsx | 42 +++ .../useSnippetTitleGenerator.test.tsx | 50 ++++ .../SQLEditor/useSqlEditorAi.test.tsx | 152 ++++++++++ .../SQLEditor/useSqlEditorExecution.test.tsx | 263 ++++++++++++++++++ 5 files changed, 574 insertions(+) create mode 100644 apps/studio/components/interfaces/SQLEditor/usePrettifyQuery.test.tsx create mode 100644 apps/studio/components/interfaces/SQLEditor/useSnippetIdentity.test.tsx create mode 100644 apps/studio/components/interfaces/SQLEditor/useSnippetTitleGenerator.test.tsx create mode 100644 apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.test.tsx create mode 100644 apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.test.tsx diff --git a/apps/studio/components/interfaces/SQLEditor/usePrettifyQuery.test.tsx b/apps/studio/components/interfaces/SQLEditor/usePrettifyQuery.test.tsx new file mode 100644 index 0000000000000..d96a2d7ba849e --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/usePrettifyQuery.test.tsx @@ -0,0 +1,67 @@ +import { act, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { usePrettifyQuery } from './usePrettifyQuery' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { formatSql } from '@/lib/formatSql' +import { sqlEditorState } from '@/state/sql-editor/sql-editor-state' +import { + createInMemoryEditor, + renderSqlEditorHook, + resetSqlEditorStores, + seedSnippet, + setupSqlEditorMocks, +} from '@/tests/lib/sql-editor-test-utils' + +const SNIPPET_ID = 'prettify-snippet' +const MESSY_SQL = 'select id,name from users' + +function usePrettifyHarness({ isDiffOpen }: { isDiffOpen: boolean }) { + const { data: project } = useSelectedProjectQuery() + const prettifyQuery = usePrettifyQuery({ id: SNIPPET_ID, isDiffOpen }) + return { prettifyQuery, isReady: !!project } +} + +beforeEach(() => { + resetSqlEditorStores() + setupSqlEditorMocks() + seedSnippet({ id: SNIPPET_ID, name: 'My query', sql: MESSY_SQL }) +}) + +afterEach(() => { + resetSqlEditorStores() +}) + +describe('usePrettifyQuery', () => { + it('formats the editor SQL in place and writes it back to the snippet store', async () => { + const inMemoryEditor = createInMemoryEditor(MESSY_SQL) + const { result } = renderSqlEditorHook( + (props: { isDiffOpen: boolean }) => usePrettifyHarness(props), + { inMemoryEditor, initialProps: { isDiffOpen: false } } + ) + await waitFor(() => expect(result.current.isReady).toBe(true)) + + await act(async () => { + await result.current.prettifyQuery() + }) + + const expected = formatSql(MESSY_SQL) + expect(inMemoryEditor.editor.getValue()).toBe(expected) + expect(sqlEditorState.snippets[SNIPPET_ID].snippet.content?.unchecked_sql).toBe(expected) + }) + + it('is a no-op while a diff is open', async () => { + const inMemoryEditor = createInMemoryEditor(MESSY_SQL) + const { result } = renderSqlEditorHook( + (props: { isDiffOpen: boolean }) => usePrettifyHarness(props), + { inMemoryEditor, initialProps: { isDiffOpen: true } } + ) + await waitFor(() => expect(result.current.isReady).toBe(true)) + + await act(async () => { + await result.current.prettifyQuery() + }) + + expect(inMemoryEditor.editor.getValue()).toBe(MESSY_SQL) + }) +}) diff --git a/apps/studio/components/interfaces/SQLEditor/useSnippetIdentity.test.tsx b/apps/studio/components/interfaces/SQLEditor/useSnippetIdentity.test.tsx new file mode 100644 index 0000000000000..61e8b723917b9 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/useSnippetIdentity.test.tsx @@ -0,0 +1,42 @@ +import { waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it } from 'vitest' + +import { useSnippetIdentity } from './useSnippetIdentity' +import { + renderSqlEditorHook, + resetSqlEditorStores, + seedSnippet, +} from '@/tests/lib/sql-editor-test-utils' + +/** + * `useParams` is globally stubbed to `{ ref: 'default' }` (no snippet id), so + * these tests exercise the generated-id branch. The pure id/loading derivation + * itself is proven exhaustively against `deriveSnippetIdentity` in + * `SQLEditor.utils.test`; here we assert the hook wires the store in. + */ +describe('useSnippetIdentity', () => { + beforeEach(() => { + resetSqlEditorStores() + }) + + it('generates a fresh snippet id + name when there is no URL id', () => { + const { result } = renderSqlEditorHook(useSnippetIdentity) + + expect(result.current.urlId).toBeUndefined() + expect(result.current.id).toEqual(expect.any(String)) + expect(result.current.id.length).toBeGreaterThan(0) + expect(result.current.generatedNewSnippetName).toEqual(expect.any(String)) + expect(result.current.generatedNewSnippetName.length).toBeGreaterThan(0) + }) + + it('reports loading until the snippet content is present in the store', async () => { + const { result } = renderSqlEditorHook(useSnippetIdentity) + + // The generated snippet has no content in the store yet. + expect(result.current.isLoading).toBe(true) + + seedSnippet({ id: result.current.id, name: 'My query', sql: 'select 1;' }) + + await waitFor(() => expect(result.current.isLoading).toBe(false)) + }) +}) diff --git a/apps/studio/components/interfaces/SQLEditor/useSnippetTitleGenerator.test.tsx b/apps/studio/components/interfaces/SQLEditor/useSnippetTitleGenerator.test.tsx new file mode 100644 index 0000000000000..93012e33a44f7 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/useSnippetTitleGenerator.test.tsx @@ -0,0 +1,50 @@ +import { act, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { useSnippetTitleGenerator } from './useSnippetTitleGenerator' +import { sqlEditorState } from '@/state/sql-editor/sql-editor-state' +import { + renderSqlEditorHook, + resetSqlEditorStores, + seedSnippet, + setupSqlEditorMocks, +} from '@/tests/lib/sql-editor-test-utils' + +const SNIPPET_ID = 'title-snippet' + +beforeEach(() => { + resetSqlEditorStores() + // The title endpoint (POST /ai/sql/title-v2) returns { title: 'Generated title' }. + setupSqlEditorMocks() + seedSnippet({ id: SNIPPET_ID, name: 'Untitled query', sql: 'select 1;' }) +}) + +afterEach(() => { + resetSqlEditorStores() +}) + +describe('useSnippetTitleGenerator', () => { + it('names an untitled snippet from the generated title and queues it for saving', async () => { + const { result } = renderSqlEditorHook(useSnippetTitleGenerator) + + await act(async () => { + await result.current.setAiTitle(SNIPPET_ID, 'select 1;') + }) + + await waitFor(() => + expect(sqlEditorState.snippets[SNIPPET_ID].snippet.name).toBe('Generated title') + ) + expect(sqlEditorState.needsSaving.has(SNIPPET_ID)).toBe(true) + }) + + it('returns the generated title from generateSqlTitle', async () => { + const { result } = renderSqlEditorHook(useSnippetTitleGenerator) + + let title: string | undefined + await act(async () => { + title = (await result.current.generateSqlTitle({ sql: 'select 1;' })).title + }) + + expect(title).toBe('Generated title') + }) +}) diff --git a/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.test.tsx b/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.test.tsx new file mode 100644 index 0000000000000..244f4fc443148 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.test.tsx @@ -0,0 +1,152 @@ +import { act, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { useSqlEditorDiff, useSqlEditorPrompt } from './hooks' +import { DiffType } from './SQLEditor.types' +import { useSqlEditorAi } from './useSqlEditorAi' +import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' +import { sidebarManagerState } from '@/state/sidebar-manager-state' +import { sqlEditorDiffRequestState } from '@/state/sql-editor/sql-editor-diff-request' +import { sqlEditorSessionState } from '@/state/sql-editor/sql-editor-session-state' +import { + createInMemoryEditor, + renderSqlEditorHook, + resetSqlEditorStores, + seedSnippet, + setupSqlEditorMocks, +} from '@/tests/lib/sql-editor-test-utils' + +const SNIPPET_ID = 'ai-snippet' + +/** + * Composes the diff + prompt state hooks the AI hook depends on (production + * wires these together in `SQLEditorControllers`), so tests drive the real + * accept/discard/drain flows end to end. + */ +function useAiHarness({ editorMountCount = 1 }: { editorMountCount?: number } = {}) { + const diff = useSqlEditorDiff() + const prompt = useSqlEditorPrompt() + const ai = useSqlEditorAi({ id: SNIPPET_ID, editorMountCount, diff, prompt }) + return { ai, diff, prompt } +} + +beforeEach(() => { + resetSqlEditorStores() + setupSqlEditorMocks() +}) + +afterEach(() => { + resetSqlEditorStores() +}) + +describe('useSqlEditorAi — diff-request drain', () => { + it('copies the requested SQL straight into an empty editor and consumes the request', async () => { + sqlEditorDiffRequestState.requestDiff('select 100;', DiffType.Modification) + const inMemoryEditor = createInMemoryEditor('') + + const { result } = renderSqlEditorHook(useAiHarness, { inMemoryEditor }) + + await waitFor(() => expect(inMemoryEditor.editor.getValue()).toBe('select 100;')) + // One-shot: the request is drained so it can't re-apply to a later editor. + expect(sqlEditorDiffRequestState.pending).toBeUndefined() + expect(result.current.diff.isDiffOpen).toBe(false) + }) + + it('opens a diff between existing and requested SQL when the editor is non-empty', async () => { + sqlEditorDiffRequestState.requestDiff('select 42;', DiffType.Modification) + const inMemoryEditor = createInMemoryEditor('select 1;') + + const { result } = renderSqlEditorHook(useAiHarness, { inMemoryEditor }) + + await waitFor(() => expect(result.current.diff.isDiffOpen).toBe(true)) + // The diff-sync effect pushes the resolved diff into the (in-memory) diff editor. + expect(inMemoryEditor.getDiffOriginal()).toBe('select 1;') + expect(inMemoryEditor.getDiffModified()).toBe('select 42;') + expect(sqlEditorDiffRequestState.pending).toBeUndefined() + // The editor's own contents are untouched until the diff is accepted. + expect(inMemoryEditor.editor.getValue()).toBe('select 1;') + }) + + it('drains a pending request exactly once across editor remounts', async () => { + sqlEditorDiffRequestState.requestDiff('select 100;', DiffType.Modification) + const inMemoryEditor = createInMemoryEditor('') + + const { rerender } = renderSqlEditorHook(useAiHarness, { + inMemoryEditor, + initialProps: { editorMountCount: 1 }, + }) + + await waitFor(() => expect(inMemoryEditor.editor.getValue()).toBe('select 100;')) + + // Simulate a fresh editor mount: the consumed request must not re-apply. + inMemoryEditor.setValue('edited by user') + rerender({ editorMountCount: 2 }) + + await new Promise((r) => setTimeout(r, 20)) + expect(inMemoryEditor.editor.getValue()).toBe('edited by user') + }) +}) + +describe('useSqlEditorAi — accept / discard diff', () => { + async function openModificationDiff() { + sqlEditorDiffRequestState.requestDiff('select 42;', DiffType.Modification) + const inMemoryEditor = createInMemoryEditor('select 1;') + const utils = renderSqlEditorHook(useAiHarness, { inMemoryEditor }) + await waitFor(() => expect(utils.result.current.diff.isDiffOpen).toBe(true)) + return { ...utils, inMemoryEditor } + } + + it('accepting a modification writes the diff result back into the editor and closes the diff', async () => { + const { result, inMemoryEditor } = await openModificationDiff() + + await act(async () => { + await result.current.ai.acceptAiHandler() + }) + + await waitFor(() => expect(result.current.diff.isDiffOpen).toBe(false)) + expect(inMemoryEditor.editor.getValue()).toBe('select 42;') + }) + + it('discarding a diff leaves the editor untouched and closes the diff', async () => { + const { result, inMemoryEditor } = await openModificationDiff() + + await act(async () => { + result.current.ai.discardAiHandler() + }) + + await waitFor(() => expect(result.current.diff.isDiffOpen).toBe(false)) + expect(inMemoryEditor.editor.getValue()).toBe('select 1;') + }) +}) + +describe('useSqlEditorAi — debug', () => { + it('onDebug opens the assistant sidebar and starts a debug chat from the failing snippet', async () => { + seedSnippet({ id: SNIPPET_ID, name: 'Broken query', sql: 'selct 1;' }) + sqlEditorSessionState.addResultError(SNIPPET_ID, { message: 'syntax error at or near "selct"' }) + + const { result, aiAssistantState } = renderSqlEditorHook(useAiHarness) + + await act(async () => { + await result.current.ai.onDebug() + }) + + // No sidebar is registered in the test tree, so opening queues a pending open. + expect(sidebarManagerState.pendingSidebarOpen).toBe(SIDEBAR_KEYS.AI_ASSISTANT) + + const activeChat = aiAssistantState.chats[aiAssistantState.activeChatId ?? ''] + expect(activeChat?.name).toBe('Debug SQL snippet') + expect(aiAssistantState.sqlSnippets).toEqual(['selct 1;']) + expect(aiAssistantState.initialInput).toContain('syntax error at or near "selct"') + }) + + it('buildDebugPrompt embeds the snippet SQL and its error message', async () => { + seedSnippet({ id: SNIPPET_ID, name: 'Broken query', sql: 'selct 1;' }) + sqlEditorSessionState.addResultError(SNIPPET_ID, { message: 'boom' }) + + const { result } = renderSqlEditorHook(useAiHarness) + + const promptText = result.current.ai.buildDebugPrompt() + expect(promptText).toContain('boom') + expect(promptText).toContain('selct 1;') + }) +}) diff --git a/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.test.tsx b/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.test.tsx new file mode 100644 index 0000000000000..4c4be71b112e1 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.test.tsx @@ -0,0 +1,263 @@ +import { acceptUntrustedSql, untrustedSql, type SafeSqlFragment } from '@supabase/pg-meta' +import { act, waitFor } from '@testing-library/react' +import { HttpResponse } from 'msw' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { useSqlEditorExecution } from './useSqlEditorExecution' +import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { createDatabaseSelectorState } from '@/state/database-selector' +import { sqlEditorSessionState } from '@/state/sql-editor/sql-editor-session-state' +import { addAPIMock } from '@/tests/lib/msw' +import { + renderSqlEditorHook, + resetSqlEditorStores, + seedSnippet, + setupSqlEditorMocks, +} from '@/tests/lib/sql-editor-test-utils' + +const SNIPPET_ID = 'execution-snippet' + +/** Promote raw text to the `SafeSqlFragment` the run pipeline expects, exactly + * as the toolbar/editor-panel promote it right at the user action. */ +const sql = (text: string): SafeSqlFragment => acceptUntrustedSql(untrustedSql(text)) + +type ExecutionArgs = Parameters[0] + +/** + * Wraps the hook with the two queries it depends on so tests can wait for the + * project + read-replicas data to load before firing a run (an unloaded project + * or connection string silently short-circuits `executeQuery`). + */ +function useExecutionHarness(args: ExecutionArgs) { + const { data: project } = useSelectedProjectQuery() + const { data: databases } = useReadReplicasQuery({ projectRef: 'default' }) + const execution = useSqlEditorExecution(args) + return { execution, isReady: !!project && !!databases } +} + +/** Registers a query-endpoint resolver that records every executed SQL body. */ +function captureExecutedQueries(rows: unknown[] = []) { + const queries: string[] = [] + addAPIMock({ + method: 'post', + path: '/platform/pg-meta/:ref/query', + response: async ({ request }) => { + const body = (await request.json()) as { query: string } + queries.push(body.query) + return HttpResponse.json(rows) + }, + }) + return queries +} + +function renderExecution( + args: Partial = {}, + { selectedDatabaseId = 'default' }: { selectedDatabaseId?: string } = {} +) { + const databaseSelectorState = createDatabaseSelectorState() + databaseSelectorState.setSelectedDatabaseId(selectedDatabaseId) + + const setAiTitle = vi.fn() + const initialProps: ExecutionArgs = { + id: SNIPPET_ID, + isDiffOpen: false, + hasSelection: false, + setAiTitle, + ...args, + } + + const utils = renderSqlEditorHook((props: ExecutionArgs) => useExecutionHarness(props), { + initialProps, + databaseSelectorState, + }) + + return { ...utils, setAiTitle } +} + +beforeEach(() => { + resetSqlEditorStores() + setupSqlEditorMocks() + seedSnippet({ id: SNIPPET_ID, name: 'My query', sql: 'select 1;' }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('useSqlEditorExecution', () => { + it('runs a non-destructive query and writes the result to the session store', async () => { + const rows = [{ id: 1, name: 'row-1' }] + const queries = captureExecutedQueries(rows) + + const { result } = renderExecution() + await waitFor(() => expect(result.current.isReady).toBe(true)) + + await act(async () => { + await result.current.execution.executeQuery(sql('select 1')) + }) + + await waitFor(() => expect(sqlEditorSessionState.results[SNIPPET_ID]).toBeDefined()) + expect(sqlEditorSessionState.results[SNIPPET_ID][0].rows).toEqual(rows) + expect(queries.some((q) => /select 1/i.test(q))).toBe(true) + }) + + it('appends the auto-limit to a bare SELECT and records it on the result', async () => { + const queries = captureExecutedQueries([]) + + const { result } = renderExecution() + await waitFor(() => expect(result.current.isReady).toBe(true)) + + await act(async () => { + await result.current.execution.executeQuery(sql('select 1')) + }) + + await waitFor(() => expect(sqlEditorSessionState.results[SNIPPET_ID]).toBeDefined()) + // The session store's `limit` defaults to 100 (see resetSqlEditorStores). + expect(sqlEditorSessionState.results[SNIPPET_ID][0].autoLimit).toBe(100) + expect(queries.some((q) => /limit 100/i.test(q))).toBe(true) + }) + + it('gates a destructive query behind potentialIssues instead of running it', async () => { + const queries = captureExecutedQueries([]) + + const { result } = renderExecution() + await waitFor(() => expect(result.current.isReady).toBe(true)) + + await act(async () => { + await result.current.execution.executeQuery(sql('drop table foo;')) + }) + + // The warning-modal gate fired: issues are surfaced and nothing was executed. + await waitFor(() => expect(result.current.execution.potentialIssues).toBeDefined()) + expect(result.current.execution.potentialIssues?.hasDestructiveOperations).toBe(true) + expect(queries.some((q) => /drop table foo/i.test(q))).toBe(false) + expect(sqlEditorSessionState.results[SNIPPET_ID]).toBeUndefined() + }) + + it('runs a destructive query when forced', async () => { + const queries = captureExecutedQueries([]) + + const { result } = renderExecution() + await waitFor(() => expect(result.current.isReady).toBe(true)) + + await act(async () => { + await result.current.execution.executeQuery(sql('drop table foo;'), true) + }) + + await waitFor(() => expect(queries.some((q) => /drop table foo/i.test(q))).toBe(true)) + await waitFor(() => expect(sqlEditorSessionState.results[SNIPPET_ID]).toBeDefined()) + }) + + it('highlights the error line and records the error on failure', async () => { + addAPIMock({ + method: 'post', + path: '/platform/pg-meta/:ref/query', + response: async ({ request }) => { + const body = (await request.json()) as { query: string } + // The event-triggers probe shares this endpoint; only fail the real run. + if (/select 1/i.test(body.query)) { + return HttpResponse.json( + { + message: 'syntax error', + position: '8', + formattedError: + 'ERROR: syntax error at or near "slect"\nLINE 3: slect 1;\n ^', + }, + { status: 400 } + ) + } + return HttpResponse.json([]) + }, + }) + + const { result, inMemoryEditor } = renderExecution() + await waitFor(() => expect(result.current.isReady).toBe(true)) + + await act(async () => { + await result.current.execution.executeQuery(sql('select 1')) + }) + + await waitFor(() => expect(sqlEditorSessionState.results[SNIPPET_ID]?.[0].error).toBeDefined()) + // No selection → base line 0 + parsed `LINE 3` = 3. + expect(inMemoryEditor.getHighlightedLine()).toBe(3) + expect(inMemoryEditor.getRevealedLine()).toBe(3) + }) + + it("sends the selected database's connection string as the connection header", async () => { + const connectionString = 'postgresql://postgres@replica.example:5432/postgres' + const connectionHeaders: (string | null)[] = [] + addAPIMock({ + method: 'post', + path: '/platform/pg-meta/:ref/query', + response: async ({ request }) => { + const body = (await request.json()) as { query: string } + if (/select 1/i.test(body.query)) { + connectionHeaders.push(request.headers.get('x-connection-encrypted')) + } + return HttpResponse.json([]) + }, + }) + + // Point the read-replicas list + selector at a replica with its own conn string. + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref/databases', + response: [ + { + identifier: 'replica-1', + connectionString, + cloud_provider: 'AWS', + db_host: 'db.replica.supabase.co', + db_name: 'postgres', + db_port: 5432, + db_user: 'postgres', + inserted_at: '2024-01-01T00:00:00Z', + region: 'us-east-1', + restUrl: 'https://replica.supabase.co/rest/v1/', + size: 'ci_micro', + status: 'ACTIVE_HEALTHY', + }, + ], + }) + + const { result } = renderExecution({}, { selectedDatabaseId: 'replica-1' }) + await waitFor(() => expect(result.current.isReady).toBe(true)) + + await act(async () => { + await result.current.execution.executeQuery(sql('select 1')) + }) + + await waitFor(() => expect(connectionHeaders.length).toBeGreaterThan(0)) + expect(connectionHeaders[0]).toBe(connectionString) + }) + + it('short-circuits while a diff is open', async () => { + const queries = captureExecutedQueries([]) + + const { result } = renderExecution({ isDiffOpen: true }) + await waitFor(() => expect(result.current.isReady).toBe(true)) + + await act(async () => { + await result.current.execution.executeQuery(sql('select 1')) + }) + + await new Promise((r) => setTimeout(r, 20)) + expect(queries.some((q) => /select 1/i.test(q))).toBe(false) + expect(sqlEditorSessionState.results[SNIPPET_ID]).toBeUndefined() + }) + + it('does not auto-generate a title for an already-named snippet', async () => { + captureExecutedQueries([]) + + const { result, setAiTitle } = renderExecution() + await waitFor(() => expect(result.current.isReady).toBe(true)) + + await act(async () => { + await result.current.execution.executeQuery(sql('select 1')) + }) + + await waitFor(() => expect(sqlEditorSessionState.results[SNIPPET_ID]).toBeDefined()) + expect(setAiTitle).not.toHaveBeenCalled() + }) +}) From 6f6badae51d7c892d457c80da5cda80ca4cf835e Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:21:15 -0400 Subject: [PATCH 12/15] fix(eslint): promote require-explicit-tabindex to error (#48170) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Accessibility / lint hardening (Safari keyboard focus). ## What is the current behavior? `supabase/require-explicit-tabindex` is `'warn'`. Studio’s ratchet was at 0 but the rule was still ratcheted; www / docs / design-system still had raw ` @@ -263,7 +269,12 @@ function TemplatesPage({ onNavigateToSmtp }: { onNavigateToSmtp: () => void }) { )} /> -
diff --git a/apps/design-system/registry/default/example/page-layout-edge-function.tsx b/apps/design-system/registry/default/example/page-layout-edge-function.tsx index 4377bee9ec173..a3b250d71e033 100644 --- a/apps/design-system/registry/default/example/page-layout-edge-function.tsx +++ b/apps/design-system/registry/default/example/page-layout-edge-function.tsx @@ -261,6 +261,7 @@ export default function PageLayoutEdgeFunction() { {pages.map((page) => ( @@ -251,7 +254,10 @@ function DataSources({ schema }: { schema: any }) { {schema[dataSource].block.attributes[attribute].type ?? ( - diff --git a/apps/docs/features/directives/CodeSample.client.tsx b/apps/docs/features/directives/CodeSample.client.tsx index ea206c1a49207..61542c0ff6a3a 100644 --- a/apps/docs/features/directives/CodeSample.client.tsx +++ b/apps/docs/features/directives/CodeSample.client.tsx @@ -2,7 +2,6 @@ import Link from 'next/link' import { useState, type PropsWithChildren } from 'react' - import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from 'ui' import { Admonition } from 'ui-patterns/admonition' @@ -42,7 +41,10 @@ function MultipleSources({ children, sources }: PropsWithChildren<{ sources: (st {children} - diff --git a/apps/docs/features/docs/Reference.navigation.client.tsx b/apps/docs/features/docs/Reference.navigation.client.tsx index e6308f3fcd647..4ddf294fd202b 100644 --- a/apps/docs/features/docs/Reference.navigation.client.tsx +++ b/apps/docs/features/docs/Reference.navigation.client.tsx @@ -331,6 +331,7 @@ function CompoundRefLink({ } + actions={} /> ) @@ -87,7 +87,9 @@ describe('IntegrationOverviewTab', () => { }) it('disables actions when extensions are uninstalled and hideRequiredExtensionsSection is false', () => { - customRender(Enable integration} />) + customRender( + Enable integration} /> + ) const actionsArea = screen.getByText('Enable integration').closest('[aria-disabled]') expect(actionsArea).toHaveAttribute('aria-disabled', 'true') diff --git a/apps/studio/components/interfaces/LocalDropdown.test.tsx b/apps/studio/components/interfaces/LocalDropdown.test.tsx index f34fa0c2b7f16..5afef5f725fe4 100644 --- a/apps/studio/components/interfaces/LocalDropdown.test.tsx +++ b/apps/studio/components/interfaces/LocalDropdown.test.tsx @@ -70,7 +70,9 @@ vi.mock('ui', async () => { children, ...props }: React.ButtonHTMLAttributes & { children?: ReactNode }) => ( - + ), cn: (...classes: Array) => classes.filter(Boolean).join(' '), DropdownMenu: ({ children }: { children: ReactNode }) =>
{children}
, @@ -92,6 +94,7 @@ vi.mock('ui', async () => {
{children}
) : ( , + }) => ( + + ), Tooltip: ({ children }: { children: ReactNode }) =>
{children}
, TooltipContent: ({ children }: { children: ReactNode }) =>
{children}
, TooltipTrigger: ({ children }: { children: ReactNode }) =>
{children}
, diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.test.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.test.tsx index 579f7c8c796a1..4f1a032705135 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.test.tsx +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.test.tsx @@ -27,7 +27,7 @@ vi.mock('@/components/ui/ButtonTooltip', () => ({ type: _type, ...props }: any) => ( - diff --git a/apps/studio/components/interfaces/Settings/General/Project.test.tsx b/apps/studio/components/interfaces/Settings/General/Project.test.tsx index 82d5cce71846c..f5d6803b62d33 100644 --- a/apps/studio/components/interfaces/Settings/General/Project.test.tsx +++ b/apps/studio/components/interfaces/Settings/General/Project.test.tsx @@ -27,7 +27,14 @@ vi.mock('ui', () => ({ children: ReactNode asChild?: boolean type?: string - }) => (asChild ? <>{children} : ), + }) => + asChild ? ( + <>{children} + ) : ( + + ), Card: ({ children }: { children: ReactNode }) =>
{children}
, CardContent: ({ children }: { children: ReactNode }) =>
{children}
, })) diff --git a/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx b/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx index b0fbecf523597..bfb0e7201cf31 100644 --- a/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx +++ b/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx @@ -29,7 +29,9 @@ const Page = ({ onClose }: { onClose: () => void }) => { const [open, setOpen] = useState(false) return ( - + void }) => { const [open, setOpen] = useState(false) return ( - + void }) => { const [open, setOpen] = useState(false) return ( - + null, })) vi.mock('@/components/ui/ButtonTooltip', () => ({ - ButtonTooltip: ({ children, ...props }: any) => , + ButtonTooltip: ({ children, ...props }: any) => ( + + ), })) vi.mock('@/components/ui/PartnerIcon', () => ({ default: () =>
, diff --git a/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.test.tsx b/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.test.tsx index b4246956938d7..0d22406570bce 100644 --- a/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.test.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/EdgeFunctionRenderer.test.tsx @@ -58,8 +58,12 @@ vi.mock('../EdgeFunctionBlock/EdgeFunctionBlock', () => ({ {showReplaceWarning && (

An edge function with this name already exists.

- - + +
)}
@@ -73,7 +77,11 @@ vi.mock('./ConfirmFooter', () => ({ }: { confirmLabel?: string onConfirm?: () => void - }) => , + }) => ( + + ), })) describe('EdgeFunctionRenderer', () => { diff --git a/apps/studio/components/ui/Shortcut.test.tsx b/apps/studio/components/ui/Shortcut.test.tsx index b0b90d101ece2..8663459425123 100644 --- a/apps/studio/components/ui/Shortcut.test.tsx +++ b/apps/studio/components/ui/Shortcut.test.tsx @@ -28,7 +28,7 @@ describe('Shortcut', () => { it('renders the wrapped child', () => { render( {}}> - + ) expect(screen.getByRole('button', { name: 'Open' })).toBeInTheDocument() @@ -37,7 +37,7 @@ describe('Shortcut', () => { it('wraps the child in ShortcutTooltip', () => { render( {}}> - + ) const tooltip = screen.getByTestId('shortcut-tooltip') @@ -50,7 +50,7 @@ describe('Shortcut', () => { const handler = vi.fn() render( - + ) expect(mockUseShortcut).toHaveBeenCalledWith(SHORTCUT_IDS.ACTION_BAR_SAVE, handler, undefined) @@ -61,7 +61,7 @@ describe('Shortcut', () => { const options = { enabled: true, registerInCommandMenu: true } render( - + ) expect(mockUseShortcut).toHaveBeenCalledWith(SHORTCUT_IDS.ACTION_BAR_SAVE, handler, options) @@ -75,7 +75,7 @@ describe('Shortcut', () => { onTrigger={handler} options={{ enabled: false }} > - + ) expect(mockUseShortcut).toHaveBeenLastCalledWith(SHORTCUT_IDS.ACTION_BAR_SAVE, handler, { @@ -84,7 +84,7 @@ describe('Shortcut', () => { rerender( - + ) expect(mockUseShortcut).toHaveBeenLastCalledWith(SHORTCUT_IDS.ACTION_BAR_SAVE, handler, { @@ -96,14 +96,14 @@ describe('Shortcut', () => { const handler = vi.fn() const { rerender } = render( - + ) expect(mockUseShortcut).toHaveBeenLastCalledWith(SHORTCUT_IDS.NAV_HOME, handler, undefined) rerender( - + ) expect(mockUseShortcut).toHaveBeenLastCalledWith( @@ -118,7 +118,7 @@ describe('Shortcut', () => { it('forwards shortcutId to ShortcutTooltip', () => { render( {}}> - + ) expect(mockShortcutTooltip).toHaveBeenCalled() @@ -135,7 +135,7 @@ describe('Shortcut', () => { sideOffset={8} delayDuration={100} > - + ) const props = mockShortcutTooltip.mock.calls.at(-1)![0] @@ -148,7 +148,7 @@ describe('Shortcut', () => { it('forwards label override to ShortcutTooltip', () => { render( {}} label="Go home"> - + ) expect(mockShortcutTooltip.mock.calls.at(-1)![0].label).toBe('Go home') @@ -157,7 +157,7 @@ describe('Shortcut', () => { it('omits undefined positioning props rather than fabricating them', () => { render( {}}> - + ) const props = mockShortcutTooltip.mock.calls.at(-1)![0] @@ -175,7 +175,9 @@ describe('Shortcut', () => { const onTrigger = vi.fn() render( - + ) @@ -188,7 +190,7 @@ describe('Shortcut', () => { const onTrigger = vi.fn() render( - + ) expect(onTrigger).not.toHaveBeenCalled() diff --git a/apps/studio/scripts/ratchet-rules.json b/apps/studio/scripts/ratchet-rules.json index 80826ac1e4a26..4f34402cd7887 100644 --- a/apps/studio/scripts/ratchet-rules.json +++ b/apps/studio/scripts/ratchet-rules.json @@ -19,7 +19,6 @@ "jsx-a11y/anchor-is-valid", "jsx-a11y/heading-has-content", "jsx-a11y/no-distracting-elements", - "supabase/require-explicit-tabindex", "valtio/state-snapshot-rule", "react-hook-form/no-use-watch" ] diff --git a/apps/studio/tests/components/SQLEditor/SQLEditor.test.tsx b/apps/studio/tests/components/SQLEditor/SQLEditor.test.tsx index 9d6718f24ca9d..7d828dbf9f29d 100644 --- a/apps/studio/tests/components/SQLEditor/SQLEditor.test.tsx +++ b/apps/studio/tests/components/SQLEditor/SQLEditor.test.tsx @@ -92,10 +92,11 @@ vi.mock('@/components/interfaces/SQLEditor/MonacoEditor', async () => { return (
{props.placeholder}
- - -
@@ -150,13 +151,14 @@ vi.mock('@/components/interfaces/SQLEditor/UtilityPanel/UtilityActions', () => ( UtilityActions: (props: any) => (
- {String(props.isExecuting)} @@ -168,7 +170,7 @@ vi.mock('@/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel', () => ({ UtilityPanel: (props: any) => (
{props.activeTab} -
@@ -179,13 +181,13 @@ vi.mock('@/components/interfaces/SQLEditor/RunQueryWarningModal', () => ({ RunQueryWarningModal: (props: any) => props.visible ? (
- - -
diff --git a/apps/www/app/(home)/_components/DashboardFeaturesSection.tsx b/apps/www/app/(home)/_components/DashboardFeaturesSection.tsx index 7b2a51afa4651..dca41fefd6f89 100644 --- a/apps/www/app/(home)/_components/DashboardFeaturesSection.tsx +++ b/apps/www/app/(home)/_components/DashboardFeaturesSection.tsx @@ -35,6 +35,7 @@ export function DashboardFeaturesSection({
{tabs.map((tab, index) => ( - diff --git a/apps/www/app/(products)/edge-functions/_components/IntegratesSectionClient.tsx b/apps/www/app/(products)/edge-functions/_components/IntegratesSectionClient.tsx index 5a396516ebbfd..0587801d4a33f 100644 --- a/apps/www/app/(products)/edge-functions/_components/IntegratesSectionClient.tsx +++ b/apps/www/app/(products)/edge-functions/_components/IntegratesSectionClient.tsx @@ -118,6 +118,7 @@ export function IntegratesSectionClient({ useCases }: { useCases: UseCase[] }) { const Icon = ICONS[useCase.icon] return ( + ) - return url ? {renderButton()} : renderButton() + if (url) { + return ( + + {content} + + ) + } + + return ( + + ) } export default Button diff --git a/apps/www/components/Changelog/ChangelogDetailSidebar.tsx b/apps/www/components/Changelog/ChangelogDetailSidebar.tsx index ede236257488c..ad5cd2d56a778 100644 --- a/apps/www/components/Changelog/ChangelogDetailSidebar.tsx +++ b/apps/www/components/Changelog/ChangelogDetailSidebar.tsx @@ -57,6 +57,7 @@ export function ChangelogDetailSidebar({ slug, url, labels, className }: Props) View discussion on GitHub {' '} / + > +
+ +
+
+ {props.title} +

{props.description}

) diff --git a/apps/www/components/LaunchWeek/X/Album/Player.tsx b/apps/www/components/LaunchWeek/X/Album/Player.tsx index a3b4f0910d7c2..954774e6fc1fb 100644 --- a/apps/www/components/LaunchWeek/X/Album/Player.tsx +++ b/apps/www/components/LaunchWeek/X/Album/Player.tsx @@ -34,6 +34,7 @@ const Player = () => {
- -
- ) - }, - } -}) - -vi.mock('@/components/ui/DiffEditor', async () => { - const { useEffect } = await vi.importActual('react') - return { - DiffEditor: (props: any) => { - useEffect(() => { - props.onMount?.(mocks.diffEditor) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - return
- }, - } -}) - -vi.mock('@/components/ui/AIEditor/ResizableAIWidget', () => ({ - default: (props: any) => ( -
- - - -
- ), -})) - -// --- Child panel stubs (not under test) ------------------------------------- - -vi.mock('@/components/interfaces/SQLEditor/UtilityPanel/UtilityActions', () => ({ - UtilityActions: (props: any) => ( -
- - - {String(props.isExecuting)} -
- ), -})) - -vi.mock('@/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel', () => ({ - UtilityPanel: (props: any) => ( -
- {props.activeTab} - -
- ), -})) - -vi.mock('@/components/interfaces/SQLEditor/RunQueryWarningModal', () => ({ - RunQueryWarningModal: (props: any) => - props.visible ? ( -
- - - -
- ) : null, -})) - -// --- Context / selection hook stubs (orthogonal infra, not under test) ------ - -vi.mock('@/components/interfaces/SQLEditor/useAddDefinitions', () => ({ - useAddDefinitions: () => {}, -})) - -vi.mock('@/hooks/misc/useSelectedProject', () => ({ - useSelectedProjectQuery: () => ({ - data: { - id: 1, - ref: 'default', - connectionString: 'postgresql://postgres@localhost:5432/postgres', - }, - }), -})) - -vi.mock('@/hooks/misc/useSelectedOrganization', () => ({ - useSelectedOrganizationQuery: () => ({ data: { slug: 'test-org' } }), -})) - -vi.mock('@/hooks/misc/useOrgOptedIntoAi', () => ({ - useOrgAiOptInLevel: () => ({ - aiOptInLevel: 'disabled', - includeSchemaMetadata: false, - isHipaaProjectDisallowed: false, - }), -})) - -vi.mock('@/data/read-replicas/replicas-query', () => ({ - useReadReplicasQuery: () => ({ - data: [ - { - identifier: 'default', - connectionString: 'postgresql://postgres@localhost:5432/postgres', - }, - ], - isSuccess: true, - }), -})) - -vi.mock('@/data/database-event-triggers/database-event-triggers-query', () => ({ - useDatabaseEventTriggersQuery: () => ({ data: undefined }), -})) - -// `common` is globally mocked in vitestSetup to `{ ref: 'default' }`. Re-mock it -// here to also provide a snippet id (so `id` is stable and points at our seeded -// snippet) and a stable `useFlag`. -vi.mock('common', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useParams: () => ({ ref: 'default', id: SNIPPET_ID }), - useFlag: () => false, - } -}) - -// --- Helpers ---------------------------------------------------------------- - -function seedSnippet(sql: string, name = 'My query') { - ;(sqlEditorState.snippets as any)[SNIPPET_ID] = { - projectRef: 'default', - splitSizes: [50, 50], - snippet: { - id: SNIPPET_ID, - name, - project_id: 1, - owner_id: 1, - content: { sql, unchecked_sql: sql, schema_version: '1.0', favorite: false }, - }, - } -} - -function resetStores() { - for (const key of Object.keys(sqlEditorState.snippets)) { - delete (sqlEditorState.snippets as any)[key] - } - for (const key of Object.keys(sqlEditorSessionState.results)) { - delete (sqlEditorSessionState.results as any)[key] - } - sqlEditorDiffRequestState.pending = undefined -} - -const NON_EXPLAIN_ROWS = [{ id: 1, name: 'row-1' }] - -function mockQuerySuccess(rows: unknown[] = NON_EXPLAIN_ROWS) { - addAPIMock({ - method: 'post', - path: '/platform/pg-meta/:ref/query', - response: () => HttpResponse.json(rows), - }) -} - -function mockQueryError(body: Record) { - addAPIMock({ - method: 'post', - path: '/platform/pg-meta/:ref/query', - response: () => HttpResponse.json(body, { status: 400 }), - }) -} - -beforeEach(() => { - vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { - return setTimeout(() => cb(0), 0) as unknown as number - }) - resetStores() - seedSnippet('select 1;') - mocks.state.value = 'select 1;' - mocks.state.selection = null - mocks.state.selectionValue = undefined - mocks.state.decorations = ['decoration-1'] - mocks.editor.executeEdits.mockClear() - mocks.editor.deltaDecorations.mockClear() - mocks.editor.revealLineInCenter.mockClear() - mocks.editor.focus.mockClear() -}) - -afterEach(() => { - cleanup() - vi.unstubAllGlobals() - vi.restoreAllMocks() -}) - -async function renderEditor() { - const utils = customRender() - // The Monaco editor is loaded via next/dynamic; wait for the fake to mount. - await screen.findByTestId('monaco-editor') - return utils -} - -describe('SQLEditor characterization', () => { - test('1a. successful run adds result and keeps Results tab active', async () => { - const addResult = vi.spyOn(sqlEditorSessionState, 'addResult') - mockQuerySuccess(NON_EXPLAIN_ROWS) - - await renderEditor() - expect(screen.getByTestId('active-tab')).toHaveTextContent('results') - - fireEvent.click(screen.getByTestId('editor-run')) - - await waitFor(() => expect(addResult).toHaveBeenCalled()) - // The snippet id and rows are the load-bearing part; autoLimit is derived - // from the (SELECT) query + configured row limit and is asserted loosely. - expect(addResult.mock.calls[0][0]).toBe(SNIPPET_ID) - expect(addResult.mock.calls[0][1]).toEqual(NON_EXPLAIN_ROWS) - expect(screen.getByTestId('active-tab')).toHaveTextContent('results') - }) - - test('2. run error with position highlights the computed line and reveals it', async () => { - const addResultError = vi.spyOn(sqlEditorSessionState, 'addResultError') - mockQueryError({ - message: 'syntax error', - position: '8', - formattedError: 'ERROR: syntax error at or near "slect"\nLINE 3: slect 1;\n ^', - }) - - await renderEditor() - - fireEvent.click(screen.getByTestId('editor-run')) - - await waitFor(() => expect(mocks.editor.deltaDecorations).toHaveBeenCalled()) - - // With no selection, startLineNumber is 0 → highlighted line === parsed LINE (3). - const [oldDecorations, newDecorations] = mocks.editor.deltaDecorations.mock.calls[0] - expect(oldDecorations).toEqual([]) - expect(newDecorations[0].range.startLineNumber).toBe(3) - expect(newDecorations[0].range.endLineNumber).toBe(3) - expect(mocks.editor.revealLineInCenter).toHaveBeenCalledWith(3) - await waitFor(() => expect(addResultError).toHaveBeenCalled()) - }) - - test('2b. the next run clears the previously-set line highlights', async () => { - // First: produce a highlight. - mockQueryError({ - message: 'syntax error', - position: '8', - formattedError: 'ERROR: syntax error\nLINE 2: foo\n', - }) - await renderEditor() - fireEvent.click(screen.getByTestId('editor-run')) - await waitFor(() => expect(mocks.editor.deltaDecorations).toHaveBeenCalledTimes(1)) - - // Second run should clear the stored decorations before running again. - mockQuerySuccess(NON_EXPLAIN_ROWS) - fireEvent.click(screen.getByTestId('editor-run')) - await waitFor(() => - expect(mocks.editor.deltaDecorations).toHaveBeenCalledWith(['decoration-1'], []) - ) - }) - - test('3. running via the run button refocuses the editor', async () => { - mockQuerySuccess(NON_EXPLAIN_ROWS) - await renderEditor() - - fireEvent.click(screen.getByTestId('run-button')) - - await waitFor(() => expect(mocks.editor.focus).toHaveBeenCalled()) - }) - - test('3b. run button is disabled and short-circuits while a diff is open', async () => { - // Queue a diff request against a non-empty editor so a diff opens on mount. - mocks.state.value = 'select 1;' - sqlEditorDiffRequestState.requestDiff('select 42;', DiffType.Modification) - - await renderEditor() - // The queued request drains on mount and opens a diff, which disables Run. - // (Assert via the run button rather than the dynamically-imported diff editor.) - await waitFor(() => expect(screen.getByTestId('run-button')).toBeDisabled()) - - const addResult = vi.spyOn(sqlEditorSessionState, 'addResult') - // The run button is disabled while diffing; clicking should not execute. - fireEvent.click(screen.getByTestId('run-button')) - // Give any async work a chance to (not) happen. - await new Promise((r) => setTimeout(r, 20)) - expect(addResult).not.toHaveBeenCalled() - }) - - test('4. a diff request queued before mount drains exactly once', async () => { - // Empty editor → drain copies the SQL in via executeEdits('apply-ai-message'). - mocks.state.value = '' - sqlEditorDiffRequestState.requestDiff('select 100;', DiffType.Modification) - - const { unmount } = await renderEditor() - - await waitFor(() => - expect(mocks.editor.executeEdits).toHaveBeenCalledWith('apply-ai-message', expect.any(Array)) - ) - expect(mocks.editor.executeEdits).toHaveBeenCalledTimes(1) - // Request was consumed (drained), so it cannot re-apply. - expect(sqlEditorDiffRequestState.pending).toBeUndefined() - - // A fresh mount must NOT re-apply the already-consumed request. - unmount() - await renderEditor() - await new Promise((r) => setTimeout(r, 20)) - expect(mocks.editor.executeEdits).toHaveBeenCalledTimes(1) - }) - - test('5. the ask-ai widget renders only while the prompt is open', async () => { - mocks.state.value = 'select 1;' - await renderEditor() - - expect(screen.queryByTestId('ask-ai')).not.toBeInTheDocument() - - fireEvent.click(screen.getByTestId('editor-prompt')) - expect(await screen.findByTestId('ask-ai')).toBeInTheDocument() - - fireEvent.click(screen.getByTestId('ask-ai-cancel')) - await waitFor(() => expect(screen.queryByTestId('ask-ai')).not.toBeInTheDocument()) - }) - - test('6. a destructive query opens the warning modal; confirm re-runs forced', async () => { - mocks.state.value = 'drop table foo;' - let forcedQuery = '' - addAPIMock({ - method: 'post', - path: '/platform/pg-meta/:ref/query', - response: async ({ request }) => { - const body = (await request.json()) as { query: string } - forcedQuery = body.query - return HttpResponse.json(NON_EXPLAIN_ROWS) - }, - }) - - await renderEditor() - - fireEvent.click(screen.getByTestId('editor-run')) - - // Destructive query → confirmation modal, and no query is sent yet. - expect(await screen.findByTestId('warning-modal')).toBeInTheDocument() - expect(forcedQuery).toBe('') - - // Confirming forces the (same) destructive query to actually run. - fireEvent.click(screen.getByTestId('warn-confirm')) - await waitFor(() => expect(forcedQuery).toMatch(/drop table foo/i)) - }) - - test('6b. confirm-with-RLS re-runs with appended enable-RLS statements', async () => { - mocks.state.value = 'create table foo (id int);' - let capturedQuery = '' - addAPIMock({ - method: 'post', - path: '/platform/pg-meta/:ref/query', - response: async ({ request }) => { - const body = (await request.json()) as { query: string } - capturedQuery = body.query - return HttpResponse.json(NON_EXPLAIN_ROWS) - }, - }) - - await renderEditor() - - fireEvent.click(screen.getByTestId('editor-run')) - expect(await screen.findByTestId('warning-modal')).toBeInTheDocument() - - fireEvent.click(screen.getByTestId('warn-confirm-rls')) - await waitFor(() => expect(capturedQuery).toMatch(/enable row level security/i)) - }) -}) diff --git a/e2e/studio/features/sql-editor.spec.ts b/e2e/studio/features/sql-editor.spec.ts index 1c7b02f5e0a72..a2d279d1a4ed9 100644 --- a/e2e/studio/features/sql-editor.spec.ts +++ b/e2e/studio/features/sql-editor.spec.ts @@ -406,6 +406,84 @@ test.describe('SQL Editor', () => { } }) + test('destructive query warning modal: confirm re-runs the forced query', async ({ ref }) => { + await expect(page.getByText('Loading...')).not.toBeVisible() + await page.locator('.view-lines').click() + await page.keyboard.press('ControlOrMeta+KeyA') + await page.keyboard.type(`drop table pw_sql_editor_confirm_dne_e2e;`) + + // Track whether the SQL editor dispatches this specific query to pg-meta + let queryDispatched = false + const listener = (request: any) => { + if ( + request.url().includes('query?key=') && + request.method() === 'POST' && + request.postData()?.includes('drop table pw_sql_editor_confirm_dne_e2e') + ) { + queryDispatched = true + } + } + page.on('request', listener) + + await page.getByTestId('sql-run-button').click() + + // Destructive query -> confirmation modal, and no query is sent yet. + await expect(page.getByRole('heading', { name: 'Potential issue detected' })).toBeVisible() + expect(queryDispatched).toBe(false) + + // Confirming forces the (same) destructive query to actually run. + const sqlMutationPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', { + method: 'POST', + }) + await page.getByRole('button', { name: 'Run query' }).click() + await sqlMutationPromise + expect(queryDispatched).toBe(true) + + page.removeListener('request', listener) + + // clear SQL snippet + if (!isCLI()) { + await deleteSqlSnippet(page, ref, newSqlSnippetName) + } else { + await page.reload() + } + }) + + test('debug button opens the AI Assistant with the query error pre-filled', async ({ ref }) => { + await expect(page.getByText('Loading...')).not.toBeVisible() + await page.locator('.view-lines').click() + await page.keyboard.press('ControlOrMeta+KeyA') + await page.keyboard.type(`select * from pw_sql_editor_missing_table_e2e;`) + + const sqlMutationPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', { + method: 'POST', + }) + await page.getByTestId('sql-run-button').click() + await sqlMutationPromise + + const debugButton = page.getByRole('button', { name: 'Debug with Assistant' }) + await expect(debugButton, 'Debug button should appear once the query errors').toBeVisible() + await debugButton.click() + + await expect( + page.getByRole('button', { name: 'Close Assistant' }), + 'AI Assistant panel should open' + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Debug SQL snippet' }), + 'Assistant chat should be pre-named for the debug flow' + ).toBeVisible() + + await page.getByRole('button', { name: 'Close Assistant' }).click() + + // clear SQL snippet + if (!isCLI()) { + await deleteSqlSnippet(page, ref, newSqlSnippetName) + } else { + await page.reload() + } + }) + test('should not show warning modal for safe alter database statement', async ({ ref }) => { await expect(page.getByText('Loading...')).not.toBeVisible() await page.locator('.view-lines').click()