From 9f3af6e5025fddd1e1411caf0c1744242c852211 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Fri, 12 Dec 2025 14:44:11 +1000 Subject: [PATCH 1/9] Generate policies experiment (#40881) * policy generation * add ai * refactor * table create performance * policy list * style * simplify * refactor * flag * tracking * track * ts * fixes * connection string * re-add rls and realtime toggle * restore old logic * base path * badge * false rls * copy * add permissions button * Refactor based on comments * Fix TS * More nudge * Update tests * Fix test * Fixx * Fix * Address feedback * Address issues * Improve experiment telemetry for generate policies A/B test (#41172) * Address code rabbit catch --------- Co-authored-by: Joshen Lim Co-authored-by: Sean Oliver <882952+seanoliver@users.noreply.github.com> --- .../Auth/Policies/Policies.utils.test.ts | 451 ++++++++++++++++++ .../Auth/Policies/Policies.utils.ts | 291 ++++++++++- .../SidePanelEditor/SidePanelEditor.tsx | 109 +++-- .../SidePanelEditor.utils.createTable.test.ts | 11 +- .../SidePanelEditor/SidePanelEditor.utils.tsx | 43 +- .../TableEditor/RLSDisableModal.tsx | 2 +- .../TableEditor/RLSManagement/PolicyList.tsx | 105 ++++ .../RLSManagement/PolicyListEmptyState.tsx | 242 ++++++++++ .../RLSManagement/RLSManagement.tsx | 200 ++++++++ .../RLSManagement/ToggleRLSButton.tsx | 70 +++ .../TableEditor/TableEditor.tsx | 297 +++++++----- .../ui/AIAssistantPanel/AIOptInModal.tsx | 2 +- apps/studio/data/ai/sql-policy-mutation.ts | 76 +++ .../database-policy-create-mutation.ts | 2 +- .../database/foreign-key-constraints-query.ts | 12 +- .../misc/useTableCreateGeneratePolicies.ts | 76 +++ apps/studio/pages/api/ai/sql/policy.ts | 166 +++++++ apps/studio/proxy.ts | 1 + packages/common/telemetry-constants.ts | 107 +++++ 19 files changed, 2091 insertions(+), 172 deletions(-) create mode 100644 apps/studio/components/interfaces/Auth/Policies/Policies.utils.test.ts create mode 100644 apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSManagement/PolicyList.tsx create mode 100644 apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSManagement/PolicyListEmptyState.tsx create mode 100644 apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSManagement/RLSManagement.tsx create mode 100644 apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSManagement/ToggleRLSButton.tsx create mode 100644 apps/studio/data/ai/sql-policy-mutation.ts create mode 100644 apps/studio/hooks/misc/useTableCreateGeneratePolicies.ts create mode 100644 apps/studio/pages/api/ai/sql/policy.ts diff --git a/apps/studio/components/interfaces/Auth/Policies/Policies.utils.test.ts b/apps/studio/components/interfaces/Auth/Policies/Policies.utils.test.ts new file mode 100644 index 0000000000000..25cebe323bb27 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Policies/Policies.utils.test.ts @@ -0,0 +1,451 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { ForeignKeyConstraint } from 'data/database/foreign-key-constraints-query' + +// Mock generateSqlPolicy for AI tests +const mockGenerateSqlPolicy = vi.fn() +vi.mock('data/ai/sql-policy-mutation', () => ({ + generateSqlPolicy: (...args: unknown[]) => mockGenerateSqlPolicy(...args), +})) + +// Import after mocks are set up +import { + generateAiPoliciesForTable, + generateProgrammaticPoliciesForTable, + generateStartingPoliciesForTable, + type GeneratedPolicy, +} from './Policies.utils' + +// Helper to create a foreign key constraint +const createForeignKey = (overrides: Partial = {}): ForeignKeyConstraint => ({ + id: 1, + constraint_name: 'fk_constraint', + source_id: 100, + source_schema: 'public', + source_table: 'posts', + source_columns: ['user_id'], + target_id: 200, + target_schema: 'auth', + target_table: 'users', + target_columns: ['id'], + deletion_action: 'NO ACTION', + update_action: 'NO ACTION', + ...overrides, +}) + +describe('Policies.utils - Policy Generation', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('generateProgrammaticPoliciesForTable', () => { + it('should generate 4 CRUD policies for direct FK to auth.users', () => { + const foreignKeyConstraints: ForeignKeyConstraint[] = [ + createForeignKey({ + source_schema: 'public', + source_table: 'posts', + source_columns: ['user_id'], + target_schema: 'auth', + target_table: 'users', + target_columns: ['id'], + }), + ] + + const policies = generateProgrammaticPoliciesForTable({ + table: { name: 'posts', schema: 'public' }, + foreignKeyConstraints, + }) + + expect(policies).toHaveLength(4) + + const commands = policies.map((p) => p.command) + expect(commands).toContain('SELECT') + expect(commands).toContain('INSERT') + expect(commands).toContain('UPDATE') + expect(commands).toContain('DELETE') + }) + + it('should return empty array when no FK path to auth.users exists', () => { + const foreignKeyConstraints: ForeignKeyConstraint[] = [ + createForeignKey({ + source_schema: 'public', + source_table: 'posts', + source_columns: ['category_id'], + target_schema: 'public', + target_table: 'categories', + target_columns: ['id'], + }), + ] + + const policies = generateProgrammaticPoliciesForTable({ + table: { name: 'posts', schema: 'public' }, + foreignKeyConstraints, + }) + + expect(policies).toHaveLength(0) + }) + + it('should return empty array when foreignKeyConstraints is empty', () => { + const policies = generateProgrammaticPoliciesForTable({ + table: { name: 'posts', schema: 'public' }, + foreignKeyConstraints: [], + }) + + expect(policies).toHaveLength(0) + }) + + it('should generate policies with EXISTS clause for indirect FK path (2 hops)', () => { + // posts -> profiles -> auth.users + const foreignKeyConstraints: ForeignKeyConstraint[] = [ + createForeignKey({ + id: 1, + source_schema: 'public', + source_table: 'posts', + source_columns: ['profile_id'], + target_schema: 'public', + target_table: 'profiles', + target_columns: ['id'], + }), + createForeignKey({ + id: 2, + source_schema: 'public', + source_table: 'profiles', + source_columns: ['user_id'], + target_schema: 'auth', + target_table: 'users', + target_columns: ['id'], + }), + ] + + const policies = generateProgrammaticPoliciesForTable({ + table: { name: 'posts', schema: 'public' }, + foreignKeyConstraints, + }) + + expect(policies).toHaveLength(4) + + // Check that the expression contains EXISTS for indirect path + const selectPolicy = policies.find((p) => p.command === 'SELECT') + expect(selectPolicy?.definition).toContain('exists') + expect(selectPolicy?.sql).toContain('exists') + }) + + describe('policy structure validation', () => { + const foreignKeyConstraints: ForeignKeyConstraint[] = [ + createForeignKey({ + source_schema: 'public', + source_table: 'posts', + source_columns: ['user_id'], + target_schema: 'auth', + target_table: 'users', + target_columns: ['id'], + }), + ] + + it('should include all required fields in generated policies', () => { + const policies = generateProgrammaticPoliciesForTable({ + table: { name: 'posts', schema: 'public' }, + foreignKeyConstraints, + }) + + for (const policy of policies) { + expect(policy).toHaveProperty('name') + expect(policy).toHaveProperty('sql') + expect(policy).toHaveProperty('command') + expect(policy).toHaveProperty('table', 'posts') + expect(policy).toHaveProperty('schema', 'public') + expect(policy).toHaveProperty('action', 'PERMISSIVE') + expect(policy).toHaveProperty('roles') + expect(policy.roles).toContain('public') + } + }) + + it('SELECT policy should have definition but no check', () => { + const policies = generateProgrammaticPoliciesForTable({ + table: { name: 'posts', schema: 'public' }, + foreignKeyConstraints, + }) + + const selectPolicy = policies.find((p) => p.command === 'SELECT') + expect(selectPolicy?.definition).toBeDefined() + expect(selectPolicy?.check).toBeUndefined() + }) + + it('DELETE policy should have definition but no check', () => { + const policies = generateProgrammaticPoliciesForTable({ + table: { name: 'posts', schema: 'public' }, + foreignKeyConstraints, + }) + + const deletePolicy = policies.find((p) => p.command === 'DELETE') + expect(deletePolicy?.definition).toBeDefined() + expect(deletePolicy?.check).toBeUndefined() + }) + + it('INSERT policy should have check but no definition', () => { + const policies = generateProgrammaticPoliciesForTable({ + table: { name: 'posts', schema: 'public' }, + foreignKeyConstraints, + }) + + const insertPolicy = policies.find((p) => p.command === 'INSERT') + expect(insertPolicy?.definition).toBeUndefined() + expect(insertPolicy?.check).toBeDefined() + }) + + it('UPDATE policy should have both definition and check', () => { + const policies = generateProgrammaticPoliciesForTable({ + table: { name: 'posts', schema: 'public' }, + foreignKeyConstraints, + }) + + const updatePolicy = policies.find((p) => p.command === 'UPDATE') + expect(updatePolicy?.definition).toBeDefined() + expect(updatePolicy?.check).toBeDefined() + }) + + it('should generate correct SQL syntax for direct FK', () => { + const policies = generateProgrammaticPoliciesForTable({ + table: { name: 'posts', schema: 'public' }, + foreignKeyConstraints, + }) + + const selectPolicy = policies.find((p) => p.command === 'SELECT') + expect(selectPolicy?.sql).toContain('CREATE POLICY') + expect(selectPolicy?.sql).toContain('public.posts') + expect(selectPolicy?.sql).toContain('AS PERMISSIVE FOR SELECT') + expect(selectPolicy?.sql).toContain('TO public') + expect(selectPolicy?.sql).toContain('USING') + expect(selectPolicy?.sql).toContain('auth.uid()') + }) + }) + + it('should handle non-public schema', () => { + const foreignKeyConstraints: ForeignKeyConstraint[] = [ + createForeignKey({ + source_schema: 'private', + source_table: 'documents', + source_columns: ['owner_id'], + target_schema: 'auth', + target_table: 'users', + target_columns: ['id'], + }), + ] + + const policies = generateProgrammaticPoliciesForTable({ + table: { name: 'documents', schema: 'private' }, + foreignKeyConstraints, + }) + + expect(policies).toHaveLength(4) + expect(policies[0].schema).toBe('private') + expect(policies[0].sql).toContain('private.documents') + }) + }) + + describe('generateAiPoliciesForTable', () => { + const mockAiPolicies: GeneratedPolicy[] = [ + { + name: 'ai_select_policy', + sql: 'CREATE POLICY "ai_select_policy" ON public.posts FOR SELECT USING (true);', + command: 'SELECT', + table: 'posts', + schema: 'public', + definition: 'true', + action: 'PERMISSIVE', + roles: ['public'], + }, + ] + + it('should return policies from AI when called with valid inputs', async () => { + mockGenerateSqlPolicy.mockResolvedValue(mockAiPolicies) + + const policies = await generateAiPoliciesForTable({ + table: { name: 'posts', schema: 'public' }, + columns: [{ name: 'id' }, { name: 'title' }], + projectRef: 'test-project', + connectionString: 'postgresql://localhost:5432/test', + }) + + expect(mockGenerateSqlPolicy).toHaveBeenCalledWith({ + tableName: 'posts', + schema: 'public', + columns: ['id', 'title'], + projectRef: 'test-project', + connectionString: 'postgresql://localhost:5432/test', + }) + expect(policies).toEqual(mockAiPolicies) + }) + + it('should return empty array when connectionString is null', async () => { + const policies = await generateAiPoliciesForTable({ + table: { name: 'posts', schema: 'public' }, + columns: [{ name: 'id' }], + projectRef: 'test-project', + connectionString: null, + }) + + expect(mockGenerateSqlPolicy).not.toHaveBeenCalled() + expect(policies).toEqual([]) + }) + + it('should return empty array when connectionString is undefined', async () => { + const policies = await generateAiPoliciesForTable({ + table: { name: 'posts', schema: 'public' }, + columns: [{ name: 'id' }], + projectRef: 'test-project', + connectionString: undefined, + }) + + expect(mockGenerateSqlPolicy).not.toHaveBeenCalled() + expect(policies).toEqual([]) + }) + + it('should handle API errors gracefully and return empty array', async () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + mockGenerateSqlPolicy.mockRejectedValue(new Error('API error')) + + const policies = await generateAiPoliciesForTable({ + table: { name: 'posts', schema: 'public' }, + columns: [{ name: 'id' }], + projectRef: 'test-project', + connectionString: 'postgresql://localhost:5432/test', + }) + + expect(policies).toEqual([]) + expect(consoleLogSpy).toHaveBeenCalledWith('AI policy generation failed:', expect.any(Error)) + + consoleLogSpy.mockRestore() + }) + + it('should trim column names before sending to API', async () => { + mockGenerateSqlPolicy.mockResolvedValue([]) + + await generateAiPoliciesForTable({ + table: { name: 'posts', schema: 'public' }, + columns: [{ name: ' id ' }, { name: ' title ' }], + projectRef: 'test-project', + connectionString: 'postgresql://localhost:5432/test', + }) + + expect(mockGenerateSqlPolicy).toHaveBeenCalledWith( + expect.objectContaining({ + columns: ['id', 'title'], + }) + ) + }) + }) + + describe('generateStartingPoliciesForTable', () => { + const mockAiPolicies: GeneratedPolicy[] = [ + { + name: 'ai_policy', + sql: 'CREATE POLICY "ai_policy" ON public.posts FOR SELECT USING (true);', + command: 'SELECT', + table: 'posts', + schema: 'public', + definition: 'true', + action: 'PERMISSIVE', + roles: ['public'], + }, + ] + + it('should use programmatic policies when FK path exists (does not call AI)', async () => { + const foreignKeyConstraints: ForeignKeyConstraint[] = [ + createForeignKey({ + source_schema: 'public', + source_table: 'posts', + source_columns: ['user_id'], + target_schema: 'auth', + target_table: 'users', + target_columns: ['id'], + }), + ] + + const policies = await generateStartingPoliciesForTable({ + table: { name: 'posts', schema: 'public' }, + foreignKeyConstraints, + columns: [{ name: 'id' }], + projectRef: 'test-project', + connectionString: 'postgresql://localhost:5432/test', + enableAi: true, + }) + + expect(policies).toHaveLength(4) + expect(mockGenerateSqlPolicy).not.toHaveBeenCalled() + }) + + it('should fall back to AI when no FK path exists and enableAi is true', async () => { + mockGenerateSqlPolicy.mockResolvedValue(mockAiPolicies) + + const policies = await generateStartingPoliciesForTable({ + table: { name: 'posts', schema: 'public' }, + foreignKeyConstraints: [], + columns: [{ name: 'id' }], + projectRef: 'test-project', + connectionString: 'postgresql://localhost:5432/test', + enableAi: true, + }) + + expect(mockGenerateSqlPolicy).toHaveBeenCalled() + expect(policies).toEqual(mockAiPolicies) + }) + + it('should return empty array when no FK path exists and enableAi is false', async () => { + const policies = await generateStartingPoliciesForTable({ + table: { name: 'posts', schema: 'public' }, + foreignKeyConstraints: [], + columns: [{ name: 'id' }], + projectRef: 'test-project', + connectionString: 'postgresql://localhost:5432/test', + enableAi: false, + }) + + expect(mockGenerateSqlPolicy).not.toHaveBeenCalled() + expect(policies).toEqual([]) + }) + + it('should return empty array when no FK path and AI returns empty', async () => { + mockGenerateSqlPolicy.mockResolvedValue([]) + + const policies = await generateStartingPoliciesForTable({ + table: { name: 'posts', schema: 'public' }, + foreignKeyConstraints: [], + columns: [{ name: 'id' }], + projectRef: 'test-project', + connectionString: 'postgresql://localhost:5432/test', + enableAi: true, + }) + + expect(policies).toEqual([]) + }) + + it('should prioritize programmatic over AI even when both could generate policies', async () => { + mockGenerateSqlPolicy.mockResolvedValue(mockAiPolicies) + + const foreignKeyConstraints: ForeignKeyConstraint[] = [ + createForeignKey({ + source_schema: 'public', + source_table: 'posts', + source_columns: ['user_id'], + target_schema: 'auth', + target_table: 'users', + target_columns: ['id'], + }), + ] + + const policies = await generateStartingPoliciesForTable({ + table: { name: 'posts', schema: 'public' }, + foreignKeyConstraints, + columns: [{ name: 'id' }], + projectRef: 'test-project', + connectionString: 'postgresql://localhost:5432/test', + enableAi: true, + }) + + // Should return 4 programmatic policies, not 1 AI policy + expect(policies).toHaveLength(4) + expect(mockGenerateSqlPolicy).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/studio/components/interfaces/Auth/Policies/Policies.utils.ts b/apps/studio/components/interfaces/Auth/Policies/Policies.utils.ts index ebfcc71f43e18..128668a8d7109 100644 --- a/apps/studio/components/interfaces/Auth/Policies/Policies.utils.ts +++ b/apps/studio/components/interfaces/Auth/Policies/Policies.utils.ts @@ -1,5 +1,10 @@ import type { PostgresPolicy } from '@supabase/postgres-meta' import { has, isEmpty, isEqual } from 'lodash' + +import { ident } from '@supabase/pg-meta/src/pg-format' +import { generateSqlPolicy } from 'data/ai/sql-policy-mutation' +import type { CreatePolicyBody } from 'data/database-policies/database-policy-create-mutation' +import type { ForeignKeyConstraint } from 'data/database/foreign-key-constraints-query' import { PolicyFormField, PolicyForReview, @@ -73,7 +78,7 @@ const createSQLStatementForCreatePolicy = (policyFormFields: PolicyFormField): P return { description, statement } } -export const createSQLStatementForUpdatePolicy = ( +const createSQLStatementForUpdatePolicy = ( policyFormFields: PolicyFormField, fieldsToUpdate: Partial ): PolicyForReview => { @@ -151,3 +156,287 @@ export const createPayloadForUpdatePolicy = ( return payload } + +// --- Policy Generation --- + +/** + * Generated policy extends CreatePolicyBody with additional fields for display. + * - sql: Full CREATE POLICY SQL statement for preview + * - Required fields that are optional in CreatePolicyBody + */ +export type GeneratedPolicy = Required< + Pick +> & + Pick & { + sql: string + } + +type Relationship = { + source_schema: string + source_table_name: string + source_column_name: string + target_table_schema: string + target_table_name: string + target_column_name: string +} + +/** + * Gets relationships for a specific table from FK constraints. + * Returns relationships where the table is the source. + */ +const getRelationshipsForTable = ({ + schema, + table, + fkConstraints, +}: { + schema: string + table: string + fkConstraints: ForeignKeyConstraint[] +}): Relationship[] => { + return fkConstraints + .filter((fk) => fk.source_schema === schema && fk.source_table === table) + .flatMap((fk) => + fk.source_columns.map((sourceCol, i) => ({ + source_schema: fk.source_schema, + source_table_name: fk.source_table, + source_column_name: sourceCol, + target_table_schema: fk.target_schema, + target_table_name: fk.target_table, + target_column_name: fk.target_columns[i], + })) + ) +} + +/** + * BFS to find shortest path from table to auth.users via foreign key relationships. + * Returns null if no path exists within maxDepth. + */ +const findPathToAuthUsers = ( + startTable: { schema: string; name: string }, + allForeignKeyConstraints: ForeignKeyConstraint[], + maxDepth = 3 +): Relationship[] | null => { + const startRelationships = getRelationshipsForTable({ + schema: startTable.schema, + table: startTable.name, + fkConstraints: allForeignKeyConstraints, + }) + + const queue: { table: { schema: string; name: string }; path: Relationship[] }[] = [ + { table: startTable, path: [] }, + ] + const visited = new Set() + visited.add(`${startTable.schema}.${startTable.name}`) + + while (queue.length > 0) { + const queueItem = queue.shift() + if (!queueItem) continue + + const { table, path } = queueItem + if (path.length >= maxDepth) continue + + const relationships = + path.length === 0 + ? startRelationships + : getRelationshipsForTable({ + schema: table.schema, + table: table.name, + fkConstraints: allForeignKeyConstraints, + }) + + for (const rel of relationships) { + // Found path to auth.users + if ( + rel.target_table_schema === 'auth' && + rel.target_table_name === 'users' && + rel.target_column_name === 'id' + ) { + return [...path, rel] + } + + const targetId = `${rel.target_table_schema}.${rel.target_table_name}` + if (visited.has(targetId)) continue + + // Add target table to queue for further exploration + queue.push({ + table: { schema: rel.target_table_schema, name: rel.target_table_name }, + path: [...path, rel], + }) + visited.add(targetId) + } + } + + return null +} + +/** Generates SQL expression for RLS policy based on FK path to auth.users */ +const buildPolicyExpression = (path: Relationship[]): string => { + if (path.length === 0) return '' + + // Direct FK to auth.users + if (path.length === 1) { + return `(select auth.uid()) = ${ident(path[0].source_column_name)}` + } + + // Indirect path - build EXISTS with JOINs + const [first, ...rest] = path + const firstTarget = `${ident(first.target_table_schema)}.${ident(first.target_table_name)}` + const source = `${ident(first.source_schema)}.${ident(first.source_table_name)}` + const last = path[path.length - 1] + + const joins = rest + .slice(0, -1) + .map((r) => { + const targetSchema = ident(r.target_table_schema) + const targetTable = ident(r.target_table_name) + const targetColumn = ident(r.target_column_name) + + const sourceSchema = ident(r.source_schema) + const sourceTable = ident(r.source_table_name) + const sourceColumn = ident(r.source_column_name) + return `join ${targetSchema}.${targetTable} on ${targetSchema}.${targetTable}.${targetColumn} = ${sourceSchema}.${sourceTable}.${sourceColumn}` + }) + .join('\n ') + + return `exists ( + select 1 from ${firstTarget} + ${joins} + where ${firstTarget}.${ident(first.target_column_name)} = ${source}.${ident(first.source_column_name)} + and ${ident(last.source_schema)}.${ident(last.source_table_name)}.${ident(last.source_column_name)} = (select auth.uid()) +)` +} + +/** Builds policy SQL for all CRUD operations */ +const buildPoliciesForPath = ( + table: { name: string; schema: string }, + path: Relationship[] +): GeneratedPolicy[] => { + const expression = buildPolicyExpression(path) + const targetCol = path[0].source_column_name + + return (['SELECT', 'INSERT', 'UPDATE', 'DELETE'] as const).map((command) => { + const name = `Enable ${command.toLowerCase()} access for users based on ${ident(targetCol)}` + const base = `CREATE POLICY "${name}" ON ${ident(table.schema)}.${ident(table.name)} AS PERMISSIVE FOR ${command} TO public` + + const sql = + command === 'INSERT' + ? `${base} WITH CHECK (${expression});` + : command === 'UPDATE' + ? `${base} USING (${expression}) WITH CHECK (${expression});` + : `${base} USING (${expression});` + + // Structured data for mutation API + const definition = command === 'INSERT' ? undefined : expression + const check = command === 'SELECT' || command === 'DELETE' ? undefined : expression + + return { + name, + sql, + command, + table: table.name, + schema: table.schema, + definition, + check, + action: 'PERMISSIVE' as const, + roles: ['public'], + } + }) +} + +/** + * Generates RLS policies programmatically based on FK relationships to auth.users. + */ +export const generateProgrammaticPoliciesForTable = ({ + table, + foreignKeyConstraints, +}: { + table: { name: string; schema: string } + foreignKeyConstraints: ForeignKeyConstraint[] +}): GeneratedPolicy[] => { + try { + const path = findPathToAuthUsers(table, foreignKeyConstraints) + + if (path?.length) { + return buildPoliciesForPath(table, path) + } + } catch (error) { + // Silently fail - caller will handle empty result + } + + return [] +} + +/** + * Generates RLS policies using AI. + */ +export const generateAiPoliciesForTable = async ({ + table, + columns, + projectRef, + connectionString, +}: { + table: { name: string; schema: string } + columns: { name: string }[] + projectRef: string + connectionString?: string | null +}): Promise => { + if (!connectionString) return [] + + try { + const aiPolicies = await generateSqlPolicy({ + tableName: table.name, + schema: table.schema, + columns: columns.map((col) => col.name.trim()), + projectRef, + connectionString: connectionString ?? '', + }) + // AI response now includes all structured fields + return aiPolicies as GeneratedPolicy[] + } catch (error) { + console.log('AI policy generation failed:', error) + return [] + } +} + +/** + * Generates RLS policies for a table. + * First tries programmatic generation based on FK relationships to auth.users. + * Falls back to AI generation if no path exists. + */ +export const generateStartingPoliciesForTable = async ({ + table, + foreignKeyConstraints, + columns, + projectRef, + connectionString, + enableAi, +}: { + table: { name: string; schema: string } + foreignKeyConstraints: ForeignKeyConstraint[] + columns: { name: string }[] + projectRef: string + connectionString?: string | null + enableAi: boolean +}): Promise => { + // Try programmatic generation first + const programmaticPolicies = generateProgrammaticPoliciesForTable({ + table, + foreignKeyConstraints, + }) + + if (programmaticPolicies.length > 0) { + return programmaticPolicies + } + + // Fall back to AI generation + if (enableAi) { + return await generateAiPoliciesForTable({ + table, + columns, + projectRef, + connectionString, + }) + } + + return [] +} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index 12d2f525bc24c..80a1ba852d5b4 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -5,6 +5,9 @@ import { useState } from 'react' import { toast } from 'sonner' import { useParams } from 'common' +import { type GeneratedPolicy } from 'components/interfaces/Auth/Policies/Policies.utils' +import { useDatabasePolicyCreateMutation } from 'data/database-policies/database-policy-create-mutation' +import { databasePoliciesKeys } from 'data/database-policies/keys' import { useDatabasePublicationCreateMutation } from 'data/database-publications/database-publications-create-mutation' import { useDatabasePublicationsQuery } from 'data/database-publications/database-publications-query' import { useDatabasePublicationUpdateMutation } from 'data/database-publications/database-publications-update-mutation' @@ -24,6 +27,7 @@ import { getTables } from 'data/tables/tables-query' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' +import { usePHFlag } from 'hooks/ui/useFlag' import { useUrlState } from 'hooks/ui/useUrlState' import { useTrack } from 'lib/telemetry/track' import { useGetImpersonatedRoleState } from 'state/role-impersonation-state' @@ -64,6 +68,7 @@ type SaveTableParamsBase = { columns: ColumnField[] foreignKeyRelations: ForeignKey[] resolve: () => void + generatedPolicies?: GeneratedPolicy[] } type SaveTableParamsNew = SaveTableParamsBase & { @@ -130,13 +135,20 @@ export const SidePanelEditor = ({ const tabsSnap = useTabsStateSnapshot() const [_, setParams] = useUrlState({ arrayKeys: ['filter', 'sort'] }) + const track = useTrack() const queryClient = useQueryClient() const { data: project } = useSelectedProjectQuery() const { data: org } = useSelectedOrganizationQuery() - const track = useTrack() + const getImpersonatedRoleState = useGetImpersonatedRoleState() + const generatePoliciesFlag = usePHFlag('tableCreateGeneratePolicies') const [isEdited, setIsEdited] = useState(false) + const { data: publications } = useDatabasePublicationsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ checkIsDirty: () => isEdited, onClose: () => { @@ -161,16 +173,13 @@ export const SidePanelEditor = ({ toast.success('Successfully updated row') }, }) - const { data: publications } = useDatabasePublicationsQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) const { mutateAsync: createPublication } = useDatabasePublicationCreateMutation() const { mutateAsync: updatePublication } = useDatabasePublicationUpdateMutation({ onError: () => {}, }) - - const getImpersonatedRoleState = useGetImpersonatedRoleState() + const { mutateAsync: createPolicy } = useDatabasePolicyCreateMutation({ + onError: () => {}, // Errors handled inline + }) const isDuplicating = snap.sidePanel?.type === 'table' && snap.sidePanel.mode === 'duplicate' @@ -472,13 +481,18 @@ export const SidePanelEditor = ({ } } - const saveTable = async (params: SaveTableParams) => { - // action and payload are not destructured here to preserve type - // narrowing later on - const { configuration, columns, foreignKeyRelations, resolve } = params - + const saveTable = async ({ + action, + payload, + configuration, + columns, + foreignKeyRelations, + generatedPolicies = [], + resolve, + }: SaveTableParams) => { let toastId let saveTableError = false + const { importContent, isRLSEnabled, @@ -489,46 +503,75 @@ export const SidePanelEditor = ({ } = configuration try { - if (params.action === 'create') { - toastId = toast.loading(`Creating new table: ${params.payload.name}...`) + if (action === 'create') { + toastId = toast.loading(`Creating new table: ${payload.name}...`) - const table = await createTable({ + const { table, failedPolicies } = await createTable({ projectRef: project?.ref!, connectionString: project?.connectionString, toastId, - payload: params.payload, + payload, columns, foreignKeyRelations, isRLSEnabled, importContent, organizationSlug: org?.slug, + generatedPolicies, + onCreatePoliciesSuccess: () => track('rls_generated_policies_created'), }) + if (isRealtimeEnabled) await updateTableRealtime(table, true) + // Invalidate queries for table creation await Promise.all([ queryClient.invalidateQueries({ queryKey: tableKeys.list(project?.ref, table.schema, includeColumns), }), queryClient.invalidateQueries({ queryKey: entityTypeKeys.list(project?.ref) }), + queryClient.invalidateQueries({ queryKey: databasePoliciesKeys.list(project?.ref) }), ]) - toast.success(`Table ${table.name} is good to go!`, { id: toastId }) + // Show success toast after everything is complete + if (failedPolicies.length > 0) { + toast.success( + `Table ${table.name} is created successfully, but we ran into issues creating ${failedPolicies.length} policie${failedPolicies.length > 1 ? 's' : ''}`, + { + id: toastId, + description: ( +
    + {failedPolicies.map((x) => ( +
  • {x.name}
  • + ))} +
+ ), + } + ) + } else { + toast.success(`Table ${table.name} is good to go!`, { id: toastId }) + } + + // Track experiment conversion if user is in the experiment + if (generatePoliciesFlag !== undefined) { + track('table_create_generate_policies_experiment_converted', { + experiment_id: 'tableCreateGeneratePolicies', + variant: generatePoliciesFlag ? 'treatment' : 'control', + has_rls_enabled: isRLSEnabled, + has_rls_policies: generatedPolicies.length > 0, + has_generated_policies: generatedPolicies.length > 0, + }) + } + onTableCreated(table) - } else if (params.action === 'duplicate' && !!selectedTable) { + } else if (action === 'duplicate' && !!selectedTable) { const tableToDuplicate = selectedTable toastId = toast.loading(`Duplicating table: ${tableToDuplicate.name}...`) - const table = await duplicateTable( - project?.ref!, - project?.connectionString, - params.payload, - { - isRLSEnabled, - isDuplicateRows, - duplicateTable: tableToDuplicate, - foreignKeyRelations, - } - ) + const table = await duplicateTable(project?.ref!, project?.connectionString, payload, { + isRLSEnabled, + isDuplicateRows, + duplicateTable: tableToDuplicate, + foreignKeyRelations, + }) if (isRealtimeEnabled) await updateTableRealtime(table, isRealtimeEnabled) await Promise.all([ @@ -543,7 +586,7 @@ export const SidePanelEditor = ({ { id: toastId } ) onTableCreated(table) - } else if (params.action === 'update' && selectedTable) { + } else if (action === 'update' && selectedTable) { toastId = toast.loading(`Updating table: ${selectedTable.name}...`) const { table, hasError } = await updateTable({ @@ -551,7 +594,7 @@ export const SidePanelEditor = ({ connectionString: project?.connectionString, toastId, table: selectedTable, - payload: params.payload, + payload, columns, foreignKeyRelations, existingForeignKeyRelations, @@ -571,10 +614,10 @@ export const SidePanelEditor = ({ `Table ${table.name} has been updated but there were some errors. Please check these errors separately.` ) } else { - if (ref && params.payload.name) { + if (ref && payload.name) { // [Joshen] Only table entities can be updated via the dashboard const tabId = createTabId(ENTITY_TYPE.TABLE, { id: selectedTable.id }) - tabsSnap.updateTab(tabId, { label: params.payload.name }) + tabsSnap.updateTab(tabId, { label: payload.name }) } toast.success(`Successfully updated ${table.name}!`, { id: toastId }) } diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.createTable.test.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.createTable.test.ts index 9041b931c925c..1c09396321bb7 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.createTable.test.ts +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.createTable.test.ts @@ -141,6 +141,7 @@ describe('createTable', () => { event: { action: 'table_created', properties: { + has_generated_policies: false, method: 'table_editor', schema_name: 'public', table_name: 'test_table', @@ -160,7 +161,10 @@ describe('createTable', () => { }) ) - expect(result).toStrictEqual(mockTableResult) + expect(result).toStrictEqual({ + failedPolicies: [], + table: mockTableResult, + }) }) it('should create a table with RLS enabled', async () => { @@ -355,7 +359,10 @@ describe('createTable', () => { isRLSEnabled: false, }) - expect(result).toStrictEqual(mockTableResult) + expect(result).toStrictEqual({ + failedPolicies: [], + table: mockTableResult, + }) expect(consoleErrorSpy).toHaveBeenCalledWith( 'Failed to track table creation event:', expect.any(Error) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx index 675c64b1223a3..54a4831deaf9b 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx @@ -5,10 +5,12 @@ import Papa from 'papaparse' import { toast } from 'sonner' import { Query } from '@supabase/pg-meta/src/query' +import { GeneratedPolicy } from 'components/interfaces/Auth/Policies/Policies.utils' import SparkBar from 'components/ui/SparkBar' import { createDatabaseColumn } from 'data/database-columns/database-column-create-mutation' import { deleteDatabaseColumn } from 'data/database-columns/database-column-delete-mutation' import { updateDatabaseColumn } from 'data/database-columns/database-column-update-mutation' +import { createDatabasePolicy } from 'data/database-policies/database-policy-create-mutation' import type { Constraint } from 'data/database/constraints-query' import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants' import { ForeignKeyConstraint } from 'data/database/foreign-key-constraints-query' @@ -496,6 +498,8 @@ export const createTable = async ({ isRLSEnabled, importContent, organizationSlug, + generatedPolicies = [], + onCreatePoliciesSuccess, }: { projectRef: string connectionString?: string | null @@ -510,6 +514,8 @@ export const createTable = async ({ isRLSEnabled: boolean importContent?: ImportContent organizationSlug?: string + generatedPolicies?: GeneratedPolicy[] + onCreatePoliciesSuccess?: () => void }) => { const queryClient = getQueryClient() @@ -583,6 +589,39 @@ export const createTable = async ({ queryKey: ['table', 'create-with-columns'], }) + // 6. Create generated RLS policies if any + // [Joshen] Possible area for optimization to create all policies in a single query call + // Can be subsequently added to the table creation SQL as well for a single transaction + + const failedPolicies: GeneratedPolicy[] = [] + if (generatedPolicies.length > 0 && isRLSEnabled) { + toast.loading(`Creating ${generatedPolicies.length} policies for table...`, { id: toastId }) + await Promise.all( + generatedPolicies.map(async (policy) => { + try { + return await createDatabasePolicy({ + projectRef, + connectionString, + payload: { + name: policy.name, + table: policy.table, + schema: policy.schema, + definition: policy.definition, + check: policy.check, + action: policy.action, + command: policy.command, + roles: policy.roles, + }, + }) + } catch (error: any) { + console.error('Failed to generate policy', error.message) + failedPolicies.push(policy) + } + }) + ) + onCreatePoliciesSuccess?.() + } + // Track table creation event (fire-and-forget to avoid blocking) sendEvent({ event: { @@ -591,6 +630,7 @@ export const createTable = async ({ method: 'table_editor', schema_name: payload.schema, table_name: payload.name, + has_generated_policies: generatedPolicies.length > 0 && isRLSEnabled, }, groups: { project: projectRef, @@ -681,7 +721,6 @@ export const createTable = async ({ type="horizontal" barClass="bg-brand" labelBottom={`Adding ${importContent.rows.length.toLocaleString()} rows to ${table.name}`} - labelBottomClass="" labelTop={`${progress.toFixed(2)}%`} labelTopClass="tabular-nums" /> @@ -721,7 +760,7 @@ export const createTable = async ({ }) // Finally, return the created table - return table + return { table, failedPolicies } } /** TODO: Refactor to do in a single transaction */ diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSDisableModal.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSDisableModal.tsx index b9f18ae1d186c..abc9c488d5926 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSDisableModal.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSDisableModal.tsx @@ -5,7 +5,7 @@ import { useCustomContent } from 'hooks/custom-content/useCustomContent' import { DOCS_URL } from 'lib/constants' import { Alert } from 'ui' -export default function RLSDisableModalContent() { +export function RLSDisableModalContent() { const { docsRowLevelSecurityGuidePath } = useCustomContent(['docs:row_level_security_guide_path']) return ( diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSManagement/PolicyList.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSManagement/PolicyList.tsx new file mode 100644 index 0000000000000..f40562843e881 --- /dev/null +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSManagement/PolicyList.tsx @@ -0,0 +1,105 @@ +import { X } from 'lucide-react' + +import { + Badge, + Card, + cn, + HoverCard_Shadcn_, + HoverCardContent_Shadcn_, + HoverCardTrigger_Shadcn_, + SimpleCodeBlock, +} from 'ui' + +export interface PolicyListItemData { + name: string + command?: string | null + sql?: string | null + isNew?: boolean +} + +interface PolicyListItemProps { + policy: PolicyListItemData + onRemove?: () => void +} + +/** + * A shared component for displaying policy items with hover preview + * Used in RLSManagement and policy creation toast notifications + */ +export const PolicyListItem = ({ policy, onRemove }: PolicyListItemProps) => { + return ( + + +
+ {policy.name} + +
+ {policy.command && {policy.command}} + {policy.isNew && New} + {!!onRemove && policy.isNew && ( + + )} +
+
+
+ + {policy.sql ? ( + + {policy.sql} + + ) : ( +

No definition available.

+ )} +
+
+ ) +} + +interface PolicyListProps { + disabled?: boolean + policies: PolicyListItemData[] + className?: string + onRemove?: (index: number) => void +} + +/** + * A list of policies with hover previews + * Used in RLSManagement and toast notifications + */ +export const PolicyList = ({ + disabled = false, + policies, + className, + onRemove, +}: PolicyListProps) => { + return ( + +
+ {policies.map((policy, idx) => ( + onRemove(idx) : undefined} + /> + ))} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSManagement/PolicyListEmptyState.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSManagement/PolicyListEmptyState.tsx new file mode 100644 index 0000000000000..4477cc88d23bd --- /dev/null +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSManagement/PolicyListEmptyState.tsx @@ -0,0 +1,242 @@ +import { useMemo, useState } from 'react' +import { toast } from 'sonner' + +import { + type GeneratedPolicy, + generateStartingPoliciesForTable, +} from 'components/interfaces/Auth/Policies/Policies.utils' +import { AIOptInModal } from 'components/ui/AIAssistantPanel/AIOptInModal' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { + ForeignKeyConstraint, + useForeignKeyConstraintsQuery, +} from 'data/database/foreign-key-constraints-query' +import { useOrgAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { useTrack } from 'lib/telemetry/track' +import { Button, Card, CardContent, cn } from 'ui' +import type { ForeignKey } from '../../ForeignKeySelector/ForeignKeySelector.types' +import { ColumnField } from '../../SidePanelEditor.types' + +interface PolicyListEmptyStateProps { + schema: string + tableName?: string + columns: ColumnField[] + foreignKeyRelations: ForeignKey[] + isNewRecord: boolean + isRLSEnabled: boolean + isDuplicating: boolean + onGeneratedPoliciesChange?: (policies: GeneratedPolicy[]) => void +} + +export const PolicyListEmptyState = ({ + schema, + tableName, + columns, + foreignKeyRelations, + isNewRecord, + isRLSEnabled, + isDuplicating, + onGeneratedPoliciesChange, +}: PolicyListEmptyStateProps) => { + const track = useTrack() + const { data: project } = useSelectedProjectQuery() + const { includeSchemaMetadata } = useOrgAiOptInLevel() + + const [isGenerating, setIsGenerating] = useState(false) + const [generateFailed, setGenerateFailed] = useState(false) + const [isOptInModalOpen, setIsOptInModalOpen] = useState(false) + + // Fetch foreign key constraints for policy generation BFS traversal + const { data: schemaForeignKeys } = useForeignKeyConstraintsQuery( + { + projectRef: project?.ref, + connectionString: project?.connectionString, + schema: schema, + }, + { + enabled: isNewRecord && !isDuplicating && !!schema, + } + ) + + const emptyStateMessage = useMemo(() => { + if (!generateFailed) { + return { + title: 'Generate starting policies', + description: ( + <> + Starter policies are generated from your table relationships to{' '} + auth.users or, if none exist, using AI. + + ), + } + } + + if (!includeSchemaMetadata) { + return { + title: 'Unable to generate policies', + description: ( + <> +

We couldn't detect any relationships to auth.users to suggest policies.

+

Enable schema metadata sharing to use our AI-assisted policy generator.

+ + ), + } + } + + return { + title: 'We could not generate policies for this table', + description: + "Automatic policy generation wasn't possible for this table. Update the schema and try again, or add policies manually after creating the table.", + } + }, [generateFailed, includeSchemaMetadata]) + + const { title, description } = emptyStateMessage + const showPermissionButton = generateFailed && !includeSchemaMetadata + const allowPolicyGeneration = isNewRecord && !isDuplicating + + const convertForeignKeysToConstraints = (fks: ForeignKey[]): ForeignKeyConstraint[] => { + if (!tableName || !schema) { + return [] + } + + return fks + .filter((fk) => fk.columns && fk.columns.length > 0) // Only include FKs with columns + .map((fk) => ({ + id: typeof fk.id === 'number' ? fk.id : 0, + constraint_name: fk.name || '', + source_id: typeof fk.tableId === 'number' ? fk.tableId : 0, + source_schema: schema.trim(), + source_table: tableName.trim(), + source_columns: fk.columns.map((col: { source: string; target: string }) => + col.source.trim() + ), + target_id: 0, + target_schema: fk.schema.trim(), + target_table: fk.table.trim(), + target_columns: fk.columns.map((col: { source: string; target: string }) => + col.target.trim() + ), + deletion_action: fk.deletionAction || 'NO ACTION', + update_action: fk.updateAction || 'NO ACTION', + })) + } + + const handleGeneratePolicies = async () => { + if (!project?.ref || !tableName || columns.length === 0) { + return toast.error( + 'Unable to generate policies. Please ensure table name and columns are set.' + ) + } + + track('rls_generate_policies_clicked') + + setIsGenerating(true) + try { + const trimmedTableName = tableName.trim() + const trimmedSchema = schema.trim() + + const newTableForeignKeys = convertForeignKeysToConstraints(foreignKeyRelations) + + const allForeignKeys = [ + ...newTableForeignKeys, + ...(schemaForeignKeys ?? []).filter( + (existingFk) => + !( + existingFk.source_schema === trimmedSchema && + existingFk.source_table === trimmedTableName + ) + ), + ] + + const tableColumns = columns.map((col) => ({ name: col.name.trim() })) + + const policies = await generateStartingPoliciesForTable({ + table: { name: trimmedTableName, schema: trimmedSchema }, + foreignKeyConstraints: allForeignKeys, + columns: tableColumns, + projectRef: project.ref, + connectionString: project.connectionString, + enableAi: includeSchemaMetadata, + }) + + if (policies.length === 0) { + setGenerateFailed(true) + } else { + setGenerateFailed(false) + onGeneratedPoliciesChange?.(policies) + } + } catch (error: any) { + console.error('Failed to generate policies:', error) + toast.error(error.message || 'Failed to generate policies') + } finally { + setIsGenerating(false) + } + } + + if (allowPolicyGeneration) { + return ( + <> + + +
+
+

{title}

+

{description}

+
+
+ + {isGenerating + ? 'Generating policies...' + : generateFailed + ? 'Try generating again' + : 'Generate policies'} + + {showPermissionButton && ( + + )} +
+
+
+
+ setIsOptInModalOpen(false)} /> + + ) + } + + return ( + + +

No policies exist for this table

+
+
+ ) +} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSManagement/RLSManagement.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSManagement/RLSManagement.tsx new file mode 100644 index 0000000000000..278d455c5d338 --- /dev/null +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSManagement/RLSManagement.tsx @@ -0,0 +1,200 @@ +import type { PostgresTable } from '@supabase/postgres-meta' +import { ExternalLink } from 'lucide-react' +import Link from 'next/link' +import { useMemo } from 'react' + +import { type GeneratedPolicy } from 'components/interfaces/Auth/Policies/Policies.utils' +import { generatePolicyUpdateSQL } from 'components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils' +import { DocsButton } from 'components/ui/DocsButton' +import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query' +import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { DOCS_URL } from 'lib/constants' +import { useTrack } from 'lib/telemetry/track' +import { Button, cn } from 'ui' +import { Admonition } from 'ui-patterns/admonition' +import type { ForeignKey } from '../../ForeignKeySelector/ForeignKeySelector.types' +import { TableField } from '../TableEditor.types' +import { PolicyList, type PolicyListItemData } from './PolicyList' +import { PolicyListEmptyState } from './PolicyListEmptyState' +import { ToggleRLSButton } from './ToggleRLSButton' + +interface RLSManagementProps { + table?: PostgresTable + tableFields: TableField // Fields within the form + foreignKeyRelations?: ForeignKey[] // For new tables + isNewRecord: boolean + isDuplicating: boolean + generatedPolicies?: GeneratedPolicy[] + onRLSUpdate?: (isEnabled: boolean) => void + onGeneratedPoliciesChange?: (policies: GeneratedPolicy[]) => void +} + +export const RLSManagement = ({ + table, + tableFields, + foreignKeyRelations = [], + isNewRecord, + isDuplicating, + generatedPolicies = [], + onRLSUpdate, + onGeneratedPoliciesChange, +}: RLSManagementProps) => { + const track = useTrack() + const { data: project } = useSelectedProjectQuery() + const { selectedSchema } = useQuerySchemaState() + + const { name: tableName, columns, isRLSEnabled } = tableFields + const schema = table?.schema ?? selectedSchema + + const isExistingTable = !!table && !isNewRecord && !isDuplicating + const generatedPoliciesTargetTable = generatedPolicies[0]?.table + const policiesNotRelevantDueToTableNameChange = + isNewRecord && tableName !== generatedPoliciesTargetTable + const disablePoliciesList = + (isExistingTable && !isRLSEnabled) || policiesNotRelevantDueToTableNameChange + + const { data: policies } = useDatabasePoliciesQuery( + { + projectRef: project?.ref, + connectionString: project?.connectionString, + }, + { + enabled: !isNewRecord && !isDuplicating, + } + ) + + const tablePolicies = (policies ?? []).filter( + (policy) => policy.schema === table?.schema && policy.table === table?.name + ) + + const existingPoliciesList = useMemo( + () => + (tablePolicies ?? []).map((policy) => ({ + name: policy.name, + command: policy.action ?? policy.command, + sql: generatePolicyUpdateSQL(policy), + isNew: false, + })), + [tablePolicies] + ) + + // Convert generated policies to PolicyListItemData format + const generatedPoliciesList = useMemo( + () => + generatedPolicies.map((policy) => ({ + name: policy.name, + command: policy.command, + sql: policy.sql, + isNew: true, + })), + [generatedPolicies] + ) + + const allPoliciesList = useMemo( + () => [...existingPoliciesList, ...generatedPoliciesList], + [existingPoliciesList, generatedPoliciesList] + ) + + const hasPolicies = allPoliciesList.length > 0 + + const handleRemoveGeneratedPolicy = (index: number) => { + // Find the index in generatedPoliciesList that corresponds to the allPoliciesList index + const generatedIndex = index - existingPoliciesList.length + if (generatedIndex >= 0 && generatedIndex < generatedPolicies.length) { + const updatedPolicies = generatedPolicies.filter((_, i) => i !== generatedIndex) + onGeneratedPoliciesChange?.(updatedPolicies) + + // Track policy removal + if (project?.ref) track('rls_generated_policy_removed') + } + } + + return ( +
+
+
+
Policies
+

+ Set rules around who can read and write data to this table +

+
+ {!isNewRecord && ( + + )} +
+ + {generatedPolicies.length > 0 && policiesNotRelevantDueToTableNameChange && ( + { + const updatedPolicies = generatedPolicies.map((policy) => { + return { + ...policy, + table: tableName, + name: policy.name.replaceAll(generatedPoliciesTargetTable, tableName), + sql: policy.sql.replaceAll(generatedPoliciesTargetTable, tableName), + } + }) + onGeneratedPoliciesChange?.(updatedPolicies) + }} + > + Update policies + + } + /> + )} + + + + +
+ } + /> + + {!hasPolicies ? ( + { + onGeneratedPoliciesChange?.(policies) + }} + /> + ) : ( + + )} + + ) +} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSManagement/ToggleRLSButton.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSManagement/ToggleRLSButton.tsx new file mode 100644 index 0000000000000..41ed4a52835a3 --- /dev/null +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSManagement/ToggleRLSButton.tsx @@ -0,0 +1,70 @@ +import { PostgresTable } from '@supabase/postgres-meta' +import { useTableUpdateMutation } from 'data/tables/table-update-mutation' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { useState } from 'react' +import { toast } from 'sonner' + +import { Button } from 'ui' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' + +interface ToggleRLSButtonProps { + table?: PostgresTable + isRLSEnabled?: boolean + onSuccess?: (value: boolean) => void +} + +export const ToggleRLSButton = ({ + table, + isRLSEnabled = false, + onSuccess, +}: ToggleRLSButtonProps) => { + const { data: project } = useSelectedProjectQuery() + const [showConfirmation, setShowConfirmation] = useState(false) + + const action = isRLSEnabled ? 'Disable' : 'Enable' + + const { mutate: updateTable, isPending } = useTableUpdateMutation() + + const onConfirm = () => { + if (!project) return console.error('Project is required') + if (!table) return console.error('Table is missing') + + updateTable( + { + id: table.id, + projectRef: project?.ref, + connectionString: project?.connectionString, + schema: table.schema, + name: table.name, + payload: { rls_enabled: !isRLSEnabled }, + }, + { + onSuccess: () => { + toast.success(`Row Level Security has been ${action.toLowerCase()} for this table.`) + onSuccess?.(!isRLSEnabled) + setShowConfirmation(false) + }, + onError: (error) => { + toast.error(`Failed to ${action.toLowerCase()} RLS: ${error.message}`) + }, + } + ) + } + + return ( + <> + + setShowConfirmation(false)} + onConfirm={onConfirm} + /> + + ) +} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx index 0533e95c1ba75..66c325dd7c42d 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx @@ -1,8 +1,9 @@ import type { PostgresTable } from '@supabase/postgres-meta' -import { isEmpty, isUndefined, noop } from 'lodash' +import { isEmpty, noop } from 'lodash' import { useContext, useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' +import type { GeneratedPolicy } from 'components/interfaces/Auth/Policies/Policies.utils' import { DocsButton } from 'components/ui/DocsButton' import { useDatabasePublicationsQuery } from 'data/database-publications/database-publications-query' import { CONSTRAINT_TYPE, useTableConstraintsQuery } from 'data/database/constraints-query' @@ -14,6 +15,7 @@ import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { RealtimeButtonVariant, useRealtimeExperiment } from 'hooks/misc/useRealtimeExperiment' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { useTableCreateGeneratePolicies } from 'hooks/misc/useTableCreateGeneratePolicies' import { useUrlState } from 'hooks/ui/useUrlState' import { useProtectedSchemas } from 'hooks/useProtectedSchemas' import { DOCS_URL } from 'lib/constants' @@ -32,7 +34,8 @@ import { SpreadsheetImport } from '../SpreadsheetImport/SpreadsheetImport' import ColumnManagement from './ColumnManagement' import { ForeignKeysManagement } from './ForeignKeysManagement/ForeignKeysManagement' import { HeaderTitle } from './HeaderTitle' -import RLSDisableModalContent from './RLSDisableModal' +import { RLSDisableModalContent } from './RLSDisableModal' +import { RLSManagement } from './RLSManagement/RLSManagement' import { DEFAULT_COLUMNS } from './TableEditor.constants' import type { ImportContent, TableField } from './TableEditor.types' import { @@ -69,24 +72,34 @@ export const TableEditor = ({ saveChanges = noop, updateEditorDirty = noop, }: TableEditorProps) => { - const tableEditorApi = useContext(TableEditorStateContext) + const track = useTrack() const snap = useTableEditorStateSnapshot() + const tableEditorApi = useContext(TableEditorStateContext) + const { realtimeAll: realtimeEnabled } = useIsFeatureEnabled(['realtime:all']) + const { docsRowLevelSecurityGuidePath } = useCustomContent(['docs:row_level_security_guide_path']) + const [params, setParams] = useUrlState() const { data: project } = useSelectedProjectQuery() const { selectedSchema } = useQuerySchemaState() - const isNewRecord = isUndefined(table) - const { realtimeAll: realtimeEnabled } = useIsFeatureEnabled(['realtime:all']) - const track = useTrack() - const { docsRowLevelSecurityGuidePath } = useCustomContent(['docs:row_level_security_guide_path']) + const isNewRecord = table === undefined + const visibleChanged = useChanged(visible) - const [params, setParams] = useUrlState() - useEffect(() => { - if (params.create === 'table' && snap.ui.open === 'none') { - tableEditorApi.onAddTable() - setParams({ ...params, create: undefined }) - } - }, [tableEditorApi, setParams, snap.ui.open, params]) + const [errors, setErrors] = useState({}) + const [tableFields, setTableFields] = useState() + const [fkRelations, setFkRelations] = useState([]) + + const [isDuplicateRows, setIsDuplicateRows] = useState(false) + const [importContent, setImportContent] = useState() + const [isImportingSpreadsheet, setIsImportingSpreadsheet] = useState(false) + const [rlsConfirmVisible, setRlsConfirmVisible] = useState(false) + + const [generatedPolicies, setGeneratedPolicies] = useState([]) + + const { enabled: generatePoliciesEnabled } = useTableCreateGeneratePolicies({ + isNewRecord, + projectInsertedAt: project?.inserted_at, + }) const { data: types } = useEnumeratedTypesQuery({ projectRef: project?.ref, @@ -115,15 +128,6 @@ export const TableEditor = ({ isRealtimeEnabled, }) - const [errors, setErrors] = useState({}) - const [tableFields, setTableFields] = useState() - const [fkRelations, setFkRelations] = useState([]) - - const [isDuplicateRows, setIsDuplicateRows] = useState(false) - const [importContent, setImportContent] = useState() - const [isImportingSpreadsheet, setIsImportingSpreadsheet] = useState(false) - const [rlsConfirmVisible, setRlsConfirmVisible] = useState(false) - const { data: constraints } = useTableConstraintsQuery({ projectRef: project?.ref, connectionString: project?.connectionString, @@ -134,11 +138,16 @@ export const TableEditor = ({ ) const { data: foreignKeyMeta, isSuccess: isSuccessForeignKeyMeta } = - useForeignKeyConstraintsQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - schema: table?.schema, - }) + useForeignKeyConstraintsQuery( + { + projectRef: project?.ref, + connectionString: project?.connectionString, + schema: table?.schema, + }, + { + enabled: !isNewRecord && !!table?.schema, + } + ) const foreignKeys = useMemo( () => (foreignKeyMeta ?? []).filter( @@ -186,15 +195,11 @@ export const TableEditor = ({ setFkRelations(relations) } - const onSaveChanges = (resolve: () => void) => { + const onSaveChanges = async (resolve: () => void) => { if (tableFields) { const errors = validateFields(tableFields) - if (errors.name) { - toast.error(errors.name) - } - if (errors.columns) { - toast.error(errors.columns) - } + if (errors.name) toast.error(errors.name) + if (errors.columns) toast.error(errors.columns) setErrors(errors) const isNameChanged = tableFields.name.trim() !== table?.name @@ -228,6 +233,7 @@ export const TableEditor = ({ columns, foreignKeyRelations: fkRelations, resolve, + generatedPolicies, }) } else if (isDuplicating) { const payload: SaveTablePayloadFor<'duplicate'> = { @@ -241,6 +247,7 @@ export const TableEditor = ({ columns, foreignKeyRelations: fkRelations, resolve, + generatedPolicies: [], }) } else { const payload: SaveTablePayloadFor<'update'> = { @@ -255,6 +262,7 @@ export const TableEditor = ({ columns, foreignKeyRelations: fkRelations, resolve, + generatedPolicies: [], }) } } else { @@ -263,12 +271,19 @@ export const TableEditor = ({ } } - const visibleChanged = useChanged(visible) + useEffect(() => { + if (params.create === 'table' && snap.ui.open === 'none') { + tableEditorApi.onAddTable() + setParams({ ...params, create: undefined }) + } + }, [tableEditorApi, setParams, snap.ui.open, params]) + useEffect(() => { if (visibleChanged && visible) { setErrors({}) setImportContent(undefined) setIsDuplicateRows(false) + setGeneratedPolicies([]) if (isNewRecord) { const tableFields = generateTableField() if (templateData) { @@ -362,88 +377,96 @@ export const TableEditor = ({ onChange={(event: any) => onUpdateField({ comment: event.target.value })} /> + - - - Enable Row Level Security (RLS) - Recommended - - } - description="Restrict access to your table by enabling RLS and writing Postgres policies." - checked={tableFields.isRLSEnabled} - onChange={() => { - // if isEnabled, show confirm modal to turn off - // if not enabled, allow turning on without modal confirmation - tableFields.isRLSEnabled - ? setRlsConfirmVisible(true) - : onUpdateField({ isRLSEnabled: !tableFields.isRLSEnabled }) - }} - size="medium" - /> - {tableFields.isRLSEnabled ? ( - - You need to create an access policy before you can query data from this table. - Without a policy, querying this table will return an{' '} - empty array of results.{' '} - {isNewRecord ? 'You can create policies after saving this table.' : ''} - - } - > - - - ) : ( - - {tableFields.name ? `The table ${tableFields.name}` : 'Your table'} will be publicly - writable and readable - - } - > - + + + Enable Row Level Security (RLS) + Recommended + + } + description="Restrict access to your table by enabling RLS and writing Postgres policies." + checked={tableFields.isRLSEnabled} + onChange={() => { + // if isEnabled, show confirm modal to turn off + // if not enabled, allow turning on without modal confirmation + tableFields.isRLSEnabled + ? setRlsConfirmVisible(true) + : onUpdateField({ isRLSEnabled: !tableFields.isRLSEnabled }) + }} + size="medium" /> - - )} - {activeRealtimeVariant !== RealtimeButtonVariant.HIDE_BUTTON && realtimeEnabled && ( - { - track('realtime_toggle_table_clicked', { - newState: tableFields.isRealtimeEnabled ? 'disabled' : 'enabled', - origin: 'tableSidePanel', - }) - onUpdateField({ isRealtimeEnabled: !tableFields.isRealtimeEnabled }) - }} - size="medium" - /> - )} - + {tableFields.isRLSEnabled ? ( + + You need to create an access policy before you can query data from this table. + Without a policy, querying this table will return an{' '} + empty array of results.{' '} + {isNewRecord ? 'You can create policies after saving this table.' : ''} + + } + > + + + ) : ( + + {tableFields.name ? `The table ${tableFields.name}` : 'Your table'} will be + publicly writable and readable + + } + > + + + )} + + {activeRealtimeVariant !== RealtimeButtonVariant.HIDE_BUTTON && realtimeEnabled && ( + { + track('realtime_toggle_table_clicked', { + newState: tableFields.isRealtimeEnabled ? 'disabled' : 'enabled', + origin: 'tableSidePanel', + }) + onUpdateField({ + isRealtimeEnabled: !tableFields.isRealtimeEnabled, + }) + }} + size="medium" + /> + )} + - + + + )} {!isDuplicating && ( @@ -487,19 +510,21 @@ export const TableEditor = ({ closePanel={() => setIsImportingSpreadsheet(false)} /> - setRlsConfirmVisible(false)} - onConfirm={() => { - onUpdateField({ isRLSEnabled: !tableFields.isRLSEnabled }) - setRlsConfirmVisible(false) - }} - > - - + {!generatePoliciesEnabled && ( + setRlsConfirmVisible(false)} + onConfirm={() => { + onUpdateField({ isRLSEnabled: !tableFields.isRLSEnabled }) + setRlsConfirmVisible(false) + }} + > + + + )} {!isDuplicating && ( @@ -516,6 +541,26 @@ export const TableEditor = ({ )} + + {/* [Joshen] Temporarily hide this section if duplicating, as we aren't duplicating policies atm when duplicating tables */} + {/* We should do this thought, but let's do this in another PR as the current one is already quite big */} + {generatePoliciesEnabled && !isDuplicating && ( + <> + + + onUpdateField({ isRLSEnabled: value })} + /> + + + )} ) } diff --git a/apps/studio/components/ui/AIAssistantPanel/AIOptInModal.tsx b/apps/studio/components/ui/AIAssistantPanel/AIOptInModal.tsx index 70f7a91360222..d539c9eba1ab8 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIOptInModal.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIOptInModal.tsx @@ -43,7 +43,7 @@ export const AIOptInModal = ({ visible, onCancel }: AIOptInModalProps) => { return ( - +
diff --git a/apps/studio/data/ai/sql-policy-mutation.ts b/apps/studio/data/ai/sql-policy-mutation.ts new file mode 100644 index 0000000000000..fde77f775cbff --- /dev/null +++ b/apps/studio/data/ai/sql-policy-mutation.ts @@ -0,0 +1,76 @@ +import { useMutation } from '@tanstack/react-query' +import { toast } from 'sonner' + +import type { CreatePolicyBody } from 'data/database-policies/database-policy-create-mutation' +import { fetchPost } from 'data/fetchers' +import { BASE_PATH } from 'lib/constants' +import { ResponseError, UseCustomMutationOptions } from 'types' + +export type SqlPolicyGenerateVariables = { + tableName: string + schema?: string + columns?: string[] + projectRef: string + connectionString: string + orgSlug?: string + message?: string +} + +/** + * AI-generated policy response extends CreatePolicyBody with required fields and sql for display. + */ +export type SqlPolicyGenerateResponse = (Required< + Pick +> & + Pick & { + sql: string + })[] + +export async function generateSqlPolicy({ + tableName, + schema, + columns, + projectRef, + connectionString, + orgSlug, + message, +}: SqlPolicyGenerateVariables): Promise { + const result = await fetchPost(`${BASE_PATH}/api/ai/sql/policy`, { + tableName, + schema, + columns, + projectRef, + connectionString, + orgSlug, + message, + }) + + if ('error' in result) throw new ResponseError((result.error as any).message, 400) + return result as SqlPolicyGenerateResponse +} + +type SqlPolicyGenerateData = Awaited> + +export const useSqlPolicyGenerateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions, + 'mutationFn' +> = {}) => { + return useMutation({ + mutationFn: (vars) => generateSqlPolicy(vars), + async onSuccess(data, variables, context) { + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to generate policy: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/database-policies/database-policy-create-mutation.ts b/apps/studio/data/database-policies/database-policy-create-mutation.ts index b1012f086e1bc..a952aeda8f0ff 100644 --- a/apps/studio/data/database-policies/database-policy-create-mutation.ts +++ b/apps/studio/data/database-policies/database-policy-create-mutation.ts @@ -6,7 +6,7 @@ import { executeSql } from 'data/sql/execute-sql-query' import type { ResponseError, UseCustomMutationOptions } from 'types' import { databasePoliciesKeys } from './keys' -type CreatePolicyBody = { +export type CreatePolicyBody = { name: string table: string schema?: string diff --git a/apps/studio/data/database/foreign-key-constraints-query.ts b/apps/studio/data/database/foreign-key-constraints-query.ts index 8494a43e82e91..797d09ffd200f 100644 --- a/apps/studio/data/database/foreign-key-constraints-query.ts +++ b/apps/studio/data/database/foreign-key-constraints-query.ts @@ -1,8 +1,8 @@ import { QueryClient, useQuery } from '@tanstack/react-query' +import { UseCustomQueryOptions } from 'types' import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query' import { databaseKeys } from './keys' -import { UseCustomQueryOptions } from 'types' type GetForeignKeyConstraintsVariables = { schema?: string @@ -39,9 +39,7 @@ export type ForeignKeyConstraint = { } export const getForeignKeyConstraintsSql = ({ schema }: GetForeignKeyConstraintsVariables) => { - if (!schema) { - throw new Error('schema is required') - } + if (!schema) throw new Error('schema is required') const sql = /* SQL */ ` SELECT @@ -139,7 +137,11 @@ export const useForeignKeyConstraintsQuery = queryKey: databaseKeys.foreignKeyConstraints(projectRef, schema), queryFn: ({ signal }) => getForeignKeyConstraints({ projectRef, connectionString, schema }, signal), - enabled: enabled && typeof projectRef !== 'undefined' && typeof schema !== 'undefined', + enabled: + enabled && + typeof projectRef !== 'undefined' && + typeof schema !== 'undefined' && + schema.length > 0, ...options, }) diff --git a/apps/studio/hooks/misc/useTableCreateGeneratePolicies.ts b/apps/studio/hooks/misc/useTableCreateGeneratePolicies.ts new file mode 100644 index 0000000000000..92cf14dee2de7 --- /dev/null +++ b/apps/studio/hooks/misc/useTableCreateGeneratePolicies.ts @@ -0,0 +1,76 @@ +import dayjs from 'dayjs' +import utc from 'dayjs/plugin/utc' +import { useEffect, useMemo, useRef } from 'react' + +import { usePHFlag } from 'hooks/ui/useFlag' +import { IS_PLATFORM } from 'lib/constants' +import { useTrack } from 'lib/telemetry/track' + +dayjs.extend(utc) + +interface UseTableCreateGeneratePoliciesOptions { + /** + * Whether this is a new table being created + */ + isNewRecord?: boolean + /** + * Project creation timestamp + */ + projectInsertedAt?: string +} + +interface UseTableCreateGeneratePoliciesResult { + /** + * Whether the generate policies feature is enabled + */ + enabled: boolean +} + +/** + * Hook to manage the table create generate policies feature flag. + * Handles feature flag determination and exposure tracking. + * + * @param options Configuration for feature targeting + * @returns Feature state including whether it's enabled + */ +export function useTableCreateGeneratePolicies({ + isNewRecord = false, + projectInsertedAt, +}: UseTableCreateGeneratePoliciesOptions): UseTableCreateGeneratePoliciesResult { + const track = useTrack() + const tableCreateGeneratePoliciesFlag = usePHFlag('tableCreateGeneratePolicies') + const hasTrackedExposure = useRef(false) + + const enabled = useMemo(() => { + if (!IS_PLATFORM) return false + if (!tableCreateGeneratePoliciesFlag) return false + return true + }, [tableCreateGeneratePoliciesFlag]) + + useEffect(() => { + if (!IS_PLATFORM) return + if (hasTrackedExposure.current) return + if (!isNewRecord) return + if (tableCreateGeneratePoliciesFlag === undefined) return + if (!projectInsertedAt) return + + try { + const insertedDate = dayjs.utc(projectInsertedAt) + if (!insertedDate.isValid()) return + + const daysSinceCreation = dayjs.utc().diff(insertedDate, 'day') + track('table_create_generate_policies_experiment_exposed', { + experiment_id: 'tableCreateGeneratePolicies', + variant: tableCreateGeneratePoliciesFlag ? 'treatment' : 'control', + days_since_project_creation: daysSinceCreation, + }) + hasTrackedExposure.current = true + } catch { + hasTrackedExposure.current = false + } + }, [isNewRecord, tableCreateGeneratePoliciesFlag, projectInsertedAt, track]) + + return { + enabled, + } +} diff --git a/apps/studio/pages/api/ai/sql/policy.ts b/apps/studio/pages/api/ai/sql/policy.ts new file mode 100644 index 0000000000000..d403e9b6b3551 --- /dev/null +++ b/apps/studio/pages/api/ai/sql/policy.ts @@ -0,0 +1,166 @@ +import { Output, generateText, stepCountIs } from 'ai' +import { IS_PLATFORM } from 'common' +import { source } from 'common-tags' +import { NextApiRequest, NextApiResponse } from 'next' +import { z } from 'zod' + +import type { AiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi' +import { getModel } from 'lib/ai/model' +import { getOrgAIDetails } from 'lib/ai/org-ai-details' +import { RLS_PROMPT } from 'lib/ai/prompts' +import { getTools } from 'lib/ai/tools' +import apiWrapper from 'lib/api/apiWrapper' + +const policySchema = z.object({ + sql: z.string().describe('The generated Postgres CREATE POLICY statement.'), + name: z.string().describe('The name of the policy.'), + command: z + .enum(['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'ALL']) + .describe('The SQL command this policy applies to.'), + definition: z + .string() + .optional() + .describe('The USING clause expression (for SELECT, UPDATE, DELETE).'), + check: z.string().optional().describe('The WITH CHECK clause expression (for INSERT, UPDATE).'), + action: z + .enum(['PERMISSIVE', 'RESTRICTIVE']) + .default('PERMISSIVE') + .describe('Whether the policy is PERMISSIVE or RESTRICTIVE.'), + roles: z.array(z.string()).default(['public']).describe('The roles this policy applies to.'), +}) + +const requestBodySchema = z.object({ + tableName: z.string().min(1), + schema: z.string().default('public'), + columns: z.array(z.string()).optional(), + projectRef: z.string().min(1), + connectionString: z.string().min(1), + orgSlug: z.string().optional(), + message: z.string().optional(), +}) + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { method } = req + + switch (method) { + case 'POST': + return handlePost(req, res) + default: + res.setHeader('Allow', ['POST']) + res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) + } +} + +export async function handlePost(req: NextApiRequest, res: NextApiResponse) { + const authorization = req.headers.authorization + const accessToken = authorization?.replace('Bearer ', '') + + if (IS_PLATFORM && !accessToken) { + return res.status(401).json({ error: 'Authorization token is required' }) + } + + const body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body + const { data, error: parseError } = requestBodySchema.safeParse(body) + + if (parseError) { + return res.status(400).json({ error: 'Invalid request body', issues: parseError.issues }) + } + + const { tableName, schema, columns = [], projectRef, connectionString, orgSlug, message } = data + + let aiOptInLevel: AiOptInLevel = 'disabled' + + if (!IS_PLATFORM) { + aiOptInLevel = 'schema' + } + + if (IS_PLATFORM && orgSlug && authorization) { + try { + const { aiOptInLevel: orgAIOptInLevel } = await getOrgAIDetails({ + orgSlug, + authorization, + projectRef, + }) + + aiOptInLevel = orgAIOptInLevel + } catch (error) { + return res.status(400).json({ + error: 'There was an error fetching your organization details', + }) + } + } + + try { + const { model, error: modelError } = await getModel({ + provider: 'openai', + routingKey: 'sql-policy', + }) + + if (modelError) { + return res.status(500).json({ error: modelError.message }) + } + + const tools = await getTools({ + projectRef, + connectionString, + authorization, + aiOptInLevel, + accessToken, + }) + + const { experimental_output } = await generateText({ + model, + stopWhen: stepCountIs(5), + prompt: source` + You are a Postgres RLS (Row Level Security) expert. + Determine the most appropriate policies for the "${schema}"."${tableName}" table within a Supabase project. + + ${columns.length > 0 ? `Table columns: ${columns.join(', ')}` : 'No column metadata provided.'} + + ${message ? `User request: ${message}` : ''} + + RLS Guide: ${RLS_PROMPT} + + Requirements: + - Use the available planning and schema tools (like "list_policies" or "list_tables") to inspect the "${schema}" schema and existing policies before generating new ones. + - Ensure policies strictly adhere to the existing schema + - Return a curated list of recommended CREATE POLICY statements as JSON. + - Each policy must include: name, sql, command (SELECT/INSERT/UPDATE/DELETE/ALL), action (PERMISSIVE/RESTRICTIVE), roles (array of role names). + - Include "definition" (USING clause expression without the USING keyword) for SELECT, UPDATE, DELETE policies. + - Include "check" (WITH CHECK clause expression without the WITH CHECK keywords) for INSERT, UPDATE policies. + - Avoid duplicating existing policies and reference the public schema and typical Supabase best practices when deciding the coverage. + - Prefer PERMISSIVE policies unless a RESTRICTIVE policy is explicitly required + `, + tools, + experimental_output: Output.object({ + schema: z.object({ + policies: z.array(policySchema), + }), + }), + }) + + // Add table and schema to each policy from the request + const policies = (experimental_output?.policies ?? []).map((policy) => ({ + ...policy, + table: tableName, + schema, + })) + + return res.json(policies) + } catch (error) { + if (error instanceof Error) { + console.error(`AI policy generation failed: ${error.message}`) + return res.status(500).json({ + error: 'Failed to generate policy. Please try again.', + }) + } + return res.status(500).json({ + error: 'An unknown error occurred.', + }) + } +} + +const wrapper = (req: NextApiRequest, res: NextApiResponse) => + apiWrapper(req, res, handler, { withAuth: true }) + +export default wrapper diff --git a/apps/studio/proxy.ts b/apps/studio/proxy.ts index 124d6aa31269d..9f8c75aabc34a 100644 --- a/apps/studio/proxy.ts +++ b/apps/studio/proxy.ts @@ -8,6 +8,7 @@ export const config = { // [Joshen] Return 404 for all next.js API endpoints EXCEPT the ones we use in hosted: const HOSTED_SUPPORTED_API_URLS = [ '/ai/sql/generate-v4', + '/ai/sql/policy', '/ai/feedback/rate', '/ai/code/complete', '/ai/sql/cron-v2', diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 92c7ce67b61cf..664b0a4f50e54 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -1933,6 +1933,10 @@ export interface TableCreatedEvent { * Name of the table created */ table_name?: string + /** + * Whether RLS policies were generated and saved with the table + */ + has_generated_policies?: boolean } groups: Partial } @@ -1989,6 +1993,104 @@ export interface TableRLSEnabledEvent { groups: Partial } +/** + * User clicked the generate policies button in the table editor. + * + * @group Events + * @source studio + * @page /dashboard/project/{ref}/editor + */ +export interface RLSGeneratePoliciesClickedEvent { + action: 'rls_generate_policies_clicked' + groups: TelemetryGroups +} + +/** + * User removed a generated policy from the table editor. + * + * @group Events + * @source studio + * @page /dashboard/project/{ref}/editor + */ +export interface RLSGeneratedPolicyRemovedEvent { + action: 'rls_generated_policy_removed' + groups: TelemetryGroups +} + +/** + * User successfully created generated RLS policies for a table. + * + * @group Events + * @source studio + * @page /dashboard/project/{ref}/editor + */ +export interface RLSGeneratedPoliciesCreatedEvent { + action: 'rls_generated_policies_created' + groups: TelemetryGroups +} + +/** + * Conversion event for the generate policies experiment. + * Fires when a user in the experiment creates a new table via table editor. + * This is separate from TableCreatedEvent to keep experiment tracking isolated. + * + * @group Events + * @source studio + * @page /dashboard/project/{ref}/editor + */ +export interface TableCreateGeneratePoliciesExperimentConvertedEvent { + action: 'table_create_generate_policies_experiment_converted' + properties: { + /** + * Experiment identifier for tracking + */ + experiment_id: 'tableCreateGeneratePolicies' + /** + * Experiment variant: 'control' (feature disabled) or 'treatment' (feature enabled) + */ + variant: 'control' | 'treatment' + /** + * Whether RLS was enabled on the table + */ + has_rls_enabled: boolean + /** + * Whether the table was created with any RLS policies (manual or generated) + */ + has_rls_policies: boolean + /** + * Whether AI-generated policies were used (only possible in treatment) + */ + has_generated_policies: boolean + } + groups: TelemetryGroups +} + +/** + * User was exposed to the generate policies experiment (shown or not shown the Generate Policies button). + * + * @group Events + * @source studio + * @page /dashboard/project/{ref}/editor + */ +export interface TableCreateGeneratePoliciesExperimentExposedEvent { + action: 'table_create_generate_policies_experiment_exposed' + properties: { + /** + * Experiment identifier for tracking + */ + experiment_id: 'tableCreateGeneratePolicies' + /** + * Experiment variant: 'control' (feature disabled) or 'treatment' (feature enabled) + */ + variant: 'control' | 'treatment' + /** + * Days since project creation (to segment by new user cohorts) + */ + days_since_project_creation: number + } + groups: TelemetryGroups +} + /** * User opened API docs panel. * @@ -2607,6 +2709,11 @@ export type TelemetryEvent = | TableCreatedEvent | TableDataAddedEvent | TableRLSEnabledEvent + | RLSGeneratePoliciesClickedEvent + | RLSGeneratedPolicyRemovedEvent + | RLSGeneratedPoliciesCreatedEvent + | TableCreateGeneratePoliciesExperimentExposedEvent + | TableCreateGeneratePoliciesExperimentConvertedEvent | TableQuickstartOpenedEvent | TableQuickstartAIPromptSubmittedEvent | TableQuickstartAIGenerationCompletedEvent From 77944aca9a1b5f1b2743ee112e8318828cbfa531 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 12 Dec 2025 14:42:56 +0800 Subject: [PATCH 2/9] Decouple foreign schema creation from vector buckets (#41258) * Decouple foreign schema creation from vector buckets * Clean up * Fix ts * Fix issues --- .../NamespaceWithTables/TableRowComponent.tsx | 3 +- .../NamespaceWithTables/index.tsx | 2 - .../CreateAnalyticsBucketModal.tsx | 13 +- .../Storage/ImportForeignSchemaDialog.tsx | 3 +- .../ImportForeignSchemaDialog.utils.ts | 49 ----- .../interfaces/Storage/Storage.utils.ts | 48 +++++ .../CreateVectorBucketDialog.tsx | 32 +--- .../VectorBuckets/CreateVectorTableSheet.tsx | 15 +- .../VectorBuckets/DeleteVectorTableModal.tsx | 33 +++- .../InitializeForeignSchemaDialog.tsx | 175 ++++++++++++++++++ .../VectorBucketCallouts.tsx | 14 +- .../VectorBucketTableExamplesSheet.tsx | 67 ++++--- .../VectorBucketDetails/index.tsx | 30 +-- .../VectorBuckets/VectorBuckets.utils.ts | 4 +- apps/studio/data/fdw/fdw-update-mutation.ts | 17 +- apps/studio/data/fdw/fdws-query.ts | 2 +- .../s3-vectors-wrapper-create-mutation.ts | 6 +- .../storage/vectors/buckets/[bucketId].tsx | 2 +- 18 files changed, 352 insertions(+), 163 deletions(-) delete mode 100644 apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.utils.ts create mode 100644 apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/InitializeForeignSchemaDialog.tsx diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx index 7c018dc3f4f8c..154b4006ceaab 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx @@ -9,7 +9,7 @@ import { convertKVStringArrayToJson, formatWrapperTables, } from 'components/interfaces/Integrations/Wrappers/Wrappers.utils' -import { getDecryptedParameters } from 'components/interfaces/Storage/ImportForeignSchemaDialog.utils' +import { getDecryptedParameters } from 'components/interfaces/Storage/Storage.utils' import { DotPing } from 'components/ui/DotPing' import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip' import { useFDWDropForeignTableMutation } from 'data/fdw/fdw-drop-foreign-table-mutation' @@ -194,6 +194,7 @@ export const TableRowComponent = ({ table, schema, namespace }: TableRowComponen ref: project?.ref, connectionString: project?.connectionString ?? undefined, wrapper: wrapperInstance, + wrapperMeta, }) const formValues: Record = { wrapper_name: wrapperInstance.name, diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/index.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/index.tsx index e7083db2c2a34..fed718770011c 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/index.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/index.tsx @@ -5,7 +5,6 @@ import { toast } from 'sonner' import { useParams } from 'common' import { FormattedWrapperTable } from 'components/interfaces/Integrations/Wrappers/Wrappers.utils' import { ImportForeignSchemaDialog } from 'components/interfaces/Storage/ImportForeignSchemaDialog' -import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useFDWDropForeignTableMutation } from 'data/fdw/fdw-drop-foreign-table-mutation' import { useFDWImportForeignSchemaMutation } from 'data/fdw/fdw-import-foreign-schema-mutation' import { useIcebergNamespaceDeleteMutation } from 'data/storage/iceberg-namespace-delete-mutation' @@ -66,7 +65,6 @@ export const NamespaceWithTables = ({ const [showConfirmDeleteNamespace, setShowConfirmDeleteNamespace] = useState(false) const [isDeletingNamespace, setIsDeletingNamespace] = useState(false) - const { data: projectSettings } = useProjectSettingsV2Query({ projectRef }) const { publication, icebergWrapper } = useAnalyticsBucketAssociatedEntities({ projectRef, bucketId, diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketModal.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketModal.tsx index 6ca9035d2d9c4..bb8bd0c54c118 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketModal.tsx @@ -176,11 +176,10 @@ export const CreateAnalyticsBucketModal = ({ schema: wrappersExtension.schema ?? 'extensions', version: wrappersExtension.default_version, }) - await createIcebergWrapper({ bucketName: values.name }) - } else if (wrappersExtensionState === 'installed') { - await createIcebergWrapper({ bucketName: values.name }) } + await createIcebergWrapper({ bucketName: values.name }) + sendEvent({ action: 'storage_bucket_created', properties: { bucketType: 'analytics' }, @@ -207,7 +206,7 @@ export const CreateAnalyticsBucketModal = ({ if (!open) handleClose() }} > - + Create {config.singularName} bucket @@ -216,7 +215,7 @@ export const CreateAnalyticsBucketModal = ({ - + (

@@ -262,7 +263,7 @@ export const CreateAnalyticsBucketModal = ({

) : ( - +

Supabase will install the{' '} {wrappersExtensionState !== 'installed' ? 'Wrappers extension and ' : ''} diff --git a/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx b/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx index 4aa8e54520cd3..fe4b4a82a189a 100644 --- a/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx +++ b/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx @@ -18,7 +18,7 @@ import { formatWrapperTables } from '../Integrations/Wrappers/Wrappers.utils' import { SchemaEditor } from '../TableGridEditor/SidePanelEditor/SchemaEditor' import { getAnalyticsBucketFDWServerName } from './AnalyticsBuckets/AnalyticsBucketDetails/AnalyticsBucketDetails.utils' import { useAnalyticsBucketAssociatedEntities } from './AnalyticsBuckets/AnalyticsBucketDetails/useAnalyticsBucketAssociatedEntities' -import { getDecryptedParameters } from './ImportForeignSchemaDialog.utils' +import { getDecryptedParameters } from './Storage.utils' export interface ImportForeignSchemaDialogProps { namespace: string @@ -111,6 +111,7 @@ export const ImportForeignSchemaDialog = ({ ref: project?.ref, connectionString: project?.connectionString ?? undefined, wrapper, + wrapperMeta, }) const formValues: Record = { diff --git a/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.utils.ts b/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.utils.ts deleted file mode 100644 index 1b1f6d5172097..0000000000000 --- a/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.utils.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { type FDW } from 'data/fdw/fdws-query' -import { getDecryptedValues } from 'data/vault/vault-secret-decrypted-value-query' -import { INTEGRATIONS } from '../Integrations/Landing/Integrations.constants' -import { WrapperMeta } from '../Integrations/Wrappers/Wrappers.types' -import { convertKVStringArrayToJson } from '../Integrations/Wrappers/Wrappers.utils' - -export const getDecryptedParameters = async ({ - ref, - connectionString, - wrapper, -}: { - ref?: string - connectionString?: string - wrapper: FDW -}) => { - const integration = INTEGRATIONS.find((i) => i.id === 'iceberg_wrapper' && i.type === 'wrapper') - const wrapperMeta = (integration?.type === 'wrapper' && integration.meta) as WrapperMeta - const wrapperServerOptions = wrapperMeta.server.options - - const serverOptions = convertKVStringArrayToJson(wrapper?.server_options ?? []) - - const paramsToBeDecrypted = Object.fromEntries( - new Map( - Object.entries(serverOptions).filter(([key, value]) => { - return wrapperServerOptions.find((option) => option.name === key)?.encrypted - }) - ) - ) - - const decryptedValues = await getDecryptedValues({ - projectRef: ref, - connectionString: connectionString, - ids: Object.values(paramsToBeDecrypted), - }) - - const paramsWithDecryptedValues = Object.fromEntries( - new Map( - Object.entries(paramsToBeDecrypted).map(([name, id]) => { - const decryptedValue = decryptedValues[id] - return [name, decryptedValue] - }) - ) - ) - - return { - ...serverOptions, - ...paramsWithDecryptedValues, - } -} diff --git a/apps/studio/components/interfaces/Storage/Storage.utils.ts b/apps/studio/components/interfaces/Storage/Storage.utils.ts index 9988c45eee89c..bb4d1110a84c4 100644 --- a/apps/studio/components/interfaces/Storage/Storage.utils.ts +++ b/apps/studio/components/interfaces/Storage/Storage.utils.ts @@ -2,7 +2,11 @@ import { PostgresPolicy } from '@supabase/postgres-meta' import { difference } from 'lodash' import { useRouter } from 'next/router' +import { WrapperMeta } from 'components/interfaces/Integrations/Wrappers/Wrappers.types' +import { convertKVStringArrayToJson } from 'components/interfaces/Integrations/Wrappers/Wrappers.utils' +import { FDW } from 'data/fdw/fdws-query' import { Bucket } from 'data/storage/buckets-query' +import { getDecryptedValues } from 'data/vault/vault-secret-decrypted-value-query' import { createWrappedSymbol } from 'lib/helpers' import { STORAGE_CLIENT_LIBRARY_MAPPINGS } from './Storage.constants' import type { StoragePolicyFormField } from './Storage.types' @@ -213,3 +217,47 @@ export const useStorageV2Page = () => { const router = useRouter() return router.pathname.split('/')[4] as undefined | 'files' | 'analytics' | 'vectors' | 's3' } + +export const getDecryptedParameters = async ({ + ref, + connectionString, + wrapper, + wrapperMeta, +}: { + ref?: string + connectionString?: string + wrapper: FDW + wrapperMeta: WrapperMeta +}) => { + const wrapperServerOptions = wrapperMeta.server.options + + const serverOptions = convertKVStringArrayToJson(wrapper?.server_options ?? []) + + const paramsToBeDecrypted = Object.fromEntries( + new Map( + Object.entries(serverOptions).filter(([key, value]) => { + return wrapperServerOptions.find((option) => option.name === key)?.encrypted + }) + ) + ) + + const decryptedValues = await getDecryptedValues({ + projectRef: ref, + connectionString: connectionString, + ids: Object.values(paramsToBeDecrypted), + }) + + const paramsWithDecryptedValues = Object.fromEntries( + new Map( + Object.entries(paramsToBeDecrypted).map(([name, id]) => { + const decryptedValue = decryptedValues[id] + return [name, decryptedValue] + }) + ) + ) + + return { + ...serverOptions, + ...paramsWithDecryptedValues, + } +} diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorBucketDialog.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorBucketDialog.tsx index b5d9bf56a0959..2b0e1fb71989f 100644 --- a/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorBucketDialog.tsx +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorBucketDialog.tsx @@ -7,7 +7,6 @@ import z from 'zod' import { useParams } from 'common' import { InlineLink } from 'components/ui/InlineLink' import { useDatabaseExtensionEnableMutation } from 'data/database-extensions/database-extension-enable-mutation' -import { useSchemaCreateMutation } from 'data/database/schema-create-mutation' import { useS3VectorsWrapperCreateMutation } from 'data/storage/s3-vectors-wrapper-create-mutation' import { useVectorBucketCreateMutation } from 'data/storage/vector-bucket-create-mutation' import { useVectorBucketsQuery } from 'data/storage/vector-buckets-query' @@ -33,7 +32,6 @@ import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { validVectorBucketName } from './CreateVectorBucketDialog.utils' import { useS3VectorsWrapperExtension } from './useS3VectorsWrapper' -import { getVectorBucketFDWSchemaName } from './VectorBuckets.utils' const FormSchema = z.object({ name: z @@ -112,10 +110,6 @@ export const CreateVectorBucketDialog = ({ const { mutateAsync: createS3VectorsWrapper } = useS3VectorsWrapperCreateMutation() - const { mutateAsync: createSchema } = useSchemaCreateMutation({ - onError: () => {}, - }) - const { mutateAsync: enableExtension } = useDatabaseExtensionEnableMutation() const onSubmit: SubmitHandler = async (values) => { @@ -146,23 +140,9 @@ export const CreateVectorBucketDialog = ({ schema: wrappersExtension.schema ?? 'extensions', version: wrappersExtension.default_version, }) - - await createS3VectorsWrapper({ bucketName: values.name }) - - await createSchema({ - projectRef: project?.ref, - connectionString: project?.connectionString, - name: getVectorBucketFDWSchemaName(values.name), - }) - } else if (wrappersExtensionState === 'installed') { - await createS3VectorsWrapper({ bucketName: values.name }) - - await createSchema({ - projectRef: project?.ref, - connectionString: project?.connectionString, - name: getVectorBucketFDWSchemaName(values.name), - }) } + + await createS3VectorsWrapper({ bucketName: values.name }) } catch (error: any) { toast.warning( `Failed to create vector bucket integration: ${error.message}. The bucket will be created but you will need to manually install the integration.` @@ -195,7 +175,7 @@ export const CreateVectorBucketDialog = ({ - + )} /> - + +

Supabase will install the{' '} {wrappersExtensionState !== 'installed' ? 'Wrappers extension and ' : ''} diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorTableSheet.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorTableSheet.tsx index 0e31de76ea19c..77b41165fa7c0 100644 --- a/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorTableSheet.tsx +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorTableSheet.tsx @@ -6,13 +6,14 @@ import { useEffect } from 'react' import { SubmitHandler, useFieldArray, useForm } from 'react-hook-form' import { toast } from 'sonner' import z from 'zod' -import { DOCS_URL } from 'lib/constants' + import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { DocsButton } from 'components/ui/DocsButton' import { useFDWImportForeignSchemaMutation } from 'data/fdw/fdw-import-foreign-schema-mutation' import { useVectorBucketIndexCreateMutation } from 'data/storage/vector-bucket-index-create-mutation' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { DOCS_URL } from 'lib/constants' import { Button, Form_Shadcn_, @@ -33,7 +34,6 @@ import { import { Admonition } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { inverseValidBucketNameRegex } from '../CreateBucketModal.utils' -import { getVectorBucketFDWSchemaName } from './VectorBuckets.utils' import { useS3VectorsWrapperInstance } from './useS3VectorsWrapperInstance' const isStagingLocal = process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod' @@ -111,6 +111,9 @@ export const CreateVectorTableSheet = ({ bucketName }: CreateVectorTableSheetPro const { can: canCreateBuckets } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') const { data: wrapperInstance } = useS3VectorsWrapperInstance({ bucketId: bucketName }) + const schema = wrapperInstance?.server_options + .find((x) => x.startsWith('supabase_target_schema')) + ?.split('supabase_target_schema=')[1] // [Joshen] Can remove this once this restriction is removed const showIndexCreationNotice = isStagingLocal && !!project && project?.region !== 'us-east-1' @@ -143,7 +146,7 @@ export const CreateVectorTableSheet = ({ bucketName }: CreateVectorTableSheetPro const onSubmit: SubmitHandler = async (values) => { if (!project?.ref) return console.error('Project ref is required') - if (!bucketName) return + if (!bucketName) return console.error('Bucket name is required') try { await createVectorBucketTable({ @@ -161,13 +164,13 @@ export const CreateVectorTableSheet = ({ bucketName }: CreateVectorTableSheetPro } try { - if (wrapperInstance) { + if (wrapperInstance && !!schema) { await importForeignSchema({ projectRef: project.ref, connectionString: project?.connectionString, serverName: wrapperInstance.server_name, - sourceSchema: getVectorBucketFDWSchemaName(bucketName), - targetSchema: getVectorBucketFDWSchemaName(bucketName), + sourceSchema: schema, + targetSchema: schema, schemaOptions: [`bucket_name '${bucketName}'`], }) } diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/DeleteVectorTableModal.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/DeleteVectorTableModal.tsx index 6c4902c68a519..352fbcc6e20b8 100644 --- a/apps/studio/components/interfaces/Storage/VectorBuckets/DeleteVectorTableModal.tsx +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/DeleteVectorTableModal.tsx @@ -1,11 +1,12 @@ import { toast } from 'sonner' +import { useParams } from 'common' import { useFDWDropForeignTableMutation } from 'data/fdw/fdw-drop-foreign-table-mutation' import { useVectorBucketIndexDeleteMutation } from 'data/storage/vector-bucket-index-delete-mutation' import { VectorBucketIndex } from 'data/storage/vector-buckets-indexes-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' -import { getVectorBucketFDWSchemaName } from './VectorBuckets.utils' +import { useS3VectorsWrapperInstance } from './useS3VectorsWrapperInstance' interface DeleteVectorTableModalProps { visible: boolean @@ -18,22 +19,34 @@ export const DeleteVectorTableModal = ({ table, onClose, }: DeleteVectorTableModalProps) => { + const { bucketId } = useParams() const { data: project } = useSelectedProjectQuery() - const { mutate: deleteForeignTable } = useFDWDropForeignTableMutation({ + const { data: wrapperInstance } = useS3VectorsWrapperInstance({ bucketId }) + const foreignTable = wrapperInstance?.tables?.find((x) => x.name === table?.indexName) + + const { mutateAsync: deleteForeignTable } = useFDWDropForeignTableMutation({ onError: () => {}, }) const { mutate: deleteIndex, isPending: isDeleting } = useVectorBucketIndexDeleteMutation({ onSuccess: (_, vars) => { - deleteForeignTable({ - projectRef: project?.ref, - connectionString: project?.connectionString, - schemaName: getVectorBucketFDWSchemaName(vars.bucketName), - tableName: vars.indexName, - }) - toast.success(`Table "${vars.indexName}" deleted successfully`) - onClose() + try { + if (!!foreignTable) { + deleteForeignTable({ + projectRef: project?.ref, + connectionString: project?.connectionString, + schemaName: foreignTable.schema, + tableName: foreignTable.name, + }) + } + toast.success(`Table "${vars.indexName}" deleted successfully`) + onClose() + } catch (error: any) { + toast.success( + `Table "${vars.indexName}" deleted successfully, but its corresponding foreign table failed to clean up: ${error.message}` + ) + } }, }) diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/InitializeForeignSchemaDialog.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/InitializeForeignSchemaDialog.tsx new file mode 100644 index 0000000000000..bb79a037b7986 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/InitializeForeignSchemaDialog.tsx @@ -0,0 +1,175 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { parseAsBoolean, useQueryState } from 'nuqs' +import { useState } from 'react' +import { SubmitHandler, useForm } from 'react-hook-form' +import { toast } from 'sonner' +import z from 'zod' + +import { useQueryClient } from '@tanstack/react-query' +import { useParams } from 'common' +import { formatWrapperTables } from 'components/interfaces/Integrations/Wrappers/Wrappers.utils' +import { DocsButton } from 'components/ui/DocsButton' +import { useSchemaCreateMutation } from 'data/database/schema-create-mutation' +import { useSchemasQuery } from 'data/database/schemas-query' +import { useFDWImportForeignSchemaMutation } from 'data/fdw/fdw-import-foreign-schema-mutation' +import { useFDWUpdateMutation } from 'data/fdw/fdw-update-mutation' +import { fdwKeys } from 'data/fdw/keys' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { DOCS_URL } from 'lib/constants' +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogSection, + DialogSectionSeparator, + DialogTitle, + DialogTrigger, + Form_Shadcn_, + FormField_Shadcn_, + Input_Shadcn_, +} from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { getDecryptedParameters } from '../../Storage.utils' +import { useS3VectorsWrapperInstance } from '../useS3VectorsWrapperInstance' + +// Create foreign tables for vector bucket +export const InitializeForeignSchemaDialog = () => { + const queryClient = useQueryClient() + const { ref: projectRef, bucketId } = useParams() + const { data: project } = useSelectedProjectQuery() + const { data: schemas } = useSchemasQuery({ projectRef }) + + const [isOpen, setIsOpen] = useQueryState('initForeignSchema', parseAsBoolean.withDefault(false)) + const [isCreating, setIsCreating] = useState(false) + + const { data: wrapperInstance, meta: wrapperMeta } = useS3VectorsWrapperInstance({ bucketId }) + + const FormSchema = z.object({ + schema: z + .string() + .trim() + .min(1, 'Schema name is required') + .refine((val) => !schemas?.find((s) => s.name === val), { + message: 'This schema already exists. Please specify a unique schema name.', + }), + }) + + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { schema: '' }, + }) + + const { mutateAsync: createSchema } = useSchemaCreateMutation() + const { mutateAsync: importForeignSchema } = useFDWImportForeignSchemaMutation() + const { mutateAsync: updateFDW } = useFDWUpdateMutation() + + const onSubmit: SubmitHandler> = async (values) => { + if (!projectRef) return console.error('Project ref is required') + if (!bucketId) return console.error('Bucket ID is required') + if (!wrapperInstance) return console.error('Wrapper instance is required') + + try { + setIsCreating(true) + + await createSchema({ + projectRef, + connectionString: project?.connectionString, + name: values.schema, + }) + + const serverOptions = await getDecryptedParameters({ + ref: project?.ref, + connectionString: project?.connectionString ?? undefined, + wrapper: wrapperInstance, + wrapperMeta, + }) + + const wrapperTables = formatWrapperTables(wrapperInstance, wrapperMeta) + + await updateFDW({ + projectRef: project?.ref, + connectionString: project?.connectionString, + wrapper: wrapperInstance, + wrapperMeta: wrapperMeta, + formState: { + wrapper_name: wrapperInstance.name, + server_name: wrapperInstance.server_name, + supabase_target_schema: values.schema, + ...serverOptions, + }, + tables: wrapperTables, + skipInvalidation: true, + }) + + await importForeignSchema({ + projectRef, + connectionString: project?.connectionString, + serverName: wrapperInstance.server_name, + sourceSchema: values.schema, + targetSchema: values.schema, + schemaOptions: [`bucket_name '${bucketId}'`], + }) + + toast.success( + `Successfully created "${values.schema}" schema! Data from tables in this bucket can now be queried from there.` + ) + setIsOpen(false) + + await queryClient.invalidateQueries({ + queryKey: fdwKeys.list(projectRef), + refetchType: 'all', + }) + } catch (error: any) { + toast.error(`Failed to expose tables: ${error.message}`) + } finally { + setIsCreating(false) + } + } + + return ( +

+ + + + + + + + Query this vector bucket from Postgres + + + +

+ Data from vector tables can be queried from Postgres with the S3 Vectors Wrapper. + Create a Postgres schema to expose tables from the "{bucketId}" bucket as foreign + tables. +

+ ( + + + + )} + /> +
+ + +
+ + +
+
+ +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketCallouts.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketCallouts.tsx index 33a2d025637f8..c4c45626c75ea 100644 --- a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketCallouts.tsx +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketCallouts.tsx @@ -5,13 +5,10 @@ import { WrapperMeta } from 'components/interfaces/Integrations/Wrappers/Wrapper import { ScaffoldSection } from 'components/layouts/Scaffold' import { InlineLink } from 'components/ui/InlineLink' import { DatabaseExtension } from 'data/database-extensions/database-extensions-query' -import { useSchemaCreateMutation } from 'data/database/schema-create-mutation' import { useS3VectorsWrapperCreateMutation } from 'data/storage/s3-vectors-wrapper-create-mutation' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { DOCS_URL } from 'lib/constants' import { Button } from 'ui' import { Admonition } from 'ui-patterns/admonition' -import { getVectorBucketFDWSchemaName } from '../VectorBuckets.utils' export const ExtensionNotInstalled = ({ projectRef, @@ -100,20 +97,13 @@ export const ExtensionNeedsUpgrade = ({ } export const WrapperMissing = ({ bucketName }: { bucketName?: string }) => { - const { data: project } = useSelectedProjectQuery() const { mutateAsync: createS3VectorsWrapper, isPending: isCreatingS3VectorsWrapper } = useS3VectorsWrapperCreateMutation() - const { mutateAsync: createSchema, isPending: isCreatingSchema } = useSchemaCreateMutation() const onSetupWrapper = async () => { if (!bucketName) return console.error('Bucket name is required') try { await createS3VectorsWrapper({ bucketName }) - await createSchema({ - projectRef: project?.ref, - connectionString: project?.connectionString, - name: getVectorBucketFDWSchemaName(bucketName), - }) } catch (error) { toast.error( `Failed to install wrapper: ${error instanceof Error ? error.message : 'Unknown error'}` @@ -121,13 +111,11 @@ export const WrapperMissing = ({ bucketName }: { bucketName?: string }) => { } } - const isLoading = isCreatingS3VectorsWrapper || isCreatingSchema - return (

The S3 Vectors Wrapper integration is required in order to query vector tables.

-
diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketTableExamplesSheet.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketTableExamplesSheet.tsx index e212a75b6824f..9ececaf5dff6c 100644 --- a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketTableExamplesSheet.tsx +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketTableExamplesSheet.tsx @@ -1,9 +1,11 @@ -import { BookOpen, ChevronDown, ListPlus } from 'lucide-react' +import { ChevronDown, ListPlus } from 'lucide-react' import Link from 'next/link' +import { parseAsBoolean, useQueryState } from 'nuqs' import { useState } from 'react' import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' +import { DocsButton } from 'components/ui/DocsButton' import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { VectorBucketIndex } from 'data/storage/vector-buckets-indexes-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' @@ -27,7 +29,8 @@ import { SheetTitle, SheetTrigger, } from 'ui' -import { getVectorBucketFDWSchemaName } from '../VectorBuckets.utils' +import { Admonition } from 'ui-patterns' +import { useS3VectorsWrapperInstance } from '../useS3VectorsWrapperInstance' interface VectorBucketTableExamplesSheetProps { index: VectorBucketIndex @@ -35,16 +38,22 @@ interface VectorBucketTableExamplesSheetProps { export const VectorBucketTableExamplesSheet = ({ index }: VectorBucketTableExamplesSheetProps) => { const metadataKeys = index.metadataConfiguration?.nonFilterableMetadataKeys ?? [] + const [open, setOpen] = useState(false) const [language, setLanguage] = useState<'javascript' | 'sql'>('sql') const [showLanguage, setShowLanguage] = useState(false) + const [_, setOpenImportForeignSchemaDialog] = useQueryState( + 'initForeignSchema', + parseAsBoolean.withDefault(false) + ) + const updateLanguage = (value: 'javascript' | 'sql') => { setLanguage(value) setShowLanguage(false) } return ( - + {/* Move into overflow menu after vectors added */} + {language === 'javascript' ? ( + ) : !foreignTable ? ( + + Query from Postgres + + } + /> ) : ( <> -
+
{state === 'not-installed' && ( @@ -210,6 +215,10 @@ export const VectorBucketDetails = () => { const id = `index-${idx}` const name = index.indexName + const foreignTable = wrapperInstance?.tables?.find( + (x) => x.name === index.indexName + ) + return ( {name} @@ -232,15 +241,14 @@ export const VectorBucketDetails = () => { /> - {wrapperInstance ? ( + {!!foreignTable ? ( <> - {/* TODO: Proper URL for sql editor */} e.stopPropagation()} > { className="flex items-center space-x-2" asChild > - {/* TODO: Proper URL for table editor */} e.stopPropagation()} > {

View in Table Editor

+ ) : null} { return `${snakeCase(bucketId)}_keys` } -export const getVectorBucketFDWSchemaName = (bucketId: string) => { - return `fdw_vector_${snakeCase(bucketId)}` +export const getVectorBucketFDWServerName = (bucketId: string) => { + return `${getVectorBucketFDWName(bucketId)}_server` } export const getVectorBucketFDWName = (bucketId: string) => { diff --git a/apps/studio/data/fdw/fdw-update-mutation.ts b/apps/studio/data/fdw/fdw-update-mutation.ts index df84fe0ee4661..7ef5cefb309c4 100644 --- a/apps/studio/data/fdw/fdw-update-mutation.ts +++ b/apps/studio/data/fdw/fdw-update-mutation.ts @@ -22,6 +22,7 @@ export type FDWUpdateVariables = { [k: string]: string } tables: any[] + skipInvalidation?: boolean } export const getUpdateFDWSql = ({ @@ -77,14 +78,16 @@ export const useFDWUpdateMutation = ({ return useMutation({ mutationFn: (vars) => updateFDW(vars), async onSuccess(data, variables, context) { - const { projectRef } = variables + const { projectRef, skipInvalidation = false } = variables - await Promise.all([ - queryClient.invalidateQueries({ queryKey: fdwKeys.list(projectRef), refetchType: 'all' }), - queryClient.invalidateQueries({ queryKey: entityTypeKeys.list(projectRef) }), - queryClient.invalidateQueries({ queryKey: foreignTableKeys.list(projectRef) }), - queryClient.invalidateQueries({ queryKey: vaultSecretsKeys.list(projectRef) }), - ]) + if (!skipInvalidation) { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: fdwKeys.list(projectRef), refetchType: 'all' }), + queryClient.invalidateQueries({ queryKey: entityTypeKeys.list(projectRef) }), + queryClient.invalidateQueries({ queryKey: foreignTableKeys.list(projectRef) }), + queryClient.invalidateQueries({ queryKey: vaultSecretsKeys.list(projectRef) }), + ]) + } await onSuccess?.(data, variables, context) }, diff --git a/apps/studio/data/fdw/fdws-query.ts b/apps/studio/data/fdw/fdws-query.ts index 33a8b426fe051..9b7f249341fa5 100644 --- a/apps/studio/data/fdw/fdws-query.ts +++ b/apps/studio/data/fdw/fdws-query.ts @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/react-query' +import { UseCustomQueryOptions } from 'types' import { executeSql, ExecuteSqlError } from '../sql/execute-sql-query' import { fdwKeys } from './keys' -import { UseCustomQueryOptions } from 'types' export const getFDWsSql = () => { const sql = /* SQL */ ` diff --git a/apps/studio/data/storage/s3-vectors-wrapper-create-mutation.ts b/apps/studio/data/storage/s3-vectors-wrapper-create-mutation.ts index 81e6d49714c8a..2719fc70bb4fe 100644 --- a/apps/studio/data/storage/s3-vectors-wrapper-create-mutation.ts +++ b/apps/studio/data/storage/s3-vectors-wrapper-create-mutation.ts @@ -4,7 +4,7 @@ import { WRAPPERS } from 'components/interfaces/Integrations/Wrappers/Wrappers.c import { getVectorURI } from 'components/interfaces/Storage/StorageSettings/StorageSettings.utils' import { getVectorBucketFDWName, - getVectorBucketFDWSchemaName, + getVectorBucketFDWServerName, getVectorBucketS3KeyName, } from 'components/interfaces/Storage/VectorBuckets/VectorBuckets.utils' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' @@ -39,6 +39,7 @@ export const useS3VectorsWrapperCreateMutation = () => { }) const wrapperName = getVectorBucketFDWName(bucketName) + const serverName = getVectorBucketFDWServerName(bucketName) const params: FDWCreateVariables = { projectRef: project?.ref, @@ -46,12 +47,11 @@ export const useS3VectorsWrapperCreateMutation = () => { wrapperMeta: wrapperMeta!, formState: { wrapper_name: wrapperName, - server_name: `${wrapperName}_server`, + server_name: serverName, vault_access_key_id: createS3KeyData?.access_key, vault_secret_access_key: createS3KeyData?.secret_key, aws_region: settings!.region, endpoint_url: getVectorURI(project?.ref ?? '', protocol, endpoint), - supabase_target_schema: getVectorBucketFDWSchemaName(bucketName), }, mode: 'skip', tables: [], diff --git a/apps/studio/pages/project/[ref]/storage/vectors/buckets/[bucketId].tsx b/apps/studio/pages/project/[ref]/storage/vectors/buckets/[bucketId].tsx index 31e01d4f394e1..08c21d744c71d 100644 --- a/apps/studio/pages/project/[ref]/storage/vectors/buckets/[bucketId].tsx +++ b/apps/studio/pages/project/[ref]/storage/vectors/buckets/[bucketId].tsx @@ -6,7 +6,7 @@ import { useParams } from 'common' import { BUCKET_TYPES } from 'components/interfaces/Storage/Storage.constants' import { useSelectedVectorBucket } from 'components/interfaces/Storage/VectorBuckets/useSelectedVectorBuckets' import { VectorBucketDetails } from 'components/interfaces/Storage/VectorBuckets/VectorBucketDetails' -import DefaultLayout from 'components/layouts/DefaultLayout' +import { DefaultLayout } from 'components/layouts/DefaultLayout' import { PageLayout } from 'components/layouts/PageLayout/PageLayout' import StorageLayout from 'components/layouts/StorageLayout/StorageLayout' import { DocsButton } from 'components/ui/DocsButton' From 27188c147ce24287490b9c2ca51ee31f5486fa87 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 12 Dec 2025 16:07:36 +0800 Subject: [PATCH 3/9] Support creating multiple publishable keys, and deleting publishable keys (#41186) * Support creating multiple publishable keys, and deleting publishable keys * FIx types * Smol * Smol fix * Address issues * Update comment * Replace all usage of useApiKeysVisiblity for checking permissions to just call useAsyncCheckPermissions directly * Clean up and deprecate useApiKeysVisibility hook * ADdress --- .../interfaces/APIKeys/APIKeyDeleteDialog.tsx | 2 +- .../interfaces/APIKeys/APIKeyRow.tsx | 12 +- .../interfaces/APIKeys/ApiKeyPill.tsx | 7 +- .../APIKeys/ApiKeysIllustrations.tsx | 63 +---- .../APIKeys/CreateNewAPIKeysButton.tsx | 28 +-- .../APIKeys/CreatePublishableAPIKeyDialog.tsx | 36 +-- .../APIKeys/CreateSecretAPIKeyDialog.tsx | 34 ++- .../interfaces/APIKeys/PublishableAPIKeys.tsx | 235 +++++++++--------- .../interfaces/APIKeys/SecretAPIKeys.tsx | 33 ++- .../APIKeys/hooks/useApiKeysVisibility.ts | 57 ----- .../interfaces/App/CommandMenu/ApiKeys.tsx | 9 +- .../Database/Hooks/FormContents.tsx | 5 +- .../Database/Hooks/HTTPRequestFields.tsx | 5 +- .../DestinationPanel/DestinationPanel.tsx | 5 +- .../DestinationPanelFields.tsx | 10 +- .../interfaces/Docs/Authentication.tsx | 5 +- .../interfaces/Docs/LangSelector.tsx | 5 +- .../EdgeFunctionDetails.tsx | 3 +- .../EdgeFunctionTesterSheet.tsx | 5 +- .../Functions/TerminalInstructions.tsx | 5 +- .../Home/NewProjectPanel/APIKeys.tsx | 5 +- .../CronJobs/HttpHeaderFieldsSection.tsx | 5 +- .../Integrations/GraphQL/GraphiQLTab.tsx | 5 +- .../jwt-secret-keys-table/index.tsx | 8 +- .../interfaces/JwtSecrets/jwt-settings.tsx | 3 +- .../ProjectAPIDocs/Content/Introduction.tsx | 9 +- .../ProjectAPIDocs/ProjectAPIDocs.tsx | 5 +- .../Inspector/RealtimeTokensPopover.tsx | 5 +- .../ConnectTablesDialog.tsx | 5 +- .../layouts/APIKeys/APIKeysLayout.tsx | 3 + apps/studio/components/ui/AlertError.tsx | 1 - .../ProjectSettings/ToggleLegacyApiKeys.tsx | 3 +- .../data/api-keys/api-key-create-mutation.ts | 12 +- .../api-keys/{[id] => }/api-key-id-query.ts | 2 +- .../{[id] => }/api-key-id-update-mutation.ts | 2 +- .../iceberg-wrapper-create-mutation.ts | 3 +- apps/studio/hooks/analytics/useLogsQuery.tsx | 2 +- .../hooks/misc/useQueryStateWithSelect.ts | 2 +- .../project/[ref]/settings/api-keys/index.tsx | 30 ++- 39 files changed, 298 insertions(+), 376 deletions(-) delete mode 100644 apps/studio/components/interfaces/APIKeys/hooks/useApiKeysVisibility.ts rename apps/studio/data/api-keys/{[id] => }/api-key-id-query.ts (97%) rename apps/studio/data/api-keys/{[id] => }/api-key-id-update-mutation.ts (97%) diff --git a/apps/studio/components/interfaces/APIKeys/APIKeyDeleteDialog.tsx b/apps/studio/components/interfaces/APIKeys/APIKeyDeleteDialog.tsx index 7cacf303492d5..d1d7e4d0dd8ba 100644 --- a/apps/studio/components/interfaces/APIKeys/APIKeyDeleteDialog.tsx +++ b/apps/studio/components/interfaces/APIKeys/APIKeyDeleteDialog.tsx @@ -34,7 +34,7 @@ export const APIKeyDeleteDialog = ({ apiKey, setKeyToDelete }: APIKeyDeleteDialo }, }} > - Delete API key + Delete API key ) } diff --git a/apps/studio/components/interfaces/APIKeys/APIKeyRow.tsx b/apps/studio/components/interfaces/APIKeys/APIKeyRow.tsx index fc5a0e3847cca..e3edee5a371ac 100644 --- a/apps/studio/components/interfaces/APIKeys/APIKeyRow.tsx +++ b/apps/studio/components/interfaces/APIKeys/APIKeyRow.tsx @@ -21,15 +21,17 @@ export const APIKeyRow = ({ lastSeen, isDeleting, isDeleteModalOpen, - isLoadingLastSeen, + isLoadingLastSeen = false, + showLastSeen = true, onDelete, setKeyToDelete, }: { apiKey: Extract lastSeen?: { timestamp: number; relative: string } + showLastSeen?: boolean isDeleting: boolean isDeleteModalOpen: boolean - isLoadingLastSeen: boolean + isLoadingLastSeen?: boolean onDelete: () => void setKeyToDelete: (id: string | null) => void }) => { @@ -50,7 +52,7 @@ export const APIKeyRow = ({ mass: 1, }} > - +
{apiKey.name}
@@ -58,13 +60,14 @@ export const APIKeyRow = ({
+
- {showApiKeysLastUsed && ( + {showLastSeen && showApiKeysLastUsed && (
{isLoadingLastSeen ? ( @@ -104,6 +107,7 @@ export const APIKeyRow = ({
+ setKeyToDelete(null)} diff --git a/apps/studio/components/interfaces/APIKeys/ApiKeyPill.tsx b/apps/studio/components/interfaces/APIKeys/ApiKeyPill.tsx index 882258e4e6697..97263f3f3fa1f 100644 --- a/apps/studio/components/interfaces/APIKeys/ApiKeyPill.tsx +++ b/apps/studio/components/interfaces/APIKeys/ApiKeyPill.tsx @@ -1,6 +1,5 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useQueryClient } from '@tanstack/react-query' - import { Eye, EyeOff } from 'lucide-react' import { useEffect, useState } from 'react' import { toast } from 'sonner' @@ -8,7 +7,7 @@ import { toast } from 'sonner' import { InputVariants } from '@ui/components/shadcn/ui/input' import { useParams } from 'common' import CopyButton from 'components/ui/CopyButton' -import { useAPIKeyIdQuery } from 'data/api-keys/[id]/api-key-id-query' +import { useAPIKeyIdQuery } from 'data/api-keys/api-key-id-query' import { APIKeysData } from 'data/api-keys/api-keys-query' import { apiKeysKeys } from 'data/api-keys/keys' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' @@ -127,7 +126,9 @@ export function ApiKeyPill({ {show && data?.api_key ? data?.api_key.slice(15) : '••••••••••••••••'} ) : ( - {apiKey?.api_key} + + {apiKey.api_key} + )}
diff --git a/apps/studio/components/interfaces/APIKeys/ApiKeysIllustrations.tsx b/apps/studio/components/interfaces/APIKeys/ApiKeysIllustrations.tsx index 77dc6dd6112b6..98d7d9a76669a 100644 --- a/apps/studio/components/interfaces/APIKeys/ApiKeysIllustrations.tsx +++ b/apps/studio/components/interfaces/APIKeys/ApiKeysIllustrations.tsx @@ -1,11 +1,11 @@ -import { ExternalLink, Github } from 'lucide-react' +import { ExternalLink } from 'lucide-react' import { SupportCategories } from '@supabase/shared-types/out/constants' import { LOCAL_STORAGE_KEYS } from 'common' import { FeatureBanner } from 'components/ui/FeatureBanner' +import { InlineLink, InlineLinkClassName } from 'components/ui/InlineLink' import { APIKeysData } from 'data/api-keys/api-keys-query' import { - Button, Card, CardContent, Separator, @@ -19,7 +19,6 @@ import { import { SupportLink } from '../Support/SupportLink' import { ApiKeyPill } from './ApiKeyPill' import { CreateNewAPIKeysButton } from './CreateNewAPIKeysButton' -import { useApiKeysVisibility } from './hooks/useApiKeysVisibility' // Mock API Keys for demo const mockApiKeys = [ @@ -46,7 +45,7 @@ const mockApiKeys = [ /** * Reusable table illustration component */ -export const ApiKeysTableIllustration = () => { +const ApiKeysTableIllustration = () => { return ( @@ -90,7 +89,7 @@ export const ApiKeysTableIllustration = () => { /** * Reusable illustration with gradient overlay component */ -export const ApiKeysIllustrationWithOverlay = () => { +const ApiKeysIllustrationWithOverlay = () => { return ( <> {/* Gradient overlay - horizontal on desktop, vertical on mobile */} @@ -108,41 +107,7 @@ export const ApiKeysIllustrationWithOverlay = () => { ) } -/** - * "Coming Soon" banner for users who don't have the feature flag enabled - */ -export const ApiKeysComingSoonBanner = () => { - return ( - } bgAlt> -
-

New API keys are coming soon

-

- We're rolling out new API keys to better support your application needs. -

-
- -
-
-
- ) -} - -/** - * Create API Keys callout for users who have the feature flag enabled but no keys yet - */ export const ApiKeysCreateCallout = () => { - const { canInitApiKeys } = useApiKeysVisibility() - - if (!canInitApiKeys) return null - return ( } bgAlt>
@@ -158,15 +123,7 @@ export const ApiKeysCreateCallout = () => { ) } -/** - * Feedback banner for users who have API keys and the feature is rolled out to them - */ export const ApiKeysFeedbackBanner = () => { - const { hasApiKeys } = useApiKeysVisibility() - - // Don't show anything if not in rollout or if keys don't exist - if (!hasApiKeys) return null - return ( {

Your new API keys are here

We've updated our API keys to better support your application needs.{' '} - - Join the discussion on GitHub - + Join the discussion on GitHub +

@@ -194,7 +149,7 @@ export const ApiKeysFeedbackBanner = () => {

Having trouble with the new API keys?{' '} { const { ref: projectRef } = useParams() - const [createKeysDialogOpen, setCreateKeysDialogOpen] = useState(false) + const [isCreatingKeys, setIsCreatingKeys] = useState(false) + const [createKeysDialogOpen, setCreateKeysDialogOpen] = useState(false) - const { mutate: createAPIKey } = useAPIKeyCreateMutation() + const { mutateAsync: createAPIKey } = useAPIKeyCreateMutation() const handleCreateNewApiKeys = async () => { if (!projectRef) return @@ -27,20 +28,13 @@ export const CreateNewAPIKeysButton = () => { try { // Create publishable key - await createAPIKey({ - projectRef, - type: 'publishable', - name: 'default', - }) + await createAPIKey({ projectRef, type: 'publishable', name: 'default' }) // Create secret key - await createAPIKey({ - projectRef, - type: 'secret', - name: 'default', - }) + await createAPIKey({ projectRef, type: 'secret', name: 'default' }) setCreateKeysDialogOpen(false) + toast.success('Successfully created a new set of API keys!') } catch (error) { console.error('Failed to create API keys:', error) } finally { @@ -56,15 +50,15 @@ export const CreateNewAPIKeysButton = () => { Create new API keys This will create a default publishable key and a default secret key named{' '} - default. These keys are required to connect your application to your - Supabase project. + default. These keys are required + to connect your application to your Supabase project. Cancel - + diff --git a/apps/studio/components/interfaces/APIKeys/CreatePublishableAPIKeyDialog.tsx b/apps/studio/components/interfaces/APIKeys/CreatePublishableAPIKeyDialog.tsx index f195342a307b9..7c430bd6cf8cf 100644 --- a/apps/studio/components/interfaces/APIKeys/CreatePublishableAPIKeyDialog.tsx +++ b/apps/studio/components/interfaces/APIKeys/CreatePublishableAPIKeyDialog.tsx @@ -1,6 +1,11 @@ import { zodResolver } from '@hookform/resolvers/zod' -import { useState } from 'react' +import { Plus } from 'lucide-react' +import { useParams } from 'next/navigation' +import { parseAsString, useQueryState } from 'nuqs' import { SubmitHandler, useForm } from 'react-hook-form' +import * as z from 'zod' + +import { useAPIKeyCreateMutation } from 'data/api-keys/api-key-create-mutation' import { Button, Dialog, @@ -18,10 +23,6 @@ import { Input_Shadcn_, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import * as z from 'zod' - -import { useAPIKeyCreateMutation } from 'data/api-keys/api-key-create-mutation' -import { useParams } from 'next/navigation' const FORM_ID = 'create-publishable-api-key' const SCHEMA = z.object({ @@ -33,16 +34,22 @@ export interface CreatePublishableAPIKeyDialogProps { projectRef: string } -function CreatePublishableAPIKeyDialog() { +export const CreatePublishableAPIKeyDialog = () => { const params = useParams() const projectRef = params?.ref as string - const [visible, setVisible] = useState(false) + const [visible, setVisible] = useQueryState( + 'new', + parseAsString.withDefault('').withOptions({ history: 'push', clearOnDefault: true }) + ) - const onClose = (value: boolean) => { - setVisible(value) + const onOpenChange = (value: boolean) => { + if (value) setVisible('publishable') + else setVisible('') } + const defaultValues = { name: '', description: '' } + const form = useForm>({ resolver: zodResolver(SCHEMA), defaultValues: { @@ -63,17 +70,18 @@ function CreatePublishableAPIKeyDialog() { }, { onSuccess: () => { - onClose(false) + form.reset(defaultValues) + onOpenChange(false) }, } ) } return ( -

+ - @@ -135,5 +143,3 @@ function CreatePublishableAPIKeyDialog() { ) } - -export default CreatePublishableAPIKeyDialog diff --git a/apps/studio/components/interfaces/APIKeys/CreateSecretAPIKeyDialog.tsx b/apps/studio/components/interfaces/APIKeys/CreateSecretAPIKeyDialog.tsx index 4eb1879425701..b44779eaf5651 100644 --- a/apps/studio/components/interfaces/APIKeys/CreateSecretAPIKeyDialog.tsx +++ b/apps/studio/components/interfaces/APIKeys/CreateSecretAPIKeyDialog.tsx @@ -1,7 +1,12 @@ import { zodResolver } from '@hookform/resolvers/zod' -import { parseAsBoolean, useQueryState } from 'nuqs' +import { Plus, ShieldCheck } from 'lucide-react' +import { parseAsString, useQueryState } from 'nuqs' import { type SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' +import * as z from 'zod' + +import { useParams } from 'common' +import { useAPIKeyCreateMutation } from 'data/api-keys/api-key-create-mutation' import { Alert_Shadcn_, AlertDescription_Shadcn_, @@ -22,11 +27,6 @@ import { Input_Shadcn_, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import * as z from 'zod' - -import { useParams } from 'common' -import { useAPIKeyCreateMutation } from 'data/api-keys/api-key-create-mutation' -import { Plus, ShieldCheck } from 'lucide-react' const NAME_SCHEMA = z .string() @@ -45,23 +45,22 @@ const SCHEMA = z.object({ description: z.string().max(256, "Description shouldn't be too long").trim(), }) -const CreateSecretAPIKeyDialog = () => { +export const CreateSecretAPIKeyDialog = () => { const { ref: projectRef } = useParams() const [visible, setVisible] = useQueryState( 'new', - parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true }) + parseAsString.withDefault('').withOptions({ history: 'push', clearOnDefault: true }) ) - const onClose = (value: boolean) => { - setVisible(value) + const onOpenChange = (value: boolean) => { + if (value) setVisible('secret') + else setVisible('') } + const defaultValues = { name: '', description: '' } const form = useForm>({ resolver: zodResolver(SCHEMA), - defaultValues: { - name: '', - description: '', - }, + defaultValues, }) const { mutate: createAPIKey, isPending: isCreatingAPIKey } = useAPIKeyCreateMutation() @@ -77,14 +76,15 @@ const CreateSecretAPIKeyDialog = () => { { onSuccess: (data) => { toast.success(`Your secret API key ${data.prefix}... is ready.`) - onClose(false) + form.reset(defaultValues) + onOpenChange(false) }, } ) } return ( - + ) } - -export default CreateSecretAPIKeyDialog diff --git a/apps/studio/components/interfaces/APIKeys/PublishableAPIKeys.tsx b/apps/studio/components/interfaces/APIKeys/PublishableAPIKeys.tsx index 63605c60d5118..3870dea6bab36 100644 --- a/apps/studio/components/interfaces/APIKeys/PublishableAPIKeys.tsx +++ b/apps/studio/components/interfaces/APIKeys/PublishableAPIKeys.tsx @@ -1,146 +1,147 @@ -import { useMemo } from 'react' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useMemo, useRef } from 'react' +import { toast } from 'sonner' -import { InputVariants } from '@ui/components/shadcn/ui/input' import { useParams } from 'common' -import CopyButton from 'components/ui/CopyButton' +import { AlertError } from 'components/ui/AlertError' import { FormHeader } from 'components/ui/Forms/FormHeader' -import { useAPIKeysQuery } from 'data/api-keys/api-keys-query' +import { NoPermission } from 'components/ui/NoPermission' +import { useAPIKeyDeleteMutation } from 'data/api-keys/api-key-delete-mutation' +import { APIKeysData, useAPIKeysQuery } from 'data/api-keys/api-keys-query' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { handleErrorOnDelete, useQueryStateWithSelect } from 'hooks/misc/useQueryStateWithSelect' import { - cn, - EyeOffIcon, - Input_Shadcn_, - Skeleton, - Tooltip, - TooltipContent, - TooltipTrigger, - WarningIcon, + Card, + Table, + TableBody, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, } from 'ui' -import { useApiKeysVisibility } from './hooks/useApiKeysVisibility' - -// to add in later with follow up PR -// import CreatePublishableAPIKeyDialog from './CreatePublishableAPIKeyDialog' -// to add in later with follow up PR -// import ShowPublicJWTsDialogComposer from '../JwtSecrets/ShowPublicJWTsDialogComposer' +import { Admonition, GenericSkeletonLoader } from 'ui-patterns' +import { APIKeyRow } from './APIKeyRow' +import { CreatePublishableAPIKeyDialog } from './CreatePublishableAPIKeyDialog' export const PublishableAPIKeys = () => { const { ref: projectRef } = useParams() + const { can: canReadAPIKeys, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( + PermissionAction.SECRETS_READ, + '*' + ) - const { canReadAPIKeys, isLoading: isLoadingVisibility } = useApiKeysVisibility() const { - data: apiKeysData, - isPending: isLoadingApiKeys, + data: apiKeysData = [], error, + isPending: isLoadingApiKeys, + isError: isErrorApiKeys, } = useAPIKeysQuery({ projectRef, reveal: false }, { enabled: canReadAPIKeys }) + const newApiKeys = useMemo( + () => apiKeysData.filter(({ type }) => type === 'publishable' || type === 'secret') ?? [], + [apiKeysData] + ) + const hasApiKeys = newApiKeys.length > 0 + const publishableApiKeys = useMemo( - () => apiKeysData?.filter(({ type }) => type === 'publishable') ?? [], + () => + apiKeysData?.filter( + (key): key is Extract => + key.type === 'publishable' + ) ?? [], [apiKeysData] ) - // The default publisahble key will always be the first one - const apiKey = publishableApiKeys[0] + // Track the ID being deleted to exclude it from error checking + const deletingAPIKeyIdRef = useRef(null) + + const { value: apiKeyToDelete, setValue: setAPIKeyToDelete } = useQueryStateWithSelect({ + urlKey: 'deletePublishableKey', + select: (id: string) => (id ? publishableApiKeys?.find((key) => key.id === id) : undefined), + enabled: !!publishableApiKeys?.length, + onError: (_error, selectedId) => { + handleErrorOnDelete(deletingAPIKeyIdRef, selectedId, `API Key not found`) + }, + }) + + const { mutate: deleteAPIKey, isPending: isDeletingAPIKey } = useAPIKeyDeleteMutation({ + onSuccess: () => { + toast.success('Successfully deleted publishable key') + setAPIKeyToDelete(null) + }, + onError: () => { + deletingAPIKeyIdRef.current = null + }, + }) + + const onDeleteAPIKey = (apiKey: Extract) => { + if (!projectRef) return console.error('Project ref is required') + if (!apiKey.id) return console.error('API key ID is required') + deletingAPIKeyIdRef.current = apiKey.id + deleteAPIKey({ projectRef, id: apiKey.id }) + } return (
} /> -
-
-
- Publishable key -
- - - - - - - {!canReadAPIKeys - ? 'You need additional permissions to copy publishable keys' - : isLoadingApiKeys - ? 'Loading permissions...' - : 'Copy publishable key'} - - -
-
- {error && canReadAPIKeys ? ( -
-
Failed to load publishable key: {error?.message}
-
- ) : ( -
- The publishable key can be safely shared publicly -
- )} -
-
-
- ) -} -const ApiKeyInput = () => { - const { ref: projectRef } = useParams() + {!canReadAPIKeys && !isLoadingPermissions ? ( + + ) : isLoadingApiKeys || isLoadingPermissions ? ( + + ) : isErrorApiKeys ? ( + + ) : ( + + + + + Name + API Key + + + - const { canReadAPIKeys, isLoading: isPermissionsLoading } = useApiKeysVisibility() - const { - data: apiKeysData, - isPending: isApiKeysLoading, - error, - } = useAPIKeysQuery({ projectRef, reveal: false }, { enabled: canReadAPIKeys }) - - const publishableApiKeys = useMemo( - () => apiKeysData?.filter(({ type }) => type === 'publishable') ?? [], - [apiKeysData] - ) - // The default publisahble key will always be the first one - const apiKey = publishableApiKeys[0] + + {hasApiKeys && publishableApiKeys.length === 0 && ( + + + +

No publishable keys created yet

+
+
+
+ )} + {publishableApiKeys.map((apiKey) => ( + onDeleteAPIKey(apiKey)} + setKeyToDelete={setAPIKeyToDelete} + /> + ))} +
- const baseClasses = - 'flex-1 grow gap-1 rounded-full min-w-0 max-w-[200px] sm:max-w-[300px] md:max-w-[400px] lg:min-w-[24rem]' - const size = 'tiny' - - if (!canReadAPIKeys && !isPermissionsLoading) { - return ( -
- - You do not have permission to read API Key -
- ) - } - - if (isApiKeysLoading || isPermissionsLoading) { - return ( -
- -
- ) - } - - if (error) { - return ( -
- - Failed to load publishable key -
- ) - } - - return ( - + + + +

+ Publishable keys can be safely shared publicly +

+
+
+
+
+
+ )} + ) } diff --git a/apps/studio/components/interfaces/APIKeys/SecretAPIKeys.tsx b/apps/studio/components/interfaces/APIKeys/SecretAPIKeys.tsx index d4912561a0c72..eaaba239b28fa 100644 --- a/apps/studio/components/interfaces/APIKeys/SecretAPIKeys.tsx +++ b/apps/studio/components/interfaces/APIKeys/SecretAPIKeys.tsx @@ -1,16 +1,19 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import dayjs from 'dayjs' import { useMemo, useRef } from 'react' import { toast } from 'sonner' import { useFlag, useParams } from 'common' -import AlertError from 'components/ui/AlertError' +import { AlertError } from 'components/ui/AlertError' import { FormHeader } from 'components/ui/Forms/FormHeader' +import { NoPermission } from 'components/ui/NoPermission' import { useAPIKeyDeleteMutation } from 'data/api-keys/api-key-delete-mutation' import type { APIKeysData } from 'data/api-keys/api-keys-query' import { useAPIKeysQuery } from 'data/api-keys/api-keys-query' -import useLogsQuery from 'hooks/analytics/useLogsQuery' +import { useLogsQuery } from 'hooks/analytics/useLogsQuery' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { handleErrorOnDelete, useQueryStateWithSelect } from 'hooks/misc/useQueryStateWithSelect' -import { Card, EyeOffIcon } from 'ui' +import { Card } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { Table, @@ -20,8 +23,7 @@ import { TableRow, } from 'ui/src/components/shadcn/ui/table' import { APIKeyRow } from './APIKeyRow' -import CreateSecretAPIKeyDialog from './CreateSecretAPIKeyDialog' -import { useApiKeysVisibility } from './hooks/useApiKeysVisibility' +import { CreateSecretAPIKeyDialog } from './CreateSecretAPIKeyDialog' interface LastSeenData { [hash: string]: { timestamp: number; relative: string } @@ -67,8 +69,11 @@ function useLastSeen({ projectRef, enabled }: { projectRef: string; enabled?: bo export const SecretAPIKeys = () => { const { ref: projectRef } = useParams() + const { can: canReadAPIKeys, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( + PermissionAction.SECRETS_READ, + '*' + ) - const { canReadAPIKeys, isLoading: isLoadingPermissions } = useApiKeysVisibility() const { data: apiKeysData, error, @@ -96,7 +101,7 @@ export const SecretAPIKeys = () => { const deletingAPIKeyIdRef = useRef(null) const { setValue: setAPIKeyToDelete, value: apiKeyToDelete } = useQueryStateWithSelect({ - urlKey: 'delete', + urlKey: 'deleteSecretKey', select: (id: string) => (id ? secretApiKeys?.find((key) => key.id === id) : undefined), enabled: !!secretApiKeys?.length, onError: (_error, selectedId) => @@ -105,7 +110,7 @@ export const SecretAPIKeys = () => { const { mutate: deleteAPIKey, isPending: isDeletingAPIKey } = useAPIKeyDeleteMutation({ onSuccess: () => { - toast.success(`Successfully deleted API key`) + toast.success('Successfully deleted secret key') setAPIKeyToDelete(null) }, onError: () => { @@ -129,17 +134,7 @@ export const SecretAPIKeys = () => { /> {!canReadAPIKeys && !isLoadingPermissions ? ( - -
- -

- You do not have permission to read API Secret Keys -

-

- Contact your organization owner/admin to request access. -

-
-
+ ) : isLoadingApiKeys || isLoadingPermissions ? ( ) : isErrorApiKeys ? ( diff --git a/apps/studio/components/interfaces/APIKeys/hooks/useApiKeysVisibility.ts b/apps/studio/components/interfaces/APIKeys/hooks/useApiKeysVisibility.ts deleted file mode 100644 index 3147b1e3fc0b6..0000000000000 --- a/apps/studio/components/interfaces/APIKeys/hooks/useApiKeysVisibility.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useMemo } from 'react' - -import { useParams } from 'common' -import { useAPIKeysQuery } from 'data/api-keys/api-keys-query' -import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' - -interface ApiKeysVisibilityState { - hasApiKeys: boolean - isLoading: boolean - canReadAPIKeys: boolean - canInitApiKeys: boolean - shouldDisableUI: boolean -} - -/** - * A hook that provides visibility states for API keys UI components - * Consolidates logic for determining access to API keys functionality - */ -export function useApiKeysVisibility(): ApiKeysVisibilityState { - const { ref: projectRef } = useParams() - const { can: canReadAPIKeys, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( - PermissionAction.SECRETS_READ, - '*' - ) - - const { data: apiKeysData, isPending: isLoadingApiKeys } = useAPIKeysQuery( - { - projectRef, - reveal: false, - }, - { enabled: canReadAPIKeys } - ) - - const publishableApiKeys = useMemo( - () => apiKeysData?.filter(({ type }) => type === 'publishable') ?? [], - [apiKeysData] - ) - - // Check if there are any publishable API keys - // we don't check for secret keys because they can be optionally deleted - const hasApiKeys = publishableApiKeys.length > 0 - - // Can initialize API keys when in rollout, has permissions, not loading, and no API keys yet - const canInitApiKeys = canReadAPIKeys && !isLoadingApiKeys && !hasApiKeys - - // Disable UI for publishable keys and secrets keys if flag is not enabled OR no API keys created yet - const shouldDisableUI = !hasApiKeys - - return { - hasApiKeys, - isLoading: isLoadingPermissions || (canReadAPIKeys && isLoadingApiKeys), - canReadAPIKeys, - canInitApiKeys, - shouldDisableUI, - } -} diff --git a/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx b/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx index 0d408e1c83f85..e1ff48a069cb7 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx @@ -1,8 +1,9 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import { Key } from 'lucide-react' import { useMemo } from 'react' -import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility' import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Badge, copyToClipboard } from 'ui' import type { ICommand } from 'ui-patterns/CommandMenu' @@ -25,7 +26,11 @@ export function useApiKeysCommands() { const { data: project } = useSelectedProjectQuery() const ref = project?.ref || '_' - const { canReadAPIKeys } = useApiKeysVisibility() + const { can: canReadAPIKeys, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( + PermissionAction.SECRETS_READ, + '*' + ) + const { data: apiKeys } = useAPIKeysQuery( { projectRef: project?.ref, reveal: true }, { enabled: canReadAPIKeys } diff --git a/apps/studio/components/interfaces/Database/Hooks/FormContents.tsx b/apps/studio/components/interfaces/Database/Hooks/FormContents.tsx index b72c5594ea873..834e78113c2cd 100644 --- a/apps/studio/components/interfaces/Database/Hooks/FormContents.tsx +++ b/apps/studio/components/interfaces/Database/Hooks/FormContents.tsx @@ -1,12 +1,13 @@ import type { PostgresTable, PostgresTrigger } from '@supabase/postgres-meta' +import { PermissionAction } from '@supabase/shared-types/out/constants' import Image from 'next/legacy/image' import { MutableRefObject, useEffect } from 'react' import { useParams } from 'common' -import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility' import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection' import { useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { uuidv4 } from 'lib/helpers' import { Checkbox, Input, Listbox, Radio, SidePanel } from 'ui' @@ -51,7 +52,7 @@ export const FormContents = ({ const restUrl = project?.restUrl const restUrlTld = restUrl ? new URL(restUrl).hostname.split('.').pop() : 'co' - const { canReadAPIKeys } = useApiKeysVisibility() + const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') const { data: keys = [] } = useAPIKeysQuery( { projectRef: ref, reveal: true }, { enabled: canReadAPIKeys } diff --git a/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx b/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx index d15c54b571e9c..b27d8a35296e2 100644 --- a/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx +++ b/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx @@ -1,12 +1,13 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import { ChevronDown, Plus, X } from 'lucide-react' import Link from 'next/link' import { useParams } from 'common' -import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection' import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { uuidv4 } from 'lib/helpers' import { @@ -50,9 +51,9 @@ const HTTPRequestFields = ({ }: HTTPRequestFieldsProps) => { const { ref } = useParams() const { data: selectedProject } = useSelectedProjectQuery() + const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') const { data: functions } = useEdgeFunctionsQuery({ projectRef: ref }) - const { canReadAPIKeys } = useApiKeysVisibility() const { data: apiKeys } = useAPIKeysQuery( { projectRef: ref, reveal: true }, { enabled: canReadAPIKeys } diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx index 99781c99ccb6c..1bc865359a442 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx @@ -1,4 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod' +import { PermissionAction } from '@supabase/shared-types/out/constants' import { snakeCase } from 'lodash' import { useEffect, useMemo, useState } from 'react' import { useForm } from 'react-hook-form' @@ -6,7 +7,6 @@ import { toast } from 'sonner' import * as z from 'zod' import { useFlag, useParams } from 'common' -import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility' import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCheckPrimaryKeysExists } from 'data/database/primary-keys-exists-query' @@ -19,6 +19,7 @@ import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutati import { useUpdateDestinationPipelineMutation } from 'data/replication/update-destination-pipeline-mutation' import { useIcebergNamespaceCreateMutation } from 'data/storage/iceberg-namespace-create-mutation' import { useS3AccessKeyCreateMutation } from 'data/storage/s3-access-key-create-mutation' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { PipelineStatusRequestStatus, @@ -130,7 +131,7 @@ export const DestinationPanel = ({ pipelineId: existingDestination?.pipelineId, }) - const { canReadAPIKeys } = useApiKeysVisibility() + const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') const { data: apiKeys } = useAPIKeysQuery( { projectRef, reveal: true }, { enabled: canReadAPIKeys } diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanelFields.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanelFields.tsx index 48eaf6e32ecac..59b0e4f0b4b50 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanelFields.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanelFields.tsx @@ -1,16 +1,15 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import { Eye, EyeOff, Loader2 } from 'lucide-react' import { useState } from 'react' import type { UseFormReturn } from 'react-hook-form' import { useParams } from 'common' -import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility' import { InlineLink } from 'components/ui/InlineLink' import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' -import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useAnalyticsBucketsQuery } from 'data/storage/analytics-buckets-query' import { useIcebergNamespacesQuery } from 'data/storage/iceberg-namespaces-query' import { useStorageCredentialsQuery } from 'data/storage/s3-access-key-query' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Button, FormControl_Shadcn_, @@ -123,9 +122,8 @@ export const AnalyticsBucketFields = ({ const [showSecretAccessKey, setShowSecretAccessKey] = useState(false) const { ref: projectRef } = useParams() - const { data: project } = useSelectedProjectQuery() - const { canReadAPIKeys } = useApiKeysVisibility() + const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') const { data: apiKeys } = useAPIKeysQuery( { projectRef, reveal: true }, { enabled: canReadAPIKeys } @@ -133,8 +131,6 @@ export const AnalyticsBucketFields = ({ const { serviceKey } = getKeys(apiKeys) const serviceApiKey = serviceKey?.api_key ?? '' - const { data: projectSettings } = useProjectSettingsV2Query({ projectRef }) - const { data: keysData, isSuccess: isSuccessKeys, diff --git a/apps/studio/components/interfaces/Docs/Authentication.tsx b/apps/studio/components/interfaces/Docs/Authentication.tsx index 43ec3fefbab8d..88a063b844da8 100644 --- a/apps/studio/components/interfaces/Docs/Authentication.tsx +++ b/apps/studio/components/interfaces/Docs/Authentication.tsx @@ -1,9 +1,10 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import Link from 'next/link' import { useParams } from 'common' import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' -import { useApiKeysVisibility } from '../APIKeys/hooks/useApiKeysVisibility' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import CodeSnippet from './CodeSnippet' import Snippets from './Snippets' @@ -14,7 +15,7 @@ interface AuthenticationProps { const Authentication = ({ selectedLang, showApiKey }: AuthenticationProps) => { const { ref: projectRef } = useParams() - const { canReadAPIKeys } = useApiKeysVisibility() + const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') const { data: apiKeys } = useAPIKeysQuery({ projectRef }, { enabled: canReadAPIKeys }) const { data: settings } = useProjectSettingsV2Query({ projectRef }) diff --git a/apps/studio/components/interfaces/Docs/LangSelector.tsx b/apps/studio/components/interfaces/Docs/LangSelector.tsx index 4cf9325f25079..18558668c9394 100644 --- a/apps/studio/components/interfaces/Docs/LangSelector.tsx +++ b/apps/studio/components/interfaces/Docs/LangSelector.tsx @@ -1,9 +1,11 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import { Key } from 'lucide-react' import { useMemo } from 'react' import { useParams } from 'common' import type { showApiKey } from 'components/interfaces/Docs/Docs.types' import { useAPIKeysQuery } from 'data/api-keys/api-keys-query' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Button, DropdownMenu, @@ -15,7 +17,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from 'ui' -import { useApiKeysVisibility } from '../APIKeys/hooks/useApiKeysVisibility' const DEFAULT_KEY = { name: 'hide', key: 'SUPABASE_KEY' } @@ -34,7 +35,7 @@ export const LangSelector = ({ }: LangSelectorProps) => { const { ref: projectRef } = useParams() - const { canReadAPIKeys } = useApiKeysVisibility() + const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') const { data: apiKeys = [], isPending: isLoadingAPIKeys } = useAPIKeysQuery( { projectRef, diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx index 6d0c54d33f745..ac54e1404493c 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx @@ -10,7 +10,6 @@ import { toast } from 'sonner' import z from 'zod' import { useParams } from 'common' -import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility' import AlertError from 'components/ui/AlertError' import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' @@ -84,7 +83,7 @@ export const EdgeFunctionDetails = () => { '*' ) - const { canReadAPIKeys } = useApiKeysVisibility() + const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') const { data: apiKeys } = useAPIKeysQuery( { projectRef, diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx index a4c8c3b5b1f16..7819382c6fabf 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx @@ -1,11 +1,11 @@ import { zodResolver } from '@hookform/resolvers/zod' +import { PermissionAction } from '@supabase/shared-types/out/constants' import { Loader2, Plus, Send, X } from 'lucide-react' import { useState } from 'react' import { useFieldArray, useForm } from 'react-hook-form' import * as z from 'zod' import { useParams } from 'common' -import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility' import { RoleImpersonationPopover } from 'components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover' import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useSessionAccessTokenQuery } from 'data/auth/session-access-token-query' @@ -13,6 +13,7 @@ import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-co import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useEdgeFunctionTestMutation } from 'data/edge-functions/edge-function-test-mutation' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { IS_PLATFORM } from 'lib/constants' import { prettifyJSON } from 'lib/helpers' @@ -88,7 +89,7 @@ export const EdgeFunctionTesterSheet = ({ visible, onClose }: EdgeFunctionTester const [response, setResponse] = useState(null) const [error, setError] = useState(null) - const { canReadAPIKeys } = useApiKeysVisibility() + const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') const { data: apiKeys } = useAPIKeysQuery({ projectRef }, { enabled: canReadAPIKeys }) const { data: config } = useProjectPostgrestConfigQuery({ projectRef }) const { data: settings } = useProjectSettingsV2Query({ projectRef }) diff --git a/apps/studio/components/interfaces/Functions/TerminalInstructions.tsx b/apps/studio/components/interfaces/Functions/TerminalInstructions.tsx index 42d3abc5dcffb..648dca7ecc620 100644 --- a/apps/studio/components/interfaces/Functions/TerminalInstructions.tsx +++ b/apps/studio/components/interfaces/Functions/TerminalInstructions.tsx @@ -1,3 +1,4 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import { ExternalLink, Maximize2, Minimize2, Terminal } from 'lucide-react' import { useRouter } from 'next/router' import { ComponentPropsWithoutRef, ElementRef, forwardRef, useState } from 'react' @@ -9,6 +10,7 @@ import { useAccessTokensQuery } from 'data/access-tokens/access-tokens-query' import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { DOCS_URL } from 'lib/constants' import { Button, @@ -16,7 +18,6 @@ import { CollapsibleTrigger_Shadcn_, Collapsible_Shadcn_, } from 'ui' -import { useApiKeysVisibility } from '../APIKeys/hooks/useApiKeysVisibility' import type { Commands } from './Functions.types' interface TerminalInstructionsProps extends ComponentPropsWithoutRef { @@ -33,7 +34,7 @@ export const TerminalInstructions = forwardRef< const [showInstructions, setShowInstructions] = useState(!closable) const { data: tokens } = useAccessTokensQuery() - const { canReadAPIKeys } = useApiKeysVisibility() + const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') const { data: apiKeys } = useAPIKeysQuery({ projectRef }, { enabled: canReadAPIKeys }) const { data: settings } = useProjectSettingsV2Query({ projectRef }) const { data: customDomainData } = useCustomDomainsQuery({ projectRef }) diff --git a/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx b/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx index 878e535a38ac0..bf90128392f39 100644 --- a/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx +++ b/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx @@ -1,14 +1,15 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' import { AlertCircle, Loader } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' import { useParams } from 'common' -import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility' import Panel from 'components/ui/Panel' import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { Input, SimpleCodeBlock } from 'ui' @@ -55,7 +56,7 @@ export const APIKeys = () => { isPending: isProjectSettingsLoading, } = useProjectSettingsV2Query({ projectRef }) - const { canReadAPIKeys } = useApiKeysVisibility() + const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') const { data: apiKeys } = useAPIKeysQuery({ projectRef }, { enabled: canReadAPIKeys }) const { anonKey, serviceKey } = getKeys(apiKeys) diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx index e1c3b04232205..56c6a1edf9520 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx @@ -1,9 +1,10 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import { ChevronDown, Plus, Trash } from 'lucide-react' import { useFieldArray } from 'react-hook-form' import { useParams } from 'common' -import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility' import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Button, cn, @@ -33,7 +34,7 @@ export const HTTPHeaderFieldsSection = ({ variant }: HTTPHeaderFieldsSectionProp }) const { ref } = useParams() - const { canReadAPIKeys } = useApiKeysVisibility() + const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') const { data: apiKeys } = useAPIKeysQuery( { projectRef: ref, reveal: true }, { enabled: canReadAPIKeys } diff --git a/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx b/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx index 303779f0a3ffe..b9cc5d54af68b 100644 --- a/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx +++ b/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx @@ -1,15 +1,16 @@ import '@graphiql/react/dist/style.css' import { createGraphiQLFetcher, Fetcher } from '@graphiql/toolkit' +import { PermissionAction } from '@supabase/shared-types/out/constants' import { useTheme } from 'next-themes' import { useMemo } from 'react' import { toast } from 'sonner' import { useParams } from 'common' -import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility' import GraphiQL from 'components/interfaces/GraphQL/GraphiQL' import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useSessionAccessTokenQuery } from 'data/auth/session-access-token-query' import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { API_URL, IS_PLATFORM } from 'lib/constants' import { getRoleImpersonationJWT } from 'lib/role-impersonation' import { useGetImpersonatedRoleState } from 'state/role-impersonation-state' @@ -22,7 +23,7 @@ export const GraphiQLTab = () => { const { data: accessToken } = useSessionAccessTokenQuery({ enabled: IS_PLATFORM }) - const { canReadAPIKeys } = useApiKeysVisibility() + const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') const { data: apiKeys, isFetched } = useAPIKeysQuery( { projectRef, reveal: true }, { enabled: canReadAPIKeys } diff --git a/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/index.tsx b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/index.tsx index 61b05b06a0ca7..db94752f496ed 100644 --- a/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/index.tsx +++ b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/index.tsx @@ -1,10 +1,10 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import { AnimatePresence } from 'framer-motion' import { AlertCircle, RotateCw, Timer } from 'lucide-react' import { useMemo, useState } from 'react' import { toast } from 'sonner' import { useFlag, useParams } from 'common' -import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { TextConfirmModal } from 'components/ui/TextConfirmModalWrapper' import { useLegacyAPIKeysStatusQuery } from 'data/api-keys/legacy-api-keys-status-query' @@ -13,6 +13,7 @@ import { useJWTSigningKeyUpdateMutation } from 'data/jwt-signing-keys/jwt-signin import { JWTSigningKey, useJWTSigningKeysQuery } from 'data/jwt-signing-keys/jwt-signing-keys-query' import { useLegacyJWTSigningKeyCreateMutation } from 'data/jwt-signing-keys/legacy-jwt-signing-key-create-mutation' import { useLegacyJWTSigningKeyQuery } from 'data/jwt-signing-keys/legacy-jwt-signing-key-query' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { AlertDialog, @@ -58,7 +59,10 @@ export const JWTSecretKeysTable = () => { const [selectedKeyToUpdate, setSelectedKeyToUpdate] = useState() const [shownDialog, setShownDialog] = useState() - const { canReadAPIKeys, isLoading: isLoadingCanReadAPIKeys } = useApiKeysVisibility() + const { can: canReadAPIKeys, isLoading: isLoadingCanReadAPIKeys } = useAsyncCheckPermissions( + PermissionAction.SECRETS_READ, + '*' + ) const { data: signingKeys, isPending: isLoadingSigningKeys } = useJWTSigningKeysQuery( { projectRef, diff --git a/apps/studio/components/interfaces/JwtSecrets/jwt-settings.tsx b/apps/studio/components/interfaces/JwtSecrets/jwt-settings.tsx index 91001e5814778..47272a107c7ac 100644 --- a/apps/studio/components/interfaces/JwtSecrets/jwt-settings.tsx +++ b/apps/studio/components/interfaces/JwtSecrets/jwt-settings.tsx @@ -52,7 +52,6 @@ import { } from 'ui' import { Admonition } from 'ui-patterns/admonition' import { number, object } from 'yup' -import { useApiKeysVisibility } from '../APIKeys/hooks/useApiKeysVisibility' import { JWT_SECRET_UPDATE_ERROR_MESSAGES, JWT_SECRET_UPDATE_PROGRESS_MESSAGES, @@ -92,7 +91,7 @@ const JWTSettings = () => { const { mutateAsync: updateJwt, isPending: isSubmittingJwtSecretUpdateRequest } = useJwtSecretUpdateMutation() - const { canReadAPIKeys } = useApiKeysVisibility() + const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') const { data: legacyKey } = useLegacyJWTSigningKeyQuery( { projectRef, diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx index 0f17f7a6fbddd..5d0b089338479 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx +++ b/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx @@ -1,20 +1,21 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' +import { Copy } from 'lucide-react' +import { useEffect, useState } from 'react' import { Button, Input, copyToClipboard } from 'ui' -import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility' import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' -import { Copy } from 'lucide-react' -import { useEffect, useState } from 'react' import ContentSnippet from '../ContentSnippet' import { DOCS_CONTENT } from '../ProjectAPIDocs.constants' import type { ContentProps } from './Content.types' export const Introduction = ({ showKeys, language, apikey, endpoint }: ContentProps) => { const { ref } = useParams() - const { canReadAPIKeys } = useApiKeysVisibility() + const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') const { data: apiKeys } = useAPIKeysQuery({ projectRef: ref }, { enabled: canReadAPIKeys }) const { data } = useProjectSettingsV2Query({ projectRef: ref }) const { data: org } = useSelectedOrganizationQuery() diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.tsx index e206290a2f11e..55b2a4054c4c9 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.tsx +++ b/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.tsx @@ -1,3 +1,4 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import { useState } from 'react' import { Button, SidePanel } from 'ui' @@ -5,8 +6,8 @@ import { useParams } from 'common' import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useAppStateSnapshot } from 'state/app-state' -import { useApiKeysVisibility } from '../APIKeys/hooks/useApiKeysVisibility' import { Bucket } from './Content/Bucket' import { EdgeFunction } from './Content/EdgeFunction' import { EdgeFunctions } from './Content/EdgeFunctions' @@ -44,7 +45,7 @@ export const ProjectAPIDocs = () => { const [showKeys, setShowKeys] = useState(false) const language = snap.docsLanguage - const { canReadAPIKeys } = useApiKeysVisibility() + const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') const { data: apiKeys } = useAPIKeysQuery( { projectRef: ref }, { enabled: snap.showProjectApiDocs && canReadAPIKeys } diff --git a/apps/studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover.tsx b/apps/studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover.tsx index 6e6e23c37230d..2aedcb99c6a0f 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover.tsx +++ b/apps/studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover.tsx @@ -1,13 +1,14 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import { Dispatch, SetStateAction, useEffect, useRef } from 'react' import { toast } from 'sonner' import { useParams } from 'common' -import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility' import { RoleImpersonationPopover } from 'components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover' import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { getTemporaryAPIKey } from 'data/api-keys/temp-api-keys-query' import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { IS_PLATFORM } from 'lib/constants' import { getRoleImpersonationJWT } from 'lib/role-impersonation' @@ -24,7 +25,7 @@ export const RealtimeTokensPopover = ({ config, onChangeConfig }: RealtimeTokens const { data: org } = useSelectedOrganizationQuery() const snap = useRoleImpersonationStateSnapshot() - const { canReadAPIKeys } = useApiKeysVisibility() + const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') const { data: apiKeys } = useAPIKeysQuery( { projectRef: config.projectRef, diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/ConnectTablesDialog.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/ConnectTablesDialog.tsx index fd96c3cfc0cd9..62075f62eb89c 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/ConnectTablesDialog.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/ConnectTablesDialog.tsx @@ -1,4 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod' +import { PermissionAction } from '@supabase/shared-types/out/constants' import { AnimatePresence, motion } from 'framer-motion' import { Loader2, Plus } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' @@ -7,7 +8,6 @@ import { toast } from 'sonner' import z from 'zod' import { useFlag, useParams } from 'common' -import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility' import { useIsETLPrivateAlpha } from 'components/interfaces/Database/Replication/useIsETLPrivateAlpha' import { convertKVStringArrayToJson } from 'components/interfaces/Integrations/Wrappers/Wrappers.utils' import { ButtonTooltip } from 'components/ui/ButtonTooltip' @@ -21,6 +21,7 @@ import { useReplicationSourcesQuery } from 'data/replication/sources-query' import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation' import { useReplicationTablesQuery } from 'data/replication/tables-query' import { getDecryptedValues } from 'data/vault/vault-secret-decrypted-value-query' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Button, @@ -160,7 +161,7 @@ export const ConnectTablesDialogContent = ({ const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? []) const { data: projectSettings } = useProjectSettingsV2Query({ projectRef }) - const { canReadAPIKeys } = useApiKeysVisibility() + const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') const { data: apiKeys } = useAPIKeysQuery( { projectRef, reveal: true }, { enabled: canReadAPIKeys } diff --git a/apps/studio/components/layouts/APIKeys/APIKeysLayout.tsx b/apps/studio/components/layouts/APIKeys/APIKeysLayout.tsx index 8ee3779dcc50a..aa8c5db774877 100644 --- a/apps/studio/components/layouts/APIKeys/APIKeysLayout.tsx +++ b/apps/studio/components/layouts/APIKeys/APIKeysLayout.tsx @@ -3,6 +3,8 @@ import { ScaffoldContainer } from 'components/layouts/Scaffold' import { PropsWithChildren } from 'react' import { useParams } from 'common' +import { DocsButton } from 'components/ui/DocsButton' +import { DOCS_URL } from 'lib/constants' const ApiKeysLayout = ({ children }: PropsWithChildren) => { const { ref: projectRef } = useParams() @@ -25,6 +27,7 @@ const ApiKeysLayout = ({ children }: PropsWithChildren) => { title="API Keys" subtitle="Configure API keys to securely control access to your project" navigationItems={navigationItems} + secondaryActions={} > {children} diff --git a/apps/studio/components/ui/AlertError.tsx b/apps/studio/components/ui/AlertError.tsx index 49cbe8bc09d37..c37b2c79b7324 100644 --- a/apps/studio/components/ui/AlertError.tsx +++ b/apps/studio/components/ui/AlertError.tsx @@ -42,7 +42,6 @@ const ContactSupportButton = ({ } // [Joshen] To standardize the language for all error UIs - export const AlertError = ({ projectRef, subject, diff --git a/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx b/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx index d7bf50c212f9d..935c1082797e5 100644 --- a/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx +++ b/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx @@ -3,7 +3,6 @@ import { useState } from 'react' import { toast } from 'sonner' import { useParams } from 'common' -import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { TextConfirmModal } from 'components/ui/TextConfirmModalWrapper' import { useToggleLegacyAPIKeysMutation } from 'data/api-keys/legacy-api-key-toggle-mutation' @@ -31,7 +30,7 @@ export const ToggleLegacyApiKeysPanel = () => { const [isConfirmOpen, setIsConfirmOpen] = useState(false) const [isAppsWarningOpen, setIsAppsWarningOpen] = useState(false) - const { canReadAPIKeys } = useApiKeysVisibility() + const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') const { can: canUpdateAPIKeys, isSuccess: isPermissionsSuccess } = useAsyncCheckPermissions( PermissionAction.SECRETS_WRITE, '*' diff --git a/apps/studio/data/api-keys/api-key-create-mutation.ts b/apps/studio/data/api-keys/api-key-create-mutation.ts index 9c5562e2bf96f..3a9c307178022 100644 --- a/apps/studio/data/api-keys/api-key-create-mutation.ts +++ b/apps/studio/data/api-keys/api-key-create-mutation.ts @@ -9,17 +9,7 @@ export type APIKeyCreateVariables = { projectRef?: string name: string description?: string -} & ( - | { - type: 'publishable' - } - | { - type: 'secret' - // secret_jwt_template?: { // @mildtomato (Jonny) removed this field to reduce scope - // role: string - // } | null - } -) +} & ({ type: 'publishable' } | { type: 'secret' }) export async function createAPIKey(payload: APIKeyCreateVariables) { if (!payload.projectRef) throw new Error('projectRef is required') diff --git a/apps/studio/data/api-keys/[id]/api-key-id-query.ts b/apps/studio/data/api-keys/api-key-id-query.ts similarity index 97% rename from apps/studio/data/api-keys/[id]/api-key-id-query.ts rename to apps/studio/data/api-keys/api-key-id-query.ts index 1de58426adcdc..fcb6696147144 100644 --- a/apps/studio/data/api-keys/[id]/api-key-id-query.ts +++ b/apps/studio/data/api-keys/api-key-id-query.ts @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/react-query' import { get, handleError } from 'data/fetchers' import type { ResponseError, UseCustomQueryOptions } from 'types' -import { apiKeysKeys } from './../keys' +import { apiKeysKeys } from './keys' export interface APIKeyVariables { projectRef?: string diff --git a/apps/studio/data/api-keys/[id]/api-key-id-update-mutation.ts b/apps/studio/data/api-keys/api-key-id-update-mutation.ts similarity index 97% rename from apps/studio/data/api-keys/[id]/api-key-id-update-mutation.ts rename to apps/studio/data/api-keys/api-key-id-update-mutation.ts index 4fbbcb2064893..debc26412afed 100644 --- a/apps/studio/data/api-keys/[id]/api-key-id-update-mutation.ts +++ b/apps/studio/data/api-keys/api-key-id-update-mutation.ts @@ -3,7 +3,7 @@ import { toast } from 'sonner' import { useMutation, useQueryClient } from '@tanstack/react-query' import { handleError, patch } from 'data/fetchers' import type { ResponseError, UseCustomMutationOptions } from 'types' -import { apiKeysKeys } from '../keys' +import { apiKeysKeys } from './keys' export interface UpdateAPIKeybyIdVariables { projectRef?: string diff --git a/apps/studio/data/storage/iceberg-wrapper-create-mutation.ts b/apps/studio/data/storage/iceberg-wrapper-create-mutation.ts index de2a08e28dfda..e0cd094e623b7 100644 --- a/apps/studio/data/storage/iceberg-wrapper-create-mutation.ts +++ b/apps/studio/data/storage/iceberg-wrapper-create-mutation.ts @@ -1,6 +1,5 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility' import { WRAPPERS } from 'components/interfaces/Integrations/Wrappers/Wrappers.constants' import { getAnalyticsBucketFDWName, @@ -20,7 +19,7 @@ import { useS3AccessKeyCreateMutation } from './s3-access-key-create-mutation' export const useIcebergWrapperCreateMutation = () => { const { data: project } = useSelectedProjectQuery() - const { canReadAPIKeys } = useApiKeysVisibility() + const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') const { data: apiKeys } = useAPIKeysQuery( { projectRef: project?.ref, reveal: true }, { enabled: canReadAPIKeys } diff --git a/apps/studio/hooks/analytics/useLogsQuery.tsx b/apps/studio/hooks/analytics/useLogsQuery.tsx index cfe3f2b82a285..363cbe9c81cfd 100644 --- a/apps/studio/hooks/analytics/useLogsQuery.tsx +++ b/apps/studio/hooks/analytics/useLogsQuery.tsx @@ -31,7 +31,7 @@ export interface LogsQueryHook { enabled?: boolean } -const useLogsQuery = ( +export const useLogsQuery = ( projectRef: string, initialParams: Partial = {}, enabled = true diff --git a/apps/studio/hooks/misc/useQueryStateWithSelect.ts b/apps/studio/hooks/misc/useQueryStateWithSelect.ts index f6afa54d7ae47..46f7a0624ce3c 100644 --- a/apps/studio/hooks/misc/useQueryStateWithSelect.ts +++ b/apps/studio/hooks/misc/useQueryStateWithSelect.ts @@ -1,5 +1,5 @@ -import { MutableRefObject, useEffect, useMemo } from 'react' import { parseAsString, useQueryState } from 'nuqs' +import { MutableRefObject, useEffect, useMemo } from 'react' import { toast } from 'sonner' /** diff --git a/apps/studio/pages/project/[ref]/settings/api-keys/index.tsx b/apps/studio/pages/project/[ref]/settings/api-keys/index.tsx index 99e475e2c9d52..e758c4f236b9b 100644 --- a/apps/studio/pages/project/[ref]/settings/api-keys/index.tsx +++ b/apps/studio/pages/project/[ref]/settings/api-keys/index.tsx @@ -1,25 +1,43 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useParams } from 'common' import { ApiKeysCreateCallout, ApiKeysFeedbackBanner, } from 'components/interfaces/APIKeys/ApiKeysIllustrations' -import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility' import { PublishableAPIKeys } from 'components/interfaces/APIKeys/PublishableAPIKeys' import { SecretAPIKeys } from 'components/interfaces/APIKeys/SecretAPIKeys' import ApiKeysLayout from 'components/layouts/APIKeys/APIKeysLayout' -import DefaultLayout from 'components/layouts/DefaultLayout' +import { DefaultLayout } from 'components/layouts/DefaultLayout' import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' import { DisableInteraction } from 'components/ui/DisableInteraction' +import { useAPIKeysQuery } from 'data/api-keys/api-keys-query' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useMemo } from 'react' import type { NextPageWithLayout } from 'types' import { Separator } from 'ui' const ApiKeysNewPage: NextPageWithLayout = () => { - const { shouldDisableUI, canInitApiKeys } = useApiKeysVisibility() + const { ref: projectRef } = useParams() + const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') + const { data: apiKeysData = [] } = useAPIKeysQuery( + { + projectRef, + reveal: false, + }, + { enabled: canReadAPIKeys } + ) + + const newApiKeys = useMemo( + () => apiKeysData.filter(({ type }) => type === 'publishable' || type === 'secret'), + [apiKeysData] + ) + const hasNewApiKeys = newApiKeys.length > 0 return ( <> - {canInitApiKeys && } - - + {canReadAPIKeys && !hasNewApiKeys && } + {hasNewApiKeys && } + From 5399ce2df4ebd42b716d7e4b8fb0284da911e5e8 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Fri, 12 Dec 2025 10:42:20 +0200 Subject: [PATCH 4/9] chore: Bump `next` to the latest version (#41301) Bump next version to the latest. --- apps/studio/package.json | 2 +- pnpm-lock.yaml | 231 +++++++++++++++++++-------------------- pnpm-workspace.yaml | 2 +- 3 files changed, 115 insertions(+), 120 deletions(-) diff --git a/apps/studio/package.json b/apps/studio/package.json index ecd944863d4a4..e72c8fffd2b41 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -102,7 +102,7 @@ "memoize-one": "^5.0.1", "mime-db": "^1.53.0", "monaco-editor": "0.52.2", - "next": "^16.0.9", + "next": "^16.0.10", "next-themes": "^0.3.0", "nuqs": "2.7.1", "openai": "^4.75.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fce520f7f2920..f4432203d1a71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,8 +31,8 @@ catalogs: specifier: ^18.3.0 version: 18.3.0 next: - specifier: ^15.5.8 - version: 15.5.8 + specifier: ^15.5.9 + version: 15.5.9 react: specifier: ^18.3.0 version: 18.3.1 @@ -131,28 +131,28 @@ importers: version: 3.54.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@payloadcms/next': specifier: 3.52.0 - version: 3.52.0(@types/react@18.3.3)(graphql@16.11.0)(monaco-editor@0.52.2)(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2) + version: 3.52.0(@types/react@18.3.3)(graphql@16.11.0)(monaco-editor@0.52.2)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2) '@payloadcms/payload-cloud': specifier: 3.60.0 version: 3.60.0(encoding@0.1.13)(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2)) '@payloadcms/plugin-form-builder': specifier: 3.52.0 - version: 3.52.0(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2) + version: 3.52.0(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2) '@payloadcms/plugin-nested-docs': specifier: 3.52.0 version: 3.52.0(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2)) '@payloadcms/plugin-seo': specifier: 3.52.0 - version: 3.52.0(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2) + version: 3.52.0(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2) '@payloadcms/richtext-lexical': specifier: 3.52.0 - version: 3.52.0(@faceless-ui/modal@3.0.0-beta.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@faceless-ui/scroll-info@2.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@payloadcms/next@3.52.0(@types/react@18.3.3)(graphql@16.11.0)(monaco-editor@0.52.2)(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2))(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2)(yjs@13.6.27) + version: 3.52.0(@faceless-ui/modal@3.0.0-beta.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@faceless-ui/scroll-info@2.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@payloadcms/next@3.52.0(@types/react@18.3.3)(graphql@16.11.0)(monaco-editor@0.52.2)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2))(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2)(yjs@13.6.27) '@payloadcms/storage-s3': specifier: 3.52.0 - version: 3.52.0(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2) + version: 3.52.0(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2) '@payloadcms/ui': specifier: 3.52.0 - version: 3.52.0(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2) + version: 3.52.0(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2) '@radix-ui/react-checkbox': specifier: ^1.3.2 version: 1.3.2(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -203,7 +203,7 @@ importers: version: 0.511.0(react@18.3.1) next: specifier: 'catalog:' - version: 15.5.8(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + version: 15.5.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) payload: specifier: 3.52.0 version: 3.52.0(graphql@16.11.0)(typescript@5.9.2) @@ -266,10 +266,10 @@ importers: version: 1.2.0 next: specifier: 'catalog:' - version: 15.5.8(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + version: 15.5.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) next-contentlayer2: specifier: 0.4.6 - version: 0.4.6(contentlayer2@0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1))(esbuild@0.25.2)(markdown-wasm@1.2.0)(next@15.5.8(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1) + version: 0.4.6(contentlayer2@0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1))(esbuild@0.25.2)(markdown-wasm@1.2.0)(next@15.5.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1) next-themes: specifier: ^0.3.0 version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -420,7 +420,7 @@ importers: version: 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@sentry/nextjs': specifier: 'catalog:' - version: 10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0) + version: 10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0) '@supabase/supabase-js': specifier: 'catalog:' version: 2.87.0 @@ -528,7 +528,7 @@ importers: version: 1.0.1 next: specifier: 'catalog:' - version: 15.5.8(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + version: 15.5.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) next-mdx-remote: specifier: ^4.4.1 version: 4.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1) @@ -540,7 +540,7 @@ importers: version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) nuqs: specifier: ^1.19.1 - version: 1.19.1(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4)) + version: 1.19.1(next@15.5.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4)) openai: specifier: ^4.75.1 version: 4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.76) @@ -838,7 +838,7 @@ importers: version: 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@sentry/nextjs': specifier: 'catalog:' - version: 10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.0.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0) + version: 10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.0.10(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0) '@std/path': specifier: npm:@jsr/std__path@^1.0.8 version: '@jsr/std__path@1.0.8' @@ -984,14 +984,14 @@ importers: specifier: 0.52.2 version: 0.52.2 next: - specifier: ^16.0.9 - version: 16.0.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + specifier: ^16.0.10 + version: 16.0.10(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) next-themes: specifier: ^0.3.0 version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) nuqs: specifier: 2.7.1 - version: 2.7.1(@tanstack/react-router@1.114.27(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(next@16.0.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-router@7.5.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 2.7.1(@tanstack/react-router@1.114.27(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(next@16.0.10(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-router@7.5.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) openai: specifier: ^4.75.1 version: 4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.76) @@ -1241,7 +1241,7 @@ importers: version: 2.11.3(@types/node@22.13.14)(typescript@5.9.2) next-router-mock: specifier: ^0.9.13 - version: 0.9.13(next@16.0.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1) + version: 0.9.13(next@16.0.10(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1) node-mocks-http: specifier: ^1.17.2 version: 1.17.2(@types/node@22.13.14) @@ -1415,10 +1415,10 @@ importers: version: 0.436.0(react@18.3.1) next: specifier: 'catalog:' - version: 15.5.8(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + version: 15.5.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) next-contentlayer2: specifier: 0.4.6 - version: 0.4.6(contentlayer2@0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1))(esbuild@0.25.2)(markdown-wasm@1.2.0)(next@15.5.8(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1) + version: 0.4.6(contentlayer2@0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1))(esbuild@0.25.2)(markdown-wasm@1.2.0)(next@15.5.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1) next-themes: specifier: ^0.3.0 version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1602,7 +1602,7 @@ importers: version: 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@sentry/nextjs': specifier: 'catalog:' - version: 10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0) + version: 10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0) '@supabase/supabase-js': specifier: 'catalog:' version: 2.87.0 @@ -1683,19 +1683,19 @@ importers: version: 1.0.1 next: specifier: 'catalog:' - version: 15.5.8(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + version: 15.5.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) next-mdx-remote: specifier: ^4.4.1 version: 4.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1) next-seo: specifier: ^6.5.0 - version: 6.5.0(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 6.5.0(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-themes: specifier: ^0.3.0 version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) nuqs: specifier: ^2.8.1 - version: 2.8.1(@tanstack/react-router@1.114.27(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-router@7.5.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 2.8.1(@tanstack/react-router@1.114.27(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-router@7.5.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) openai: specifier: ^4.75.1 version: 4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.76) @@ -2020,13 +2020,13 @@ importers: version: 0.7.9 flags: specifier: ^4.0.0 - version: 4.0.1(@opentelemetry/api@1.9.0)(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 4.0.1(@opentelemetry/api@1.9.0)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) lodash: specifier: ^4.17.21 version: 4.17.21 next: specifier: 'catalog:' - version: 15.5.8(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + version: 15.5.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) next-themes: specifier: ^0.3.0 version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2346,7 +2346,7 @@ importers: version: 11.12.1(supports-color@8.1.1) next: specifier: 'catalog:' - version: 15.5.8(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + version: 15.5.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) next-themes: specifier: ^0.3.0 version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2527,7 +2527,7 @@ importers: version: 0.52.2 next: specifier: 'catalog:' - version: 15.5.8(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + version: 15.5.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) next-themes: specifier: '*' version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2639,7 +2639,7 @@ importers: version: link:../api-types next-router-mock: specifier: ^0.9.13 - version: 0.9.13(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1) + version: 0.9.13(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1) tsx: specifier: 'catalog:' version: 4.20.3 @@ -4814,14 +4814,11 @@ packages: '@next/bundle-analyzer@16.0.4': resolution: {integrity: sha512-6IajJ23QrXW5RTJj2lRHcBM8mxcEl+vgd7XXVODQG/BcJyjgIP1k5OefdRl+P80btPvHeHoV4fIgC1so25pXcg==} - '@next/env@15.5.7': - resolution: {integrity: sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==} + '@next/env@15.5.9': + resolution: {integrity: sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==} - '@next/env@15.5.8': - resolution: {integrity: sha512-ejZHa3ogTxcy851dFoNtfB5B2h7AbSAtHbR5CymUlnz4yW1QjHNufVpvTu8PTnWBKFKjrd4k6Gbi2SsCiJKvxw==} - - '@next/env@16.0.9': - resolution: {integrity: sha512-6284pl8c8n9PQidN63qjPVEu1uXXKjnmbmaLebOzIfTrSXdGiAPsIMRi4pk/+v/ezqweE1/B8bFqiAAfC6lMXg==} + '@next/env@16.0.10': + resolution: {integrity: sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==} '@next/eslint-plugin-next@15.5.4': resolution: {integrity: sha512-SR1vhXNNg16T4zffhJ4TS7Xn7eq4NfKfcOsRwea7RIAHrjRpI9ALYbamqIJqkAhowLlERffiwk0FMvTLNdnVtw==} @@ -4843,8 +4840,8 @@ packages: cpu: [arm64] os: [darwin] - '@next/swc-darwin-arm64@16.0.9': - resolution: {integrity: sha512-j06fWg/gPqiWjK+sEpCDsh5gX+Bdy9gnPYjFqMBvBEOIcCFy1/ecF6pY6XAce7WyCJAbBPVb+6GvpmUZKNq0oQ==} + '@next/swc-darwin-arm64@16.0.10': + resolution: {integrity: sha512-4XgdKtdVsaflErz+B5XeG0T5PeXKDdruDf3CRpnhN+8UebNa5N2H58+3GDgpn/9GBurrQ1uWW768FfscwYkJRg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] @@ -4855,8 +4852,8 @@ packages: cpu: [x64] os: [darwin] - '@next/swc-darwin-x64@16.0.9': - resolution: {integrity: sha512-FRYYz5GSKUkfvDSjd5hgHME2LgYjfOLBmhRVltbs3oRNQQf9n5UTQMmIu/u5vpkjJFV4L2tqo8duGqDxdQOFwg==} + '@next/swc-darwin-x64@16.0.10': + resolution: {integrity: sha512-spbEObMvRKkQ3CkYVOME+ocPDFo5UqHb8EMTS78/0mQ+O1nqE8toHJVioZo4TvebATxgA8XMTHHrScPrn68OGw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] @@ -4868,8 +4865,8 @@ packages: os: [linux] libc: [glibc] - '@next/swc-linux-arm64-gnu@16.0.9': - resolution: {integrity: sha512-EI2klFVL8tOyEIX5J1gXXpm1YuChmDy4R+tHoNjkCHUmBJqXioYErX/O2go4pEhjxkAxHp2i8y5aJcRz2m5NqQ==} + '@next/swc-linux-arm64-gnu@16.0.10': + resolution: {integrity: sha512-uQtWE3X0iGB8apTIskOMi2w/MKONrPOUCi5yLO+v3O8Mb5c7K4Q5KD1jvTpTF5gJKa3VH/ijKjKUq9O9UhwOYw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -4882,8 +4879,8 @@ packages: os: [linux] libc: [musl] - '@next/swc-linux-arm64-musl@16.0.9': - resolution: {integrity: sha512-vq/5HeGvowhDPMrpp/KP4GjPVhIXnwNeDPF5D6XK6ta96UIt+C0HwJwuHYlwmn0SWyNANqx1Mp6qSVDXwbFKsw==} + '@next/swc-linux-arm64-musl@16.0.10': + resolution: {integrity: sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -4896,8 +4893,8 @@ packages: os: [linux] libc: [glibc] - '@next/swc-linux-x64-gnu@16.0.9': - resolution: {integrity: sha512-GlUdJwy2leA/HnyRYxJ1ZJLCJH+BxZfqV4E0iYLrJipDKxWejWpPtZUdccPmCfIEY9gNBO7bPfbG6IIgkt0qXg==} + '@next/swc-linux-x64-gnu@16.0.10': + resolution: {integrity: sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -4910,8 +4907,8 @@ packages: os: [linux] libc: [musl] - '@next/swc-linux-x64-musl@16.0.9': - resolution: {integrity: sha512-UCtOVx4N8AHF434VPwg4L0KkFLAd7pgJShzlX/hhv9+FDrT7/xCuVdlBsCXH7l9yCA/wHl3OqhMbIkgUluriWA==} + '@next/swc-linux-x64-musl@16.0.10': + resolution: {integrity: sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -4923,8 +4920,8 @@ packages: cpu: [arm64] os: [win32] - '@next/swc-win32-arm64-msvc@16.0.9': - resolution: {integrity: sha512-tQjtDGtv63mV3n/cZ4TH8BgUvKTSFlrF06yT5DyRmgQuj5WEjBUDy0W3myIW5kTRYMPrLn42H3VfCNwBH6YYiA==} + '@next/swc-win32-arm64-msvc@16.0.10': + resolution: {integrity: sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] @@ -4935,8 +4932,8 @@ packages: cpu: [x64] os: [win32] - '@next/swc-win32-x64-msvc@16.0.9': - resolution: {integrity: sha512-y9AGACHTBwnWFLq5B5Fiv3FEbXBusdPb60pgoerB04CV/pwjY1xQNdoTNxAv7eUhU2k1CKnkN4XWVuiK07uOqA==} + '@next/swc-win32-x64-msvc@16.0.10': + resolution: {integrity: sha512-E+njfCoFLb01RAFEnGZn6ERoOqhK1Gl3Lfz1Kjnj0Ulfu7oJbuMyvBKNj/bw8XZnenHDASlygTjZICQW+rYW1Q==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -15066,8 +15063,8 @@ packages: next-tick@1.1.0: resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} - next@15.5.8: - resolution: {integrity: sha512-Tma2R50eiM7Fx6fbDeHiThq7sPgl06mBr76j6Ga0lMFGrmaLitFsy31kykgb8Z++DR2uIEKi2RZ0iyjIwFd15Q==} + next@15.5.9: + resolution: {integrity: sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} hasBin: true peerDependencies: @@ -15087,8 +15084,8 @@ packages: sass: optional: true - next@16.0.9: - resolution: {integrity: sha512-Xk5x/wEk6ADIAtQECLo1uyE5OagbQCiZ+gW4XEv24FjQ3O2PdSkvgsn22aaseSXC7xg84oONvQjFbSTX5YsMhQ==} + next@16.0.10: + resolution: {integrity: sha512-RtWh5PUgI+vxlV3HdR+IfWA1UUHu0+Ram/JBO4vWB54cVPentCD0e+lxyAYEsDTqGGMg7qpjhKh6dc6aW7W/sA==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -22912,11 +22909,9 @@ snapshots: - bufferutil - utf-8-validate - '@next/env@15.5.7': {} - - '@next/env@15.5.8': {} + '@next/env@15.5.9': {} - '@next/env@16.0.9': {} + '@next/env@16.0.10': {} '@next/eslint-plugin-next@15.5.4': dependencies: @@ -22932,49 +22927,49 @@ snapshots: '@next/swc-darwin-arm64@15.5.7': optional: true - '@next/swc-darwin-arm64@16.0.9': + '@next/swc-darwin-arm64@16.0.10': optional: true '@next/swc-darwin-x64@15.5.7': optional: true - '@next/swc-darwin-x64@16.0.9': + '@next/swc-darwin-x64@16.0.10': optional: true '@next/swc-linux-arm64-gnu@15.5.7': optional: true - '@next/swc-linux-arm64-gnu@16.0.9': + '@next/swc-linux-arm64-gnu@16.0.10': optional: true '@next/swc-linux-arm64-musl@15.5.7': optional: true - '@next/swc-linux-arm64-musl@16.0.9': + '@next/swc-linux-arm64-musl@16.0.10': optional: true '@next/swc-linux-x64-gnu@15.5.7': optional: true - '@next/swc-linux-x64-gnu@16.0.9': + '@next/swc-linux-x64-gnu@16.0.10': optional: true '@next/swc-linux-x64-musl@15.5.7': optional: true - '@next/swc-linux-x64-musl@16.0.9': + '@next/swc-linux-x64-musl@16.0.10': optional: true '@next/swc-win32-arm64-msvc@15.5.7': optional: true - '@next/swc-win32-arm64-msvc@16.0.9': + '@next/swc-win32-arm64-msvc@16.0.10': optional: true '@next/swc-win32-x64-msvc@15.5.7': optional: true - '@next/swc-win32-x64-msvc@16.0.9': + '@next/swc-win32-x64-msvc@16.0.10': optional: true '@noble/ciphers@1.3.0': {} @@ -24164,12 +24159,12 @@ snapshots: '@payloadcms/live-preview@3.54.0': {} - '@payloadcms/next@3.52.0(@types/react@18.3.3)(graphql@16.11.0)(monaco-editor@0.52.2)(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2)': + '@payloadcms/next@3.52.0(@types/react@18.3.3)(graphql@16.11.0)(monaco-editor@0.52.2)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2)': dependencies: '@dnd-kit/core': 6.0.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@payloadcms/graphql': 3.52.0(graphql@16.11.0)(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(typescript@5.9.2) '@payloadcms/translations': 3.52.0 - '@payloadcms/ui': 3.52.0(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2) + '@payloadcms/ui': 3.52.0(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2) busboy: 1.6.0 dequal: 2.0.3 file-type: 19.3.0 @@ -24177,7 +24172,7 @@ snapshots: graphql-http: 1.22.4(graphql@16.11.0) graphql-playground-html: 1.6.30 http-status: 2.1.0 - next: 15.5.8(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + next: 15.5.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) path-to-regexp: 6.3.0 payload: 3.52.0(graphql@16.11.0)(typescript@5.9.2) qs-esm: 7.0.2 @@ -24205,9 +24200,9 @@ snapshots: - aws-crt - encoding - '@payloadcms/plugin-cloud-storage@3.52.0(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2)': + '@payloadcms/plugin-cloud-storage@3.52.0(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2)': dependencies: - '@payloadcms/ui': 3.52.0(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2) + '@payloadcms/ui': 3.52.0(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2) find-node-modules: 2.1.3 payload: 3.52.0(graphql@16.11.0)(typescript@5.9.2) range-parser: 1.2.1 @@ -24220,9 +24215,9 @@ snapshots: - supports-color - typescript - '@payloadcms/plugin-form-builder@3.52.0(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2)': + '@payloadcms/plugin-form-builder@3.52.0(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2)': dependencies: - '@payloadcms/ui': 3.52.0(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2) + '@payloadcms/ui': 3.52.0(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2) escape-html: 1.0.3 payload: 3.52.0(graphql@16.11.0)(typescript@5.9.2) react: 18.3.1 @@ -24238,10 +24233,10 @@ snapshots: dependencies: payload: 3.52.0(graphql@16.11.0)(typescript@5.9.2) - '@payloadcms/plugin-seo@3.52.0(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2)': + '@payloadcms/plugin-seo@3.52.0(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2)': dependencies: '@payloadcms/translations': 3.52.0 - '@payloadcms/ui': 3.52.0(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2) + '@payloadcms/ui': 3.52.0(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2) payload: 3.52.0(graphql@16.11.0)(typescript@5.9.2) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -24252,7 +24247,7 @@ snapshots: - supports-color - typescript - '@payloadcms/richtext-lexical@3.52.0(@faceless-ui/modal@3.0.0-beta.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@faceless-ui/scroll-info@2.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@payloadcms/next@3.52.0(@types/react@18.3.3)(graphql@16.11.0)(monaco-editor@0.52.2)(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2))(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2)(yjs@13.6.27)': + '@payloadcms/richtext-lexical@3.52.0(@faceless-ui/modal@3.0.0-beta.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@faceless-ui/scroll-info@2.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@payloadcms/next@3.52.0(@types/react@18.3.3)(graphql@16.11.0)(monaco-editor@0.52.2)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2))(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2)(yjs@13.6.27)': dependencies: '@faceless-ui/modal': 3.0.0-beta.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@faceless-ui/scroll-info': 2.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -24266,9 +24261,9 @@ snapshots: '@lexical/selection': 0.28.0 '@lexical/table': 0.28.0 '@lexical/utils': 0.28.0 - '@payloadcms/next': 3.52.0(@types/react@18.3.3)(graphql@16.11.0)(monaco-editor@0.52.2)(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2) + '@payloadcms/next': 3.52.0(@types/react@18.3.3)(graphql@16.11.0)(monaco-editor@0.52.2)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2) '@payloadcms/translations': 3.52.0 - '@payloadcms/ui': 3.52.0(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2) + '@payloadcms/ui': 3.52.0(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2) '@types/uuid': 10.0.0 acorn: 8.12.1 bson-objectid: 2.0.4 @@ -24295,12 +24290,12 @@ snapshots: - typescript - yjs - '@payloadcms/storage-s3@3.52.0(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2)': + '@payloadcms/storage-s3@3.52.0(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2)': dependencies: '@aws-sdk/client-s3': 3.830.0 '@aws-sdk/lib-storage': 3.830.0(@aws-sdk/client-s3@3.830.0) '@aws-sdk/s3-request-presigner': 3.830.0 - '@payloadcms/plugin-cloud-storage': 3.52.0(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2) + '@payloadcms/plugin-cloud-storage': 3.52.0(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2) payload: 3.52.0(graphql@16.11.0)(typescript@5.9.2) transitivePeerDependencies: - '@types/react' @@ -24316,7 +24311,7 @@ snapshots: dependencies: date-fns: 4.1.0 - '@payloadcms/ui@3.52.0(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2)': + '@payloadcms/ui@3.52.0(@types/react@18.3.3)(monaco-editor@0.52.2)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(payload@3.52.0(graphql@16.11.0)(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(typescript@5.9.2)': dependencies: '@date-fns/tz': 1.2.0 '@dnd-kit/core': 6.0.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -24331,7 +24326,7 @@ snapshots: date-fns: 4.1.0 dequal: 2.0.3 md5: 2.3.0 - next: 15.5.8(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + next: 15.5.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) object-to-formdata: 4.5.1 payload: 3.52.0(graphql@16.11.0)(typescript@5.9.2) qs-esm: 7.0.2 @@ -26814,7 +26809,7 @@ snapshots: '@sentry/core@10.27.0': {} - '@sentry/nextjs@10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0)': + '@sentry/nextjs@10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.38.0 @@ -26827,7 +26822,7 @@ snapshots: '@sentry/react': 10.27.0(react@18.3.1) '@sentry/vercel-edge': 10.27.0 '@sentry/webpack-plugin': 4.6.1(encoding@0.1.13)(supports-color@8.1.1)(webpack@5.94.0) - next: 15.5.8(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + next: 15.5.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) resolve: 1.22.8 rollup: 4.50.2 stacktrace-parser: 0.1.10 @@ -26840,7 +26835,7 @@ snapshots: - supports-color - webpack - '@sentry/nextjs@10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.0.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0)': + '@sentry/nextjs@10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.0.10(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.38.0 @@ -26853,7 +26848,7 @@ snapshots: '@sentry/react': 10.27.0(react@18.3.1) '@sentry/vercel-edge': 10.27.0 '@sentry/webpack-plugin': 4.6.1(encoding@0.1.13)(supports-color@8.1.1)(webpack@5.94.0) - next: 16.0.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + next: 16.0.10(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) resolve: 1.22.8 rollup: 4.50.2 stacktrace-parser: 0.1.10 @@ -32126,13 +32121,13 @@ snapshots: micromatch: 4.0.8 resolve-dir: 1.0.1 - flags@4.0.1(@opentelemetry/api@1.9.0)(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + flags@4.0.1(@opentelemetry/api@1.9.0)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@edge-runtime/cookies': 5.0.2 jose: 5.9.6 optionalDependencies: '@opentelemetry/api': 1.9.0 - next: 15.5.8(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + next: 15.5.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -35538,12 +35533,12 @@ snapshots: neo-async@2.6.2: {} - next-contentlayer2@0.4.6(contentlayer2@0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1))(esbuild@0.25.2)(markdown-wasm@1.2.0)(next@15.5.8(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1): + next-contentlayer2@0.4.6(contentlayer2@0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1))(esbuild@0.25.2)(markdown-wasm@1.2.0)(next@15.5.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1): dependencies: '@contentlayer2/core': 0.4.3(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1) '@contentlayer2/utils': 0.4.3 contentlayer2: 0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1) - next: 15.5.8(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + next: 15.5.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) transitivePeerDependencies: @@ -35567,19 +35562,19 @@ snapshots: dependencies: js-yaml-loader: 1.2.2 - next-router-mock@0.9.13(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1): + next-router-mock@0.9.13(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1): dependencies: - next: 15.5.8(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + next: 15.5.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) react: 18.3.1 - next-router-mock@0.9.13(next@16.0.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1): + next-router-mock@0.9.13(next@16.0.10(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1): dependencies: - next: 16.0.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + next: 16.0.10(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) react: 18.3.1 - next-seo@6.5.0(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next-seo@6.5.0(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - next: 15.5.8(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + next: 15.5.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -35590,9 +35585,9 @@ snapshots: next-tick@1.1.0: {} - next@15.5.8(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4): + next@15.5.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4): dependencies: - '@next/env': 15.5.8 + '@next/env': 15.5.9 '@swc/helpers': 0.5.15 caniuse-lite: 1.0.30001743 postcss: 8.4.31 @@ -35616,9 +35611,9 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.0.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4): + next@16.0.10(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4): dependencies: - '@next/env': 16.0.9 + '@next/env': 16.0.10 '@swc/helpers': 0.5.15 caniuse-lite: 1.0.30001743 postcss: 8.4.31 @@ -35626,14 +35621,14 @@ snapshots: react-dom: 18.3.1(react@18.3.1) styled-jsx: 5.1.6(@babel/core@7.28.4(supports-color@8.1.1))(babel-plugin-macros@3.1.0)(react@18.3.1) optionalDependencies: - '@next/swc-darwin-arm64': 16.0.9 - '@next/swc-darwin-x64': 16.0.9 - '@next/swc-linux-arm64-gnu': 16.0.9 - '@next/swc-linux-arm64-musl': 16.0.9 - '@next/swc-linux-x64-gnu': 16.0.9 - '@next/swc-linux-x64-musl': 16.0.9 - '@next/swc-win32-arm64-msvc': 16.0.9 - '@next/swc-win32-x64-msvc': 16.0.9 + '@next/swc-darwin-arm64': 16.0.10 + '@next/swc-darwin-x64': 16.0.10 + '@next/swc-linux-arm64-gnu': 16.0.10 + '@next/swc-linux-arm64-musl': 16.0.10 + '@next/swc-linux-x64-gnu': 16.0.10 + '@next/swc-linux-x64-musl': 16.0.10 + '@next/swc-win32-arm64-msvc': 16.0.10 + '@next/swc-win32-x64-msvc': 16.0.10 '@opentelemetry/api': 1.9.0 '@playwright/test': 1.56.1 sass: 1.77.4 @@ -35955,27 +35950,27 @@ snapshots: number-flow@0.3.7: {} - nuqs@1.19.1(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4)): + nuqs@1.19.1(next@15.5.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4)): dependencies: mitt: 3.0.1 - next: 15.5.8(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + next: 15.5.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) - nuqs@2.7.1(@tanstack/react-router@1.114.27(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(next@16.0.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-router@7.5.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): + nuqs@2.7.1(@tanstack/react-router@1.114.27(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(next@16.0.10(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-router@7.5.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): dependencies: '@standard-schema/spec': 1.0.0 react: 18.3.1 optionalDependencies: '@tanstack/react-router': 1.114.27(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - next: 16.0.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + next: 16.0.10(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) react-router: 7.5.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - nuqs@2.8.1(@tanstack/react-router@1.114.27(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(next@15.5.8(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-router@7.5.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): + nuqs@2.8.1(@tanstack/react-router@1.114.27(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-router@7.5.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): dependencies: '@standard-schema/spec': 1.0.0 react: 18.3.1 optionalDependencies: '@tanstack/react-router': 1.114.27(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - next: 15.5.8(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + next: 15.5.9(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) react-router: 7.5.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) nuxt@4.1.2(@electric-sql/pglite@0.2.15)(@parcel/watcher@2.5.1)(@types/node@22.13.14)(@vue/compiler-sfc@3.5.21)(aws4fetch@1.0.20)(db0@0.3.2(@electric-sql/pglite@0.2.15)(drizzle-orm@0.44.2(@electric-sql/pglite@0.2.15)(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(pg@8.16.3)))(drizzle-orm@0.44.2(@electric-sql/pglite@0.2.15)(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(pg@8.16.3))(encoding@0.1.13)(eslint@9.37.0(jiti@2.5.1)(supports-color@8.1.1))(ioredis@5.7.0(supports-color@8.1.1))(magicast@0.3.5)(rollup@4.50.2)(sass@1.77.4)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.20.3)(typescript@5.9.2)(vite@7.1.11(@types/node@22.13.14)(jiti@2.5.1)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.1))(yaml@2.8.1): @@ -36602,7 +36597,7 @@ snapshots: payload@3.52.0(graphql@16.11.0)(typescript@5.9.2): dependencies: - '@next/env': 15.5.7 + '@next/env': 15.5.9 '@payloadcms/translations': 3.52.0 '@types/busboy': 1.5.4 ajv: 8.17.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c2a4cc41f1db8..c4344ed67163e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -13,7 +13,7 @@ catalog: '@types/node': ^22.0.0 '@types/react': ^18.3.0 '@types/react-dom': ^18.3.0 - next: ^15.5.8 + next: ^15.5.9 react: ^18.3.0 react-dom: ^18.3.0 recharts: ^2.15.4 From a90890ad52a3a16e84dd5b51ddf84c6779e4a641 Mon Sep 17 00:00:00 2001 From: "Andrey A." <56412611+aantti@users.noreply.github.com> Date: Fri, 12 Dec 2025 10:20:24 +0100 Subject: [PATCH 5/9] fix(docs): multiple edits and enhancements for self-hosted docker guide (#40901) * fix(docs): multiple edits and enhancements * fix: edited to remove typos and align with the linter * fix: remove more typos and a bit more linter compliance * fix: correct minor typos * chore: add linter exclusion * fix: exclude lint check for imgproxy * fix: move disable lint to separate lines * chore: make prettier happy * chore: typos plus add linux docker desktop * chore: fix typos and add link to postgres password guidelines * chore: stress postgres password recommendation * fix: use relative paths for docs references * chore: add a note about all digits in dashboard password * chore: add a note about no-telemetry and design partnerships if enterprise * chore: initial import of a simpler jwt generator component * fix: grammar, some sections reorg, use simpler generator --------- Co-authored-by: Chris Chinchilla --- .../JwtGenerator/JwtGeneratorSimple.tsx | 78 ++++ apps/docs/components/JwtGenerator/index.tsx | 11 +- apps/docs/content/guides/self-hosting.mdx | 76 ++-- .../content/guides/self-hosting/docker.mdx | 383 ++++++++++-------- apps/docs/features/docs/MdxBase.shared.tsx | 3 +- 5 files changed, 337 insertions(+), 214 deletions(-) create mode 100644 apps/docs/components/JwtGenerator/JwtGeneratorSimple.tsx diff --git a/apps/docs/components/JwtGenerator/JwtGeneratorSimple.tsx b/apps/docs/components/JwtGenerator/JwtGeneratorSimple.tsx new file mode 100644 index 0000000000000..0f6bb64014f18 --- /dev/null +++ b/apps/docs/components/JwtGenerator/JwtGeneratorSimple.tsx @@ -0,0 +1,78 @@ +import { KJUR } from 'jsrsasign' +import { useState } from 'react' +import { Button, CodeBlock } from 'ui' + +const JWT_HEADER = { alg: 'HS256', typ: 'JWT' } + +const generateRandomString = (length: number) => { + const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + let result = '' + + const MAX = Math.floor(256 / CHARS.length) * CHARS.length - 1 + const randomUInt8Array = new Uint8Array(1) + + for (let i = 0; i < length; i++) { + let randomNumber: number + do { + crypto.getRandomValues(randomUInt8Array) + randomNumber = randomUInt8Array[0] + } while (randomNumber > MAX) + + result += CHARS[randomNumber % CHARS.length] + } + + return result +} + +const generateKeys = () => { + const now = new Date() + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const fiveYears = new Date(now.getFullYear() + 5, now.getMonth(), now.getDate()) + const iat = Math.floor(today.valueOf() / 1000) + const exp = Math.floor(fiveYears.valueOf() / 1000) + + const anonToken = { role: 'anon', iss: 'supabase', iat, exp } + const serviceToken = { role: 'service_role', iss: 'supabase', iat, exp } + + const secret = generateRandomString(40) + const anonKey = KJUR.jws.JWS.sign(null, JWT_HEADER, anonToken, secret) + const serviceRoleKey = KJUR.jws.JWS.sign(null, JWT_HEADER, serviceToken, secret) + return { secret, anonKey, serviceRoleKey } +} + +export default function JwtGeneratorSimple() { + const [keys, setKeys] = useState(generateKeys) + + const regenerate = () => { + setKeys(generateKeys()) + } + + return ( +
+
+ + + {keys.secret} + +
+ +
+ + + {keys.anonKey} + +
+ +
+ + + {keys.serviceRoleKey} + +
+ + +
+ ) +} diff --git a/apps/docs/components/JwtGenerator/index.tsx b/apps/docs/components/JwtGenerator/index.tsx index d104d40a7798e..927292961959d 100644 --- a/apps/docs/components/JwtGenerator/index.tsx +++ b/apps/docs/components/JwtGenerator/index.tsx @@ -4,6 +4,7 @@ import dynamic from 'next/dynamic' import { Suspense } from 'react' const DynamicJwtGenerator = dynamic(() => import('./JwtGenerator'), { ssr: false }) +const DynamicJwtGeneratorSimple = dynamic(() => import('./JwtGeneratorSimple'), { ssr: false }) const JwtGenerator = () => { return ( @@ -13,4 +14,12 @@ const JwtGenerator = () => { ) } -export { JwtGenerator } +const JwtGeneratorSimple = () => { + return ( + Loading...}> + + + ) +} + +export { JwtGenerator, JwtGeneratorSimple } diff --git a/apps/docs/content/guides/self-hosting.mdx b/apps/docs/content/guides/self-hosting.mdx index ffa04358aa431..5c9dd0973a467 100644 --- a/apps/docs/content/guides/self-hosting.mdx +++ b/apps/docs/content/guides/self-hosting.mdx @@ -1,19 +1,29 @@ --- title: 'Self-Hosting' description: 'Host Supabase on your own infrastructure.' -subtitle: 'Host Supabase on your own infrastructure.' +subtitle: 'Install and run your own Supabase.' hideToc: true --- -There are several ways to host Supabase on your own computer, server, or cloud. +Self-hosted Supabase lets you run the entire Supabase stack on your own computer, server, or cloud infrastructure. This is different from: + +- **Supabase CLI / Local Development**: A lightweight [local environment](/docs/guides/local-development) for development and testing only. +- **Managed Supabase** platform: If you want to try managed Supabase for free, visit [supabase.com/dashboard](/dashboard). + +Self-hosting is a good fit if you need full control over your data, have compliance requirements that prevent using managed services, or want to run Supabase in an isolated environment. + +## No telemetry + +Self-hosted Supabase does not phone home or collect any telemetry. + +## Enterprise + +If you're an enterprise using self-hosted Supabase, we'd love to hear from you. Reach out to our [Growth Team](https://forms.supabase.com/enterprise) to discuss your use case, share feedback, or explore design partnership opportunities. ## Officially supported
- - Most common - @@ -22,24 +32,29 @@ There are several ways to host Supabase on your own computer, server, or cloud.
-
- - Contact our Enterprise sales team if you need Supabase managed in your own cloud. - -
-Supabase is also a hosted platform. If you want to get started for free, visit [supabase.com/dashboard](/dashboard). - ## Community supported -There are several community-driven projects to help you deploy Supabase. We encourage you to try them out and contribute back to the community. +{/* supa-mdx-lint-disable-next-line Rule004ExcludeWords */} +There are several community-driven projects to help you deploy Supabase. These projects may be outdated and are seeking active maintainers. If you're interested in maintaining one of these projects, [contact the community team](/open-source/contributing/supasquad).
{community.map((x) => (
- {x.description} + + {x.name} + + Maintainer needed + + + } + > + {x.description} +
))} @@ -56,34 +71,13 @@ export const community = [ description: 'A self-hosted Supabase setup with Traefik as a reverse proxy.', href: 'https://github.com/supabase-community/supabase-traefik', }, - { - name: 'AWS', - description: 'A CloudFormation template for Supabase.', - href: 'https://github.com/supabase-community/supabase-on-aws', - }, ] -## Third-party guides +## Responsibility model -The following third-party providers have shown consistent support for the self-hosted version of Supabase:. +When you self-host, **you are responsible for**: -
- {[ - { - name: 'StackGres', - description: 'Deploys using Kubernetes.', - href: 'https://stackgres.io/blog/running-supabase-on-top-of-stackgres/', - }, - { - name: 'Pigsty', - description: 'Deploys using Ansible.', - href: 'https://pigsty.io/blog/db/supabase/', - }, - ].map((x) => ( -
- - {x.description} - -
- ))} -
+- Server provisioning and maintenance +- Security hardening and keeping OS and services updated +- Backups and disaster recovery +- Monitoring and uptime diff --git a/apps/docs/content/guides/self-hosting/docker.mdx b/apps/docs/content/guides/self-hosting/docker.mdx index a54247fcf6d17..12d24259dc76e 100644 --- a/apps/docs/content/guides/self-hosting/docker.mdx +++ b/apps/docs/content/guides/self-hosting/docker.mdx @@ -5,21 +5,52 @@ subtitle: "Learn how to configure and deploy Supabase with Docker." tocVideo: "FqiQKRKsfZE" --- -Docker is the easiest way to get started with self-hosted Supabase. It should only take you a few minutes to get up and running. This guide assumes you are running the command from the machine you intend to host from. +Docker is the easiest way to get started with self-hosted Supabase. It should take you less than 30 minutes to get up and running. ## Contents 1. [Before you begin](#before-you-begin) -2. [Installing and running Supabase](#installing-and-running-supabase) -3. [Accessing your services](#accessing-supabase-studio) -4. [Updating your services](#updating-your-services) -5. [Securing your services](#securing-your-services) +2. [System requirements](#system-requirements) +3. [Installing Supabase](#installing-supabase) +4. [Configuring and securing Supabase](#configuring-and-securing-supabase) +5. [Starting and stopping](#starting-and-stopping) +6. [Accessing Supabase services](#accessing-supabase-services) +7. [Updating](#updating) +8. [Uninstalling](#uninstalling) +9. [Advanced topics](#advanced-topics) ## Before you begin -You need the following installed in your system: [Git](https://git-scm.com/downloads) and Docker ([Windows](https://docs.docker.com/desktop/install/windows-install/), [macOS](https://docs.docker.com/desktop/install/mac-install/), or [Linux](https://docs.docker.com/desktop/install/linux-install/)). +This guide assumes you're comfortable with: -## Installing and running Supabase +- Linux server administration basics +- Docker and Docker Compose +- Networking fundamentals (ports, DNS, firewalls) + +If you're new to these topics, consider starting with [managed Supabase](/dashboard) for free, or try [local development with the CLI](/docs/guides/local-development). + +You need the following installed on your system: + +- [Git](https://git-scm.com/downloads) +- [Docker](https://docs.docker.com/manuals/): + - **Linux server/VPS**: Install [Docker Engine](https://docs.docker.com/engine/install/) and [Docker Compose](https://docs.docker.com/compose/install/) + - **Linux desktop**: Install [Docker Desktop](https://docs.docker.com/desktop/setup/install/linux/) + - **macOS**: Install [Docker Desktop](https://docs.docker.com/desktop/install/mac-install/) + - **Windows**: Install [Docker Desktop](https://docs.docker.com/desktop/install/windows-install/) + +## System requirements + +Baseline server requirements for running all Supabase components. These are suitable for development and small to medium production workloads: + +| Resource | Minimum | Recommended | +|----------|---------|-------------| +| RAM | 4 GB | 8 GB+ | +| CPU | 2 cores | 4 cores+ | +| Disk | 50 GB SSD | 80 GB+ SSD | + +If you don't need specific services, such as Analytics (Logflare), Realtime, Storage, Auth, or PostgREST, you can exclude them from `docker-compose.yml` to reduce resource requirements. + +## Installing Supabase Follow these steps to start Supabase on your machine: @@ -54,9 +85,6 @@ cd supabase-project # Pull the latest images docker compose pull - -# Start the services (in detached mode) -docker compose up -d ``` @@ -88,9 +116,6 @@ cd supabase-project # Pull the latest images docker compose pull - -# Start the services (in detached mode) -docker compose up -d ``` @@ -98,216 +123,210 @@ docker compose up -d -If you are using rootless docker, edit `.env` and set `DOCKER_SOCKET_LOCATION` to your docker socket location. For example: `/run/user/1000/docker.sock`. Otherwise, you will see an error like `container supabase-vector exited (0)`. + If you are using rootless Docker, edit `.env` and set `DOCKER_SOCKET_LOCATION` to your docker socket location. For example: `/run/user/1000/docker.sock`. Otherwise, you will see an error like `container supabase-vector exited (0)`. -After all the services have started you can see them running in the background: +## Configuring and securing Supabase -```sh -docker compose ps -``` - -All of the services should have a status `running (healthy)`. If you see a status like `created` but not `running`, try starting that service manually with `docker compose start `. +While we provided example placeholder passwords and keys in the `.env.example` file, you should NEVER start your self-hosted Supabase using these defaults. -Your app is now running with default credentials. -[Secure your services](#securing-your-services) as soon as possible using the instructions below. + Follow all of the steps in this section to ensure you have a secure setup, and only then start all services. -### Accessing Supabase Studio +### Configure database password -You can access Supabase Studio through the API gateway on port `8000`. For example: `http://:8000`, or [localhost:8000](http://localhost:8000) if you are running Docker locally. +Change the placeholder password in the `.env` file **before** starting your Supabase for the first time. -You will be prompted for a username and password. By default, the credentials are: +- `POSTGRES_PASSWORD`: the password for the `postgres` and `supabase_admin` database roles -- Username: `supabase` -- Password: `this_password_is_insecure_and_should_be_updated` +Follow the [password guidelines](/docs/guides/database/postgres/roles#passwords) for choosing a secure password. For easier configuration, **use only letters and numbers** to avoid URL encoding issues in connection strings. -You should change these credentials as soon as possible using the [instructions](#dashboard-authentication) below. +### Generate and configure API keys -### Accessing the APIs +Use the key generator below to obtain and configure the following secure keys in `.env`: -Each of the APIs are available through the same API gateway: +- `JWT_SECRET`: Used by PostgREST and GoTrue to sign and verify JWTs. +- `ANON_KEY`: Client-side API key with limited permissions (`anon` role). Use this in your frontend applications. +- `SERVICE_ROLE_KEY`: Server-side API key with full database access (`service_role` role). Never expose this in client code. -- REST: `http://:8000/rest/v1/` -- Auth: `http://:8000/auth/v1/` -- Storage: `http://:8000/storage/v1/` -- Realtime: `http://:8000/realtime/v1/` + -### Accessing your Edge Functions +1. Copy the generated value and update `JWT_SECRET` in the `.env` file. Do not share this secret publicly or commit it to version control. +2. Copy the generated value and update `ANON_KEY` in the `.env` file. +3. Copy the generated value and update `SERVICE_ROLE_KEY` in the `.env` file. -Edge Functions are stored in `volumes/functions`. The default setup has a `hello` Function that you can invoke on `http://:8000/functions/v1/hello`. +The generated keys expire in 5 years. You can verify them at [jwt.io](https://jwt.io) using the JWT secret. -You can add new Functions as `volumes/functions//index.ts`. Restart the `functions` service to pick up the changes: `docker compose restart functions --no-deps` +### Configure other keys, and important URLs -### Accessing Postgres +Edit the following settings in the `.env` file: -By default, the Supabase stack runs the [Supavisor](https://supabase.github.io/supavisor/development/docs/) connection pooler. Supavisor provides efficient management of database connections. +- `SECRET_KEY_BASE`: encryption key for securing Realtime and Supavisor communications. (Must be at least 64 characters; generate with `openssl rand -base64 48`) +- `VAULT_ENC_KEY`: encryption key used by Supavisor for storing encrypted configuration. (Must be exactly 32 characters; generate with `openssl rand -hex 16`) +- `PG_META_CRYPTO_KEY`: encryption key for securing connection strings used by Studio against postgres-meta. (Must be at least 32 characters; generate with `openssl rand -base64 24`) +- `LOGFLARE_PUBLIC_ACCESS_TOKEN`: API token for log ingestion and querying. Used by Vector and Studio to send and query logs. (Must be at least 32 characters; generate with `openssl rand -base64 24`) +- `LOGFLARE_PRIVATE_ACCESS_TOKEN`: API token for Logflare management operations. Used by Studio for administrative tasks. Never expose client-side. (Must be at least 32 characters; generate with `openssl rand -base64 24`) -You can connect to the Postgres database using the following methods: +Review and change URL environment variables: -1. For session-based connections (equivalent to direct Postgres connections): +- `SUPABASE_PUBLIC_URL`: the base URL for accessing your Supabase via the Internet, e.g, `http://example.com:8000` +- `API_EXTERNAL_URL`: the base URL for API requests, e.g., `http://example.com:8000` +- `SITE_URL`: the base URL of your site, e.g., `http://example.com:3000` -```bash -psql 'postgres://postgres.your-tenant-id:your-super-secret-and-long-postgres-password@localhost:5432/postgres' -``` + -2. For pooled transactional connections: + If you are only using self-hosted Supabase locally, you can use `localhost`. -```bash -psql 'postgres://postgres.your-tenant-id:your-super-secret-and-long-postgres-password@localhost:6543/postgres' -``` + -The default tenant ID is `your-tenant-id`, and the default password is `your-super-secret-and-long-postgres-password`. You should change these as soon as possible using the [instructions below](#update-secrets). +### Studio authentication -By default, the database is not accessible from outside the local machine but the pooler is. You can [change this](#exposing-your-postgres-database) by updating the `docker-compose.yml` file. +Access to Studio dashboard and internal API is protected with **HTTP basic authentication**. -You may also want to connect to your Postgres database via an ORM or another direct method other than `psql`. + -For this you can use the standard Postgres connection string. -You can find the environment values mentioned below in the `.env` file which will be covered in the next section. + The default password MUST be changed before starting Supabase. -``` -postgres://postgres:[POSTGRES_PASSWORD]@[your-server-ip]:5432/[POSTGRES_DB] -``` + -## Updating your services +Change the password in the `.env` file: -For security reasons, we "pin" the versions of each service in the docker-compose file (these versions are updated ~monthly). If you want to update any services immediately, you can do so by updating the version number in the docker compose file and then running `docker compose pull`. You can find all the latest docker images in the [Supabase Docker Hub](https://hub.docker.com/u/supabase). +- `DASHBOARD_PASSWORD`: password for Studio / dashboard -You should update your services frequently to get the latest features and bug fixes and security patches. Note that you will need to restart the services to pick up the changes, which will result in some downtime for your services. +The password must include at least one letter (do not use numbers only). -**Example** -You'll want to update the Studio(Dashboard) frequently to get the latest features and bug fixes. To update the Dashboard: +Optionally change the user: -1. Visit the [supabase/studio](https://hub.docker.com/r/supabase/studio/tags) image in the [Supabase Docker Hub](https://hub.docker.com/u/supabase) -2. Find the latest version (tag) number. It will look something like `20241029-46e1e40` -3. Update the `image` field in the `docker-compose.yml` file to the new version. It should look like this: `image: supabase/studio:20241028-a265374` -4. Run `docker compose pull` and then `docker compose up -d` to restart the service with the new version. +- `DASHBOARD_USERNAME`: username for Studio / dashboard -## Securing your services +## Starting and stopping -While we provided you with some example secrets for getting started, you should NEVER deploy your Supabase setup using the defaults we have provided. Follow all of the steps in this section to ensure you have a secure setup, and then [restart all services](#restarting-all-services) to pick up the changes. +You can start all services by using the following command in the same directory as your `docker-compose.yml` file: -### Generate API keys +```sh +# Start the services (in detached mode) +docker compose up -d +``` -We need to generate secure keys for accessing your services. We'll use the `JWT Secret` to generate `anon` and `service` API keys using the form below. +After all the services have started you can see them running in the background: -1. **Obtain a Secret**: Use the 40-character secret provided, or create your own. If creating, ensure it's a strong, random string of 40 characters. -2. **Store Securely**: Save the secret in a secure location on your local machine. Don't share this secret publicly or commit it to version control. -3. **Generate a JWT**: Use the form below to generate a new `JWT` using your secret. +```sh +docker compose ps +``` - +After a minute or less, all services should have a status `Up [...] (healthy)`. If you see a status such as `created` but not `Up`, try inspecting the Docker logs for a specific container, e.g., -### Update API keys +```sh +docker compose logs analytics +``` -Run this form twice to generate new `anon` and `service` API keys. Replace the values in the `./docker/.env` file: +To stop Supabase, use: -- `ANON_KEY` - replace with an `anon` key -- `SERVICE_ROLE_KEY` - replace with a `service` key +```sh +docker compose down +``` -You will need to [restart](#restarting-all-services) the services for the changes to take effect. +## Accessing Supabase services -### Update secrets +After the Supabase services are configured and running, you can access the dashboard, connect to the database, and use edge functions. -Update the `./docker/.env` file with your own secrets. In particular, these are required: +### Accessing Supabase Studio -- `POSTGRES_PASSWORD`: the password for the `postgres` role. -- `JWT_SECRET`: used by PostgREST and GoTrue, among others. -- `SITE_URL`: the base URL of your site. -- `SMTP_*`: mail server credentials. You can use any SMTP server. -- `POOLER_TENANT_ID`: the tenant-id that will be used by Supavisor pooler for your connection string -- `PG_META_CRYPTO_KEY`: encryption key for securing connection strings between Studio and postgres-meta -- `SECRET_KEY_BASE`: encryption key for securing Realtime and Supavisor communications. (Must be at least 64 characters; generate with `openssl rand -base64 48`) +You can access Supabase Studio through the API gateway on port `8000`. -You will need to [restart](#restarting-all-services) the services for the changes to take effect. +For example: `http://example.com:8000`, or `http://:8000` (or `localhost:8000` if you are running Docker Compose locally). -### Dashboard authentication +You will be prompted for a username and password. Use the credentials that you set up earlier in [Studio authentication](#studio-authentication). -The Dashboard is protected with basic authentication. The default user and password MUST be updated before using Supabase in production. -Update the following values in the `./docker/.env` file: +### Accessing Postgres + +By default, the Supabase stack provides the [Supavisor](https://supabase.github.io/supavisor/development/docs/) connection pooler for accessing Postgres and managing database connections. -- `DASHBOARD_USERNAME`: The default username for the Dashboard -- `DASHBOARD_PASSWORD`: The default password for the Dashboard +You can connect to the Postgres database via Supavisor using the methods described below. Use your domain name, your server IP, or `localhost` depending on whether you are running self-hosted Supabase on a VPS, or locally. -You can also add more credentials for multiple users in `./docker/volumes/api/kong.yml`. For example: +The default POOLER_TENANT_ID is `your-tenant-id` (can be changed in `.env`), and the password is the one you set previously in [Configure database password](#configure-database-password). -```yaml docker/volumes/api/kong.yml -basicauth_credentials: - - consumer: DASHBOARD - username: user_one - password: password_one - - consumer: DASHBOARD - username: user_two - password: password_two +For session-based connections (equivalent to a direct Postgres connection): + +```sh +psql 'postgres://postgres.[POOLER_TENANT_ID]:[POSTGRES_PASSWORD]@[your-domain]:5432/postgres' ``` -To enable all dashboard features outside of `localhost`, update the following value in the `./docker/.env` file: +For pooled transactional connections: -- `SUPABASE_PUBLIC_URL`: The URL or IP used to access the dashboard +```sh +psql 'postgres://postgres.[POOLER_TENANT_ID]:[POSTGRES_PASSWORD]@[your-domain]:6543/postgres' +``` -You will need to [restart](#restarting-all-services) the services for the changes to take effect. +When using `psql` with command-line parameters instead of a connection string to connect to Supavisor, the `-U` parameter should also be `postgres.[POOLER_TENANT_ID]`, and not just `postgres`. -## Restarting all services +If for some reason you need to configure Postgres to be directly accessible from the Internet, read [Exposing your Postgres database](#exposing-your-postgres-database) below. -You can restart services to pick up any configuration changes by running: +### Accessing Edge Functions -```sh -# Stop and remove the containers -docker compose down +Edge Functions are stored in `volumes/functions`. The default setup has a `hello` function that you can invoke on `http://:8000/functions/v1/hello`. -# Recreate and start the containers -docker compose up -d -``` +You can add new Functions as `volumes/functions//index.ts`. Restart the `functions` service to pick up the changes: `docker compose restart functions --no-deps` -{/* supa-mdx-lint-disable-next-line Rule004ExcludeWords */} +### Accessing the APIs -Be aware that this will result in downtime. Simply restarting the services does not apply configuration changes. +Each of the APIs is available through the same API gateway: -## Stopping all services +- REST: `http://:8000/rest/v1/` +- Auth: `http://:8000/auth/v1/` +- Storage: `http://:8000/storage/v1/` +- Realtime: `http://:8000/realtime/v1/` + +## Updating + +We publish stable releases of the Docker Compose setup approximately once a month. To update, pull the latest changes from the repository and restart the services. If you want to run different versions of individual services, you can change the image tags in the Docker Compose file, but compatibility is not guaranteed. All Supabase images are available on [Docker Hub](https://hub.docker.com/u/supabase). + +To follow the changes and updates, refer to the self-hosted Supabase [changelog](https://github.com/supabase/supabase/blob/master/docker/CHANGELOG.md). -You can stop Supabase by running `docker compose stop` in same directory as your `docker-compose.yml` file. +You need to restart services to pick up the changes, which may result in downtime for your applications and users. + +**Example:** +You'd like to update or rollback the Studio image. Follow the steps below: + +1. Check the [supabase/studio](https://hub.docker.com/r/supabase/studio/tags) images on [Supabase Docker Hub](https://hub.docker.com/u/supabase) +2. Find the latest version (tag) number. It looks something like `2025.11.26-sha-8f096b5` +3. Update the `image` field in the `docker-compose.yml` file. It should look like this: `image: supabase/studio:2025.11.26-sha-8f096b5` +4. Run `docker compose pull`, followed by `docker compose down && docker compose up -d` to restart Supabase. ## Uninstalling -You can stop Supabase by running the following in same directory as your `docker-compose.yml` file: +To uninstall, stop Supabase (while in the same directory as your `docker-compose.yml` file): ```sh # Stop docker and remove volumes: docker compose down -v - -# Remove Postgres data: -rm -rf volumes/db/data/ ``` -This will destroy all data in the database and storage volumes, so be careful! + -## Managing your secrets + Be careful — the following destroys all data, including the database and storage volumes! -Many components inside Supabase use secure secrets and passwords. These are listed in the self-hosting [env file](https://github.com/supabase/supabase/blob/master/docker/.env.example), but we strongly recommend using a secrets manager when deploying to production. Plain text files like dotenv lead to accidental costly leaks. + -Some suggested systems include: +Remove all Postgres data: -- [Doppler](https://www.doppler.com/) -- [Infisical](https://infisical.com/) -- [Key Vault](https://docs.microsoft.com/en-us/azure/key-vault/general/overview) by Azure (Microsoft) -- [Secrets Manager](https://aws.amazon.com/secrets-manager/) by AWS -- [Secrets Manager](https://cloud.google.com/secret-manager) by GCP -- [Vault](https://www.hashicorp.com/products/vault) by HashiCorp +```sh +rm -rf volumes/db/data/ +``` -## Advanced +## Advanced topics Everything beyond this point in the guide helps you understand how the system works and how you can modify it to suit your needs. ### Architecture -Supabase is a combination of open source tools, each specifically chosen for Enterprise-readiness. +Supabase is a combination of open source tools specifically developed for enterprise-readiness. -If the tools and communities already exist, with an MIT, Apache 2, or equivalent open license, we will use and support that tool. -If the tool doesn't exist, we build and open source it ourselves. +If the tools and communities already exist, with an MIT, Apache 2, or equivalent open source license, we will use and support that tool. If the tool doesn't exist, we build and open source it ourselves. Diagram showing the architecture of Supabase. The Kong API gateway sits in front of 7 services: GoTrue, PostgREST, Realtime, Storage, pg_meta, Functions, and pg_graphql. All the services talk to a single Postgres instance. -- [Kong](https://github.com/Kong/kong) is a cloud-native API gateway. -- [GoTrue](https://github.com/supabase/gotrue) is an JWT based API for managing users and issuing JWT tokens. -- [PostgREST](http://postgrest.org/) is a web server that turns your Postgres database directly into a RESTful API -- [Realtime](https://github.com/supabase/realtime) is an Elixir server that allows you to listen to Postgres inserts, updates, and deletes using WebSockets. Realtime polls Postgres' built-in replication functionality for database changes, converts changes to JSON, then broadcasts the JSON over WebSockets to authorized clients. -- [Storage](https://github.com/supabase/storage-api) provides a RESTful interface for managing Files stored in S3, using Postgres to manage permissions. -- [`postgres-meta`](https://github.com/supabase/postgres-meta) is a RESTful API for managing your Postgres, allowing you to fetch tables, add roles, and run queries, etc. -- [Postgres](https://www.postgresql.org/) is an object-relational database system with over 30 years of active development that has earned it a strong reputation for reliability, feature robustness, and performance. -- [Supavisor](https://github.com/supabase/supavisor) is a scalable connection pooler for Postgres, allowing for efficient management of database connections. +- **[Studio](https://github.com/supabase/supabase/tree/master/apps/studio)** - A dashboard for managing your self-hosted Supabase project +- **[Kong](https://github.com/Kong/kong)** - Kong API gateway +- **[GoTrue](https://github.com/supabase/auth)** - JWT-based authentication API for user sign-ups, logins, and session management +- **[PostgREST](https://github.com/PostgREST/postgrest)** - Web server that turns your Postgres database directly into a RESTful API +- **[Realtime](https://github.com/supabase/realtime)** - Elixir server that listens to Postgres database changes and broadcasts them to subscribed clients +- **[Storage](https://github.com/supabase/storage)** - RESTful API for managing files in S3, with Postgres handling permissions +{/* supa-mdx-lint-disable-next-line Rule003Spelling */} +- **[ImgProxy](https://github.com/imgproxy/imgproxy)** - Fast and secure image processing server +- **[postgres-meta](https://github.com/supabase/postgres-meta)** - RESTful API for managing Postgres (fetch tables, add roles, run queries) +{/* supa-mdx-lint-disable-next-line Rule004ExcludeWords */} +- **[PostgreSQL](https://github.com/supabase/postgres)** - Object-relational database with over 30 years of active development +- **[Edge Runtime](https://github.com/supabase/edge-runtime)** - Web server based on Deno runtime for running JavaScript, TypeScript, and WASM services +- **[Logflare](https://github.com/Logflare/logflare)** - Log management and event analytics platform +- **[Vector](https://github.com/vectordotdev/vector)** - High-performance observability data pipeline for logs +- **[Supavisor](https://github.com/supabase/supavisor)** - Supabase's Postgres connection pooler -For the system to work cohesively, some services require additional configuration within the Postgres database. For example, the APIs and Auth system require several [default roles](/docs/guides/database/postgres/roles) and the `pgjwt` Postgres extension. +For the system to work cohesively, some services require additional configuration within the Postgres database. For example, the APIs and Auth system require several [default roles](/docs/guides/database/postgres/roles#supabase-roles). You can find all the default extensions inside the [schema migration scripts repo](https://github.com/supabase/postgres/tree/develop/migrations). These scripts are mounted at `/docker-entrypoint-initdb.d` to run automatically when starting the database container. ### Configuring services -Each system has a number of configuration options which can be found in the relevant product documentation. - -- [Postgres](https://hub.docker.com/_/postgres/) -- [PostgREST](https://postgrest.org/en/stable/configuration.html) -- [Realtime](https://github.com/supabase/realtime#server) -- [Auth](https://github.com/supabase/auth) -- [Storage](https://github.com/supabase/storage-api) -- [Kong](https://docs.konghq.com/gateway/latest/install/docker/) -- [Supavisor](https://supabase.github.io/supavisor/development/docs/) +Each service has a number of configuration options you can find in the related documentation. -These configuration items are generally added to the `env` section of each service, inside the `docker-compose.yml` section. If these configuration items are sensitive, they should be stored in a [secret manager](/docs/guides/self-hosting#managing-your-secrets) or using an `.env` file and then referenced using the `${}` syntax. +Configuration options are generally added to the `.env` file and referenced in `docker-compose.yml` service definitions, e.g., <$CodeTabs> @@ -361,9 +379,9 @@ JWT_SECRET=${JWT_SECRET} -### Common configuration +### Common configuration tasks -Each system can be [configured](../self-hosting#configuration) independently. Some of the most common configuration options are listed below. +You can configure each Supabase service separately through environment variables and configuration files. Below are the most common configuration options. #### Configuring an email server @@ -420,25 +438,39 @@ By default, `docker compose` sets the database's `log_min_messages` configuratio #### Accessing Postgres through Supavisor -By default, the Postgres database is accessible through the Supavisor connection pooler. This allows for more efficient management of database connections. You can connect to the pooled database using the `POOLER_PROXY_PORT_TRANSACTION` port and `POSTGRES_PORT` for session based connections. +By default, Postgres connections go through the Supavisor connection pooler for efficient connection management. Two ports are available: + +- `POSTGRES_PORT` (default: 5432) – Session mode, behaves like a direct Postgres connection +- `POOLER_PROXY_PORT_TRANSACTION` (default: 6543) – Transaction mode, uses connection pooling For more information on configuring and using Supavisor, see the [Supavisor documentation](https://supabase.github.io/supavisor/). #### Exposing your Postgres database -If you need direct access to the Postgres database without going through Supavisor, you can expose it by updating the `docker-compose.yml` file: +By default, Postgres is only accessible through Supavisor. If you need direct access to the database (bypassing the connection pooler), you need to disable Supavisor and expose the Postgres port. + + + + Exposing Postgres directly bypasses connection pooling and exposes your database to the network. Configure firewall rules or network policies to restrict access to trusted IPs only. + + + +Update `docker-compose.yml`: + +1. **Disable Supavisor** - Comment out or remove the entire `supavisor` service section +2. **Expose Postgres port** - Add the port mapping to the `db` service: ```yaml docker-compose.yml -# Comment or remove the supavisor section of the docker-compose file -# supavisor: -# ports: -# ... db: ports: - ${POSTGRES_PORT}:${POSTGRES_PORT} ``` -This is less-secure, so make sure you are running a firewall in front of your server. +You can then connect to the database directly using a standard Postgres connection string: + +```sh +postgres://postgres:[POSTGRES_PASSWORD]@[your-server-ip]:5432/[POSTGRES_DB] +``` #### File storage backend on macOS @@ -454,7 +486,7 @@ Due to the changes in the Analytics server, you will need to run the following c -All data in analytics will be deleted when you run the commands below. + All data in analytics will be deleted when you run the commands below. @@ -469,12 +501,23 @@ DROP PUBLICATION logflare_pub; DROP SCHEMA _analytics CASCADE; CREATE SCHEMA _an docker rm supabase-analytics ``` +## Managing your secrets + +Many components inside Supabase use secure secrets and passwords. These are listed in the self-hosting [env file](https://github.com/supabase/supabase/blob/master/docker/.env.example), but we strongly recommend using a secrets manager when deploying to production. + +Some suggested systems include: + +- [Doppler](https://www.doppler.com/) +- [Infisical](https://infisical.com/) +- [Key Vault](https://docs.microsoft.com/en-us/azure/key-vault/general/overview) by Azure (Microsoft) +- [Secrets Manager](https://aws.amazon.com/secrets-manager/) by AWS +- [Secrets Manager](https://cloud.google.com/secret-manager) by GCP +- [Vault](https://www.hashicorp.com/products/vault) by HashiCorp + --- ## Demo -A minimal setup working on Ubuntu, hosted on DigitalOcean. -
-### Demo using DigitalOcean - -1. A DigitalOcean Droplet with 1 GB memory and 25 GB solid-state drive (SSD) is sufficient to start -2. To access the Dashboard, use the ipv4 IP address of your Droplet. -3. If you're unable to access Dashboard, run `docker compose ps` to see if the Studio service is running and healthy. +1. The VPS instance is a DigitalOcean droplet. (For server requirements refer to [System requirements](#system-requirements)) +2. To access Studio, use the IPv4 IP address of your Droplet. +3. If you're unable to use Studio, run `docker compose ps` to see if all services are up and healthy. diff --git a/apps/docs/features/docs/MdxBase.shared.tsx b/apps/docs/features/docs/MdxBase.shared.tsx index 94f8b90eda056..41a168c4a22dc 100644 --- a/apps/docs/features/docs/MdxBase.shared.tsx +++ b/apps/docs/features/docs/MdxBase.shared.tsx @@ -13,7 +13,7 @@ import { AuthSmsProviderConfig } from '~/components/AuthSmsProviderConfig' import { CostWarning } from '~/components/AuthSmsProviderConfig/AuthSmsProviderConfig.Warnings' import ButtonCard from '~/components/ButtonCard' import { Extensions } from '~/components/Extensions' -import { JwtGenerator } from '~/components/JwtGenerator' +import { JwtGenerator, JwtGeneratorSimple } from '~/components/JwtGenerator' import { MetricsStackCards } from '~/components/MetricsStackCards' import { NavData } from '~/components/NavData' import { Price } from '~/components/Price' @@ -59,6 +59,7 @@ const components = { IconX: X, Image: (props: any) => , JwtGenerator, + JwtGeneratorSimple, Link, McpConfigPanel, MetricsStackCards, From 3fdacb2cca95da4451e25657626845bbbf19e1eb Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Fri, 12 Dec 2025 19:23:24 +1000 Subject: [PATCH 6/9] chore(studio): delete Confirm Modal component (#41295) * docs * naming * docs * mini fix * update policies component * use destructured prop * rls policies dialogs * custom domain dialogs * restart server dialog * remove ConfirmDialog (aka ConfirmModal) * remove unrelated changes * delete unrelated file * language * Update apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fixed broken test --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Ali Waseem --- apps/design-system/__registry__/index.tsx | 11 -- apps/design-system/registry/fragments.ts | 6 -- .../interfaces/Auth/Policies/Policies.tsx | 57 +++++----- .../CustomDomainActivate.tsx | 81 +++++--------- .../CustomDomainConfig/CustomDomainDelete.tsx | 55 +++++----- .../CustomDomainConfig/CustomDomainVerify.tsx | 70 +++++------- .../Infrastructure/RestartServerButton.tsx | 21 ++-- .../StoragePolicies/StoragePolicies.tsx | 37 ++++--- .../TableGridEditor/GridHeaderActions.tsx | 22 ++-- .../components/side-navigation-item.tsx | 6 +- e2e/studio/features/rls-policies.spec.ts | 4 +- packages/ui-patterns/package.json | 4 - .../ui-patterns/src/Dialogs/ConfirmDialog.tsx | 100 ------------------ .../src/Dialogs/ConfirmationModal.tsx | 2 +- 14 files changed, 161 insertions(+), 315 deletions(-) delete mode 100644 packages/ui-patterns/src/Dialogs/ConfirmDialog.tsx diff --git a/apps/design-system/__registry__/index.tsx b/apps/design-system/__registry__/index.tsx index a1c90d5fc24a2..b668b042da043 100644 --- a/apps/design-system/__registry__/index.tsx +++ b/apps/design-system/__registry__/index.tsx @@ -38,17 +38,6 @@ export const Index: Record = { subcategory: "undefined", chunks: [] }, - "ConfirmDialog": { - name: "ConfirmDialog", - type: "components:fragment", - registryDependencies: undefined, - component: React.lazy(() => import("@/../../packages/ui-patterns/src/Dialogs/ConfirmDialog")), - source: "", - files: ["registry/default//Dialogs/ConfirmDialog.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, "PageContainer": { name: "PageContainer", type: "components:fragment", diff --git a/apps/design-system/registry/fragments.ts b/apps/design-system/registry/fragments.ts index d35061a82ac25..3a71fbcdb47a1 100644 --- a/apps/design-system/registry/fragments.ts +++ b/apps/design-system/registry/fragments.ts @@ -19,12 +19,6 @@ export const fragments: Registry = [ files: ['/Dialogs/TextConfirmModal.tsx'], optionalPath: '/Dialogs', }, - { - name: 'ConfirmDialog', - type: 'components:fragment', - files: ['/Dialogs/ConfirmDialog.tsx'], - optionalPath: '/Dialogs', - }, { name: 'PageContainer', type: 'components:fragment', diff --git a/apps/studio/components/interfaces/Auth/Policies/Policies.tsx b/apps/studio/components/interfaces/Auth/Policies/Policies.tsx index c1a02eae47390..6972d72c4f961 100644 --- a/apps/studio/components/interfaces/Auth/Policies/Policies.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/Policies.tsx @@ -15,7 +15,7 @@ import { useDatabasePolicyDeleteMutation } from 'data/database-policies/database import { useTableUpdateMutation } from 'data/tables/table-update-mutation' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Button, Card, CardContent } from 'ui' -import ConfirmModal from 'ui-patterns/Dialogs/ConfirmDialog' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' interface PoliciesProps { search?: string @@ -51,7 +51,7 @@ export const Policies = ({ }>() const [selectedPolicyToDelete, setSelectedPolicyToDelete] = useState({}) - const { mutate: updateTable } = useTableUpdateMutation({ + const { mutate: updateTable, isPending: isUpdatingTable } = useTableUpdateMutation({ onError: (error) => { toast.error(`Failed to toggle RLS: ${error.message}`) }, @@ -60,14 +60,15 @@ export const Policies = ({ }, }) - const { mutate: deleteDatabasePolicy } = useDatabasePolicyDeleteMutation({ - onSuccess: () => { - toast.success('Successfully deleted policy!') - }, - onSettled: () => { - closeConfirmModal() - }, - }) + const { mutate: deleteDatabasePolicy, isPending: isDeletingPolicy } = + useDatabasePolicyDeleteMutation({ + onSuccess: () => { + toast.success('Successfully deleted policy!') + }, + onSettled: () => { + closeConfirmModal() + }, + }) const closeConfirmModal = useCallback(() => { setSelectedPolicyToDelete({}) @@ -180,30 +181,30 @@ export const Policies = ({ ) : null}
- - ) diff --git a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainActivate.tsx b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainActivate.tsx index d9b155257664c..7ae4948710253 100644 --- a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainActivate.tsx +++ b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainActivate.tsx @@ -1,4 +1,3 @@ -import { AlertCircle } from 'lucide-react' import { useState } from 'react' import { toast } from 'sonner' @@ -10,8 +9,9 @@ import { useCustomDomainActivateMutation } from 'data/custom-domains/custom-doma import { useCustomDomainDeleteMutation } from 'data/custom-domains/custom-domains-delete-mutation' import type { CustomDomainResponse } from 'data/custom-domains/custom-domains-query' import { DOCS_URL } from 'lib/constants' -import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' +import { Button } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { Admonition } from 'ui-patterns/admonition' export type CustomDomainActivateProps = { projectRef?: string @@ -59,36 +59,33 @@ const CustomDomainActivate = ({ projectRef, customDomain }: CustomDomainActivate
-

- Setup complete! Press activate to enable the custom domain{' '} - {customDomain.hostname} for this project. -

- +

Enable your custom domain

+

+ Set up is almost complete. Press “Activate” below to enable{' '} + {customDomain.hostname} for this project. +

+

We recommend that you schedule a downtime window of 20 - 30 minutes for your application, as you will need to update any services that need to know about your - custom domain (e.g client side code or OAuth providers) - + custom domain (e.g client side code or OAuth providers). +

- - - - Remember to retain your CNAME record for service continuity after activation - - -

- Your custom domain CNAME record for{' '} - {customDomain.hostname} should resolve - to{' '} - {endpoint ? ( - {endpoint} - ) : ( - "your project's API URL" - )} - . If you're using Cloudflare as your DNS provider, disable the proxy option. -

-
-
+ +

+ Your custom domain CNAME record for{' '} + {customDomain.hostname} should resolve to{' '} + {endpoint ? ( + {endpoint} + ) : ( + "your project's API URL" + )} + . If you're using Cloudflare as your DNS provider, disable the proxy option. +

+
@@ -107,22 +104,6 @@ const CustomDomainActivate = ({ projectRef, customDomain }: CustomDomainActivate Cancel
- - Are you sure you want to delete the custom domain{' '} - {customDomain.hostname} for the project? - - } - description="Your custom domain will be deactivated. You will need to re-verify your domain if you want to use it again." - buttonLabel="Delete" - buttonLoadingLabel="Deleting" - onSelectCancel={() => setIsDeleteConfirmModalVisible(false)} - onSelectConfirm={onDeleteCustomDomain} - /> + variant="destructive" + title="Delete custom domain" + confirmLabel="Delete" + confirmLabelLoading="Deleting" + loading={isDeletingCustomDomain} + onCancel={() => setIsDeleteConfirmModalVisible(false)} + onConfirm={onDeleteCustomDomain} + > +

+ Are you sure you want to delete the custom domain{' '} + {customDomain.hostname} for your + project? You will need to re-verify this domain if you want to use it again. +

+
) } diff --git a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainVerify.tsx b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainVerify.tsx index 56496082cbc3c..943ac726035f2 100644 --- a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainVerify.tsx +++ b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainVerify.tsx @@ -1,9 +1,9 @@ -import { AlertCircle, HelpCircle, RefreshCw } from 'lucide-react' -import Link from 'next/link' +import { AlertCircle, RefreshCw } from 'lucide-react' import { toast } from 'sonner' import { useParams } from 'common' import { DocsButton } from 'components/ui/DocsButton' +import { InlineLink } from 'components/ui/InlineLink' import Panel from 'components/ui/Panel' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCustomDomainDeleteMutation } from 'data/custom-domains/custom-domains-delete-mutation' @@ -18,6 +18,7 @@ import { Button, WarningIcon, } from 'ui' +import { Admonition } from 'ui-patterns/admonition' import DNSRecord from './DNSRecord' import { DNSTableHeaders } from './DNSTableHeaders' @@ -83,7 +84,7 @@ const CustomDomainVerify = () => {

Configure TXT verification for your custom domain{' '} - {customDomain?.hostname} + {customDomain?.hostname}

Set the following TXT record(s) in your DNS provider, then click verify to confirm your @@ -94,47 +95,30 @@ const CustomDomainVerify = () => {

{!isValidating && (
- - {isNotVerifiedYet ? ( - - ) : ( - - )} - - {isNotVerifiedYet + - -
- {isNotVerifiedYet && ( -

- Please check again soon. Note that it may take up to 24 hours for changes in - DNS records to propagate. -

- )} -

- You may also visit{' '} - - here - {' '} - to check if your DNS has been propagated successfully before clicking verify. -

- {isNotVerifiedYet && ( -

- Some registrars will require you to remove the domain name when creating DNS - records. As an example, to create a record for `foo.app.example.com`, you - would need to create an entry for `foo.app`. -

- )} -
-
-
+ : 'Please note that it may take up to 24 hours for the DNS records to propagate.' + } + > +

+ You may also visit{' '} + + here + {' '} + to check if your DNS has been propagated successfully before clicking verify. +

+ {isNotVerifiedYet && ( +

+ Some registrars will require you to remove the domain name when creating DNS + records. As an example, to create a record for{' '} + foo.app.example.com, you would need to + create an entry for foo.app. +

+ )} +
)}
diff --git a/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx b/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx index c36605d056182..b2a9580b975be 100644 --- a/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx +++ b/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx @@ -22,7 +22,7 @@ import { DropdownMenuTrigger, cn, } from 'ui' -import ConfirmModal from 'ui-patterns/Dialogs/ConfirmDialog' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' const RestartServerButton = () => { const router = useRouter() @@ -169,22 +169,21 @@ const RestartServerButton = () => { )} - - Are you sure you want to restart the{' '} - {serviceToRestart}? There will be a few minutes - of downtime. + Are you sure you want to restart your {serviceToRestart}? There will be a few minutes of + downtime. } - buttonLabel="Restart" - buttonLoadingLabel="Restarting" - onSelectCancel={() => setServiceToRestart(undefined)} - onSelectConfirm={async () => { + confirmLabel="Restart" + confirmLabelLoading="Restarting" + loading={isLoading} + onCancel={() => setServiceToRestart(undefined)} + onConfirm={async () => { if (serviceToRestart === 'project') { await requestProjectRestart() } else if (serviceToRestart === 'database') { diff --git a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx index a602a9e005103..09f21e6e0937f 100644 --- a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx @@ -14,7 +14,7 @@ import { usePaginatedBucketsQuery } from 'data/storage/buckets-query' import { useDebouncedValue } from 'hooks/misc/useDebouncedValue' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { GenericSkeletonLoader } from 'ui-patterns' -import ConfirmModal from 'ui-patterns/Dialogs/ConfirmDialog' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { PageContainer } from 'ui-patterns/PageContainer' import { PageSection, @@ -73,13 +73,17 @@ export const StoragePolicies = () => { onError: () => {}, }) const { mutateAsync: updateDatabasePolicy } = useDatabasePolicyUpdateMutation() - const { mutate: deleteDatabasePolicy } = useDatabasePolicyDeleteMutation({ - onSuccess: async () => { - await refetch() - toast.success('Successfully deleted policy!') - setSelectedPolicyToDelete(undefined) - }, - }) + const { mutate: deleteDatabasePolicy, isPending: isDeletingPolicy } = + useDatabasePolicyDeleteMutation({ + onSuccess: async () => { + await refetch() + toast.success('Successfully deleted policy!') + setSelectedPolicyToDelete(undefined) + }, + onError: (error: any) => { + toast.error(`Failed to delete policy: ${error.message}`) + }, + }) // Only use storage policy editor when creating new policies for buckets const showStoragePolicyEditor = @@ -318,15 +322,16 @@ export const StoragePolicies = () => { onSaveSuccess={onSavePolicySuccess} /> - ) diff --git a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx index 83781c793ac0a..cab44ab4f0f4c 100644 --- a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx @@ -43,7 +43,6 @@ import { TooltipTrigger, cn, } from 'ui' -import ConfirmModal from 'ui-patterns/Dialogs/ConfirmDialog' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { RoleImpersonationPopover } from '../RoleImpersonationSelector/RoleImpersonationPopover' import ViewEntityAutofixSecurityModal from './ViewEntityAutofixSecurityModal' @@ -78,7 +77,7 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp const { realtimeAll: realtimeEnabled } = useIsFeatureEnabled(['realtime:all']) const { isSchemaLocked } = useIsProtectedSchema({ schema: table.schema }) - const { mutate: updateTable } = useTableUpdateMutation({ + const { mutate: updateTable, isPending: isUpdatingTable } = useTableUpdateMutation({ onError: (error) => { toast.error(`Failed to toggle RLS: ${error.message}`) }, @@ -592,15 +591,18 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp /> {isTable && ( - )} diff --git a/apps/ui-library/components/side-navigation-item.tsx b/apps/ui-library/components/side-navigation-item.tsx index df1b7b0389c0b..74c8b636cd748 100644 --- a/apps/ui-library/components/side-navigation-item.tsx +++ b/apps/ui-library/components/side-navigation-item.tsx @@ -108,11 +108,7 @@ const NavigationItem: React.FC = ({ item, onClick, ...props )} /> {item.title} - {item.new && ( - - New - - )} + {item.new && New} ) } diff --git a/e2e/studio/features/rls-policies.spec.ts b/e2e/studio/features/rls-policies.spec.ts index 1b33676a6e517..98b55f0f096c5 100644 --- a/e2e/studio/features/rls-policies.spec.ts +++ b/e2e/studio/features/rls-policies.spec.ts @@ -200,12 +200,12 @@ test.describe.serial('RLS Policies', () => { // A confirmation modal appears when toggling RLS from the policies page await expect( - page.getByRole('heading', { name: 'Confirm to disable Row Level Security' }), + page.getByRole('heading', { name: 'Disable Row Level Security' }), 'RLS disable confirmation modal should appear' ).toBeVisible({ timeout: 50000 }) // Confirm disabling RLS - await page.getByRole('button', { name: 'Confirm' }).click() + await page.getByRole('button', { name: 'Disable RLS' }).click() // After confirming, the toggle button text should change to "Enable RLS" await expect( diff --git a/packages/ui-patterns/package.json b/packages/ui-patterns/package.json index 915a6e99e924c..e55f201de6d75 100644 --- a/packages/ui-patterns/package.json +++ b/packages/ui-patterns/package.json @@ -278,10 +278,6 @@ "import": "./src/DataInputs/Input.tsx", "types": "./src/DataInputs/Input.tsx" }, - "./Dialogs/ConfirmDialog": { - "import": "./src/Dialogs/ConfirmDialog.tsx", - "types": "./src/Dialogs/ConfirmDialog.tsx" - }, "./Dialogs/ConfirmationModal": { "import": "./src/Dialogs/ConfirmationModal.tsx", "types": "./src/Dialogs/ConfirmationModal.tsx" diff --git a/packages/ui-patterns/src/Dialogs/ConfirmDialog.tsx b/packages/ui-patterns/src/Dialogs/ConfirmDialog.tsx deleted file mode 100644 index 24a6d10f8b378..0000000000000 --- a/packages/ui-patterns/src/Dialogs/ConfirmDialog.tsx +++ /dev/null @@ -1,100 +0,0 @@ -'use client' - -import { useEffect, useState } from 'react' -import { Button, Form, Modal } from 'ui' - -// [Joshen] As of 280222, let's just use ConfirmationModal as the one and only confirmation modal (Deprecate this) - -interface ConfirmModalProps { - visible: boolean - danger?: boolean - title: string - description: string - size?: 'tiny' | 'small' | 'medium' | 'large' | 'xlarge' | 'xxlarge' - buttonLabel: string - buttonLoadingLabel?: string - onSelectCancel: () => void - onSelectConfirm: () => void -} - -/** @deprecated use ConfirmationModal instead */ -const ConfirmModal = ({ - visible = false, - danger = false, - title = '', - description = '', - size = 'small', - buttonLabel = '', - buttonLoadingLabel = '', - onSelectCancel = () => {}, - onSelectConfirm = () => {}, -}: ConfirmModalProps) => { - useEffect(() => { - if (visible) { - setLoading(false) - } - }, [visible]) - - const [loading, setLoading] = useState(false) - - const onConfirm = () => { - setLoading(true) - onSelectConfirm() - } - - return ( - -
onConfirm()} - validate={() => { - return [] - }} - > - {() => { - return ( - <> - -
- - -
-
- - ) - }} -
-
- ) -} - -export default ConfirmModal diff --git a/packages/ui-patterns/src/Dialogs/ConfirmationModal.tsx b/packages/ui-patterns/src/Dialogs/ConfirmationModal.tsx index a71b0a4915d79..b4420c6af9717 100644 --- a/packages/ui-patterns/src/Dialogs/ConfirmationModal.tsx +++ b/packages/ui-patterns/src/Dialogs/ConfirmationModal.tsx @@ -141,7 +141,7 @@ export const ConfirmationModal = forwardRef< onClick={onSubmit} className="truncate" > - {confirmLabel} + {loading && confirmLabelLoading ? confirmLabelLoading : confirmLabel} From 28e4473953a201a3da127af3e24b308afa9780e8 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Fri, 12 Dec 2025 19:32:38 +1000 Subject: [PATCH 7/9] Update account/me page components (#41193) * update account me page * copy * Update apps/studio/components/interfaces/Account/Preferences/ProfileInformation.tsx Co-authored-by: Francesco Sansalvadore * Update apps/studio/components/interfaces/Account/Preferences/ProfileInformation.tsx Co-authored-by: Francesco Sansalvadore * Update apps/studio/components/interfaces/Account/Preferences/ProfileInformation.tsx Co-authored-by: Francesco Sansalvadore * fixes * fix sizes * Smol fix --------- Co-authored-by: Francesco Sansalvadore Co-authored-by: Joshen Lim --- .../Preferences/AccountConnections.tsx | 203 +++++++------- .../Account/Preferences/AccountDeletion.tsx | 54 ++-- .../Account/Preferences/AccountIdentities.tsx | 251 ++++++++++-------- .../Account/Preferences/AnalyticsSettings.tsx | 77 ++++-- .../Account/Preferences/HotkeySettings.tsx | 210 ++++++++------- .../Preferences/InlineEditorSettings.tsx | 73 +++-- .../Preferences/ProfileInformation.tsx | 239 +++++++++-------- .../Account/Preferences/ThemeSettings.tsx | 146 +++++----- .../layouts/AccountLayout/WithSidebar.tsx | 6 +- apps/studio/pages/account/me.tsx | 106 ++++---- 10 files changed, 746 insertions(+), 619 deletions(-) diff --git a/apps/studio/components/interfaces/Account/Preferences/AccountConnections.tsx b/apps/studio/components/interfaces/Account/Preferences/AccountConnections.tsx index be47dd6f31ae1..339521850b27b 100644 --- a/apps/studio/components/interfaces/Account/Preferences/AccountConnections.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/AccountConnections.tsx @@ -3,7 +3,6 @@ import Image from 'next/image' import { useState } from 'react' import { toast } from 'sonner' -import Panel from 'components/ui/Panel' import { useGitHubAuthorizationDeleteMutation } from 'data/integrations/github-authorization-delete-mutation' import { useGitHubAuthorizationQuery } from 'data/integrations/github-authorization-query' import { BASE_PATH } from 'lib/constants' @@ -11,13 +10,24 @@ import { openInstallGitHubIntegrationWindow } from 'lib/github' import { Badge, Button, + Card, + CardContent, cn, DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { + PageSection, + PageSectionContent, + PageSectionDescription, + PageSectionMeta, + PageSectionSummary, + PageSectionTitle, +} from 'ui-patterns/PageSection' import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' export const AccountConnections = () => { @@ -54,100 +64,103 @@ export const AccountConnections = () => { } return ( - -
Connections
-

- Connect your Supabase account with other services -

- - } - > - {isLoading && ( - - - - )} - {isError && ( - -

- Failed to load GitHub connection status: {error?.message} -

-
- )} - {isSuccess && ( - -
- {`GitHub -
-

GitHub

-

- Sync GitHub repos to Supabase projects for automatic branch creation and merging + + + + Connections + + Connect your Supabase account with other services. + + + + + + {isLoading && ( + + + + )} + {isError && ( + +

+ Failed to load GitHub connection status: {error?.message}

-
-
-
- {isConnected ? ( - <> - Connected - - - - - - { - event.preventDefault() - handleReauthenticate() - }} - > - -

Re-authenticate

-
- setIsRemoveModalOpen(true)} - > - -

Remove connection

-
-
-
- - ) : ( - - )} -
-
- )} - setIsRemoveModalOpen(false)} - onConfirm={handleRemove} - loading={isRemoving} - > -

- Removing this authorization will disconnect your GitHub account from Supabase. You can - reconnect at any time. -

-
-
+ + )} + {isSuccess && ( + +
+ {`GitHub +
+

GitHub

+

+ Sync repos to Supabase projects for automatic branch creation and merging +

+
+
+
+ {isConnected ? ( + <> + Connected + + + + + + { + event.preventDefault() + handleReauthenticate() + }} + > + +

Re-authenticate

+
+ + setIsRemoveModalOpen(true)} + > + +

Remove connection

+
+
+
+ + ) : ( + + )} +
+
+ )} + + setIsRemoveModalOpen(false)} + onConfirm={handleRemove} + loading={isRemoving} + > +

+ Removing this authorization will disconnect your GitHub account from Supabase. You can + reconnect at any time. +

+
+ + ) } diff --git a/apps/studio/components/interfaces/Account/Preferences/AccountDeletion.tsx b/apps/studio/components/interfaces/Account/Preferences/AccountDeletion.tsx index c7b848848bb02..2d8b9173e15dd 100644 --- a/apps/studio/components/interfaces/Account/Preferences/AccountDeletion.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/AccountDeletion.tsx @@ -1,27 +1,39 @@ -import Panel from 'components/ui/Panel' -import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_ } from 'ui' -import { CriticalIcon } from 'ui' +import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, CriticalIcon } from 'ui' +import { + PageSection, + PageSectionContent, + PageSectionDescription, + PageSectionMeta, + PageSectionSummary, + PageSectionTitle, +} from 'ui-patterns/PageSection' import { DeleteAccountButton } from './DeleteAccountButton' export const AccountDeletion = () => { return ( - <> - - - - - Request for account deletion - - Deleting your account is permanent and cannot be undone. Your data will be deleted - within 30 days, except we may retain some metadata and logs for longer where required - or permitted by law. - - - - - - - - + + + + Danger zone + + Permanently delete your Supabase account and data. + + + + + + + Request for account deletion + + Deleting your account is permanent and cannot be undone. Your data will be deleted + within 30 days, but we may retain some metadata and logs for longer where required or + permitted by law. + + + + + + + ) } diff --git a/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.tsx b/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.tsx index 8a1f9bcf4d10b..08ebeb932bd68 100644 --- a/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.tsx @@ -7,13 +7,14 @@ import { useEffect, useState } from 'react' import { toast } from 'sonner' import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import Panel from 'components/ui/Panel' import { useProfileIdentitiesQuery } from 'data/profile/profile-identities-query' import { useUnlinkIdentityMutation } from 'data/profile/profile-unlink-identity-mutation' import { BASE_PATH } from 'lib/constants' import { Badge, Button, + Card, + CardContent, cn, Dialog, DialogContent, @@ -30,6 +31,14 @@ import { GitHubChangeEmailAddress, SSOChangeEmailAddress, } from './ChangeEmailAddress' +import { + PageSection, + PageSectionContent, + PageSectionDescription, + PageSectionMeta, + PageSectionSummary, + PageSectionTitle, +} from 'ui-patterns/PageSection' const getProviderName = (provider: string) => provider === 'github' @@ -71,126 +80,134 @@ export const AccountIdentities = () => { }, [message]) return ( - <> - Account Identities}> - {isLoading && ( - - - - )} - {isSuccess && - identities.map((identity) => { - const { identity_id, provider } = identity - const username = identity.identity_data?.user_name - const providerName = getProviderName(provider) - const iconKey = - provider === 'github' - ? 'github-icon' - : provider === 'email' - ? 'email-icon2' - : 'saml-icon' + + + + Account identities + + Manage the providers linked to your Supabase account and update their details. + + + + + + {isLoading && ( + + + + )} + {isSuccess && ( +
+ {identities.map((identity) => { + const { identity_id, provider } = identity + const username = identity.identity_data?.user_name + const providerName = getProviderName(provider) + const iconKey = + provider === 'github' + ? 'github-icon' + : provider === 'email' + ? 'email-icon2' + : 'saml-icon' - return ( - 1 ? 'last:border-t' : '')} - > -
- {`${identity.provider} -
-
-

{providerName}

- {provider === 'email' && data.new_email && !isChangeExpired && ( - - - Pending change - - Changing to {data.new_email} - + return ( + +
+ {`${identity.provider} +
+
+

{providerName}

+ {provider === 'email' && data.new_email && !isChangeExpired && ( + + + Pending change + + Changing to {data.new_email} + + )} +
+

+ {!!username ? {username} • : null} + {identity.email} +

+
+
+
+ {provider === 'email' && ( + + )} + } + className="w-7" + onClick={() => setSelectedProviderUpdateEmail(provider)} + tooltip={{ content: { side: 'bottom', text: 'Update email address' } }} + /> + {identities.length > 1 && ( + } + className="w-7" + onClick={() => setSelectedProviderUnlink(provider)} + tooltip={{ content: { side: 'bottom', text: 'Unlink identity' } }} + /> )} - {/* [Joshen] Below is not supported yet, but ideal UX */} - {/* {false && Logged in as} */}
-

- {!!username ? {username} • : null} - {identity.email} -

-
-
-
- {provider === 'email' && ( - - )} - } - className="w-7" - onClick={() => setSelectedProviderUpdateEmail(provider)} - tooltip={{ content: { side: 'bottom', text: 'Update email address' } }} - /> - {identities.length > 1 && ( - } - className="w-7" - onClick={() => setSelectedProviderUnlink(provider)} - tooltip={{ content: { side: 'bottom', text: 'Unlink identity' } }} - /> - )} -
- - ) - })} - - - { - if (!open) setSelectedProviderUpdateEmail(undefined) - }} - > - - - - {selectedProviderUpdateEmail !== 'email' - ? `Updating email address for ${getProviderName(selectedProviderUpdateEmail ?? '')} identity` - : 'Update email address'} - - - {selectedProviderUpdateEmail === 'github' ? ( - - ) : selectedProviderUpdateEmail?.startsWith('sso') ? ( - - ) : ( - setSelectedProviderUpdateEmail(undefined)} /> + + ) + })} +
)} - - + + + { + if (!open) setSelectedProviderUpdateEmail(undefined) + }} + > + + + + {selectedProviderUpdateEmail !== 'email' + ? `Updating email address for ${getProviderName(selectedProviderUpdateEmail ?? '')} identity` + : 'Update email address'} + + + {selectedProviderUpdateEmail === 'github' ? ( + + ) : selectedProviderUpdateEmail?.startsWith('sso') ? ( + + ) : ( + setSelectedProviderUpdateEmail(undefined)} /> + )} + + - setSelectedProviderUnlink(undefined)} - onConfirm={onConfirmUnlinkIdentity} - confirmLabel="Unlink identity" - confirmLabelLoading="Unlinking identity" - alert={{ - base: { variant: 'warning' }, - title: `Confirm to disconnect your ${getProviderName(selectedProviderUnlink ?? '')} identity`, - description: `After disconnecting, you will only be able to sign in via ${selectedProviderUnlink === 'github' ? 'email and password' : 'your GitHub identity'}`, - }} - /> - + setSelectedProviderUnlink(undefined)} + onConfirm={onConfirmUnlinkIdentity} + confirmLabel="Unlink identity" + confirmLabelLoading="Unlinking identity" + alert={{ + base: { variant: 'warning' }, + title: `Confirm to disconnect your ${getProviderName(selectedProviderUnlink ?? '')} identity`, + description: `After disconnecting, you will only be able to sign in via ${selectedProviderUnlink === 'github' ? 'email and password' : 'your GitHub identity'}`, + }} + /> + + ) } diff --git a/apps/studio/components/interfaces/Account/Preferences/AnalyticsSettings.tsx b/apps/studio/components/interfaces/Account/Preferences/AnalyticsSettings.tsx index 35267f776f675..3b9a41739dd14 100644 --- a/apps/studio/components/interfaces/Account/Preferences/AnalyticsSettings.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/AnalyticsSettings.tsx @@ -4,10 +4,17 @@ import { toast } from 'sonner' import * as z from 'zod' import { useConsentState } from 'common' -import Panel from 'components/ui/Panel' import { useSendResetMutation } from 'data/telemetry/send-reset-mutation' -import { FormControl_Shadcn_, FormField_Shadcn_, Form_Shadcn_, Switch } from 'ui' +import { Card, CardContent, FormControl_Shadcn_, FormField_Shadcn_, Form_Shadcn_, Switch } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { + PageSection, + PageSectionContent, + PageSectionDescription, + PageSectionMeta, + PageSectionSummary, + PageSectionTitle, +} from 'ui-patterns/PageSection' const AnalyticsSchema = z.object({ telemetryEnabled: z.boolean(), @@ -26,9 +33,11 @@ export const AnalyticsSettings = () => { const handleToggle = (value: boolean) => { if (!hasLoaded) { - return toast.error( + toast.error( "We couldn't load the privacy settings due to an ad blocker or network error. Please disable any ad blockers and try again. If the problem persists, please contact support." ) + form.setValue('telemetryEnabled', !value) + return } if (value) { @@ -42,32 +51,44 @@ export const AnalyticsSettings = () => { } return ( - Analytics and Marketing}> - + + + + Analytics and Marketing + + Control whether telemetry and marketing data is sent from Supabase services. + + + + - ( - - - { - field.onChange(value) - handleToggle(value) - }} - /> - - - )} - /> + + + ( + + + { + field.onChange(value) + handleToggle(value) + }} + /> + + + )} + /> + + - - + + ) } diff --git a/apps/studio/components/interfaces/Account/Preferences/HotkeySettings.tsx b/apps/studio/components/interfaces/Account/Preferences/HotkeySettings.tsx index b97f3d0a19c0d..669cce3c2ab4c 100644 --- a/apps/studio/components/interfaces/Account/Preferences/HotkeySettings.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/HotkeySettings.tsx @@ -4,10 +4,25 @@ import * as z from 'zod' import { LOCAL_STORAGE_KEYS } from 'common' import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' -import Panel from 'components/ui/Panel' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' -import { FormControl_Shadcn_, FormField_Shadcn_, Form_Shadcn_, KeyboardShortcut, Switch } from 'ui' +import { + Card, + CardContent, + FormControl_Shadcn_, + FormField_Shadcn_, + Form_Shadcn_, + KeyboardShortcut, + Switch, +} from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { + PageSection, + PageSectionContent, + PageSectionDescription, + PageSectionMeta, + PageSectionSummary, + PageSectionTitle, +} from 'ui-patterns/PageSection' const HotkeySchema = z.object({ commandMenuEnabled: z.boolean(), @@ -39,99 +54,102 @@ export const HotkeySettings = () => { }) return ( - -
Keyboard shortcuts
-

- Choose which shortcuts stay active while working in the dashboard -

-
- } - > - - - ( - - - Command menu - - } - > - - { - field.onChange(value) - setCommandMenuEnabled(value) - }} - /> - - - )} - /> - - - ( - - - AI Assistant Panel - - } - > - - { - field.onChange(value) - setAiAssistantEnabled(value) - }} - /> - - - )} - /> - - - ( - - - Inline SQL Editor Panel - - } - > - - { - field.onChange(value) - setInlineEditorEnabled(value) - }} - /> - - - )} - /> - - -
+ + + + Keyboard shortcuts + + Choose which shortcuts stay active while working in the dashboard. + + + + + + + + ( + + + Command menu + + } + > + + { + field.onChange(value) + setCommandMenuEnabled(value) + }} + /> + + + )} + /> + + + ( + + + AI Assistant Panel + + } + > + + { + field.onChange(value) + setAiAssistantEnabled(value) + }} + /> + + + )} + /> + + + ( + + + Inline SQL Editor Panel + + } + > + + { + field.onChange(value) + setInlineEditorEnabled(value) + }} + /> + + + )} + /> + + + + + ) } diff --git a/apps/studio/components/interfaces/Account/Preferences/InlineEditorSettings.tsx b/apps/studio/components/interfaces/Account/Preferences/InlineEditorSettings.tsx index c28bcaa39e6c7..3d702465056df 100644 --- a/apps/studio/components/interfaces/Account/Preferences/InlineEditorSettings.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/InlineEditorSettings.tsx @@ -3,12 +3,19 @@ import { useForm } from 'react-hook-form' import * as z from 'zod' import { LOCAL_STORAGE_KEYS } from 'common' -import Panel from 'components/ui/Panel' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' -import { FormControl_Shadcn_, FormField_Shadcn_, Form_Shadcn_, Switch } from 'ui' +import { Card, CardContent, FormControl_Shadcn_, FormField_Shadcn_, Form_Shadcn_, Switch } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { + PageSection, + PageSectionContent, + PageSectionDescription, + PageSectionMeta, + PageSectionSummary, + PageSectionTitle, +} from 'ui-patterns/PageSection' const InlineEditorSchema = z.object({ inlineEditorEnabled: z.boolean(), @@ -55,32 +62,44 @@ export const InlineEditorSettings = () => { } return ( - Dashboard}> - + + + + Dashboard + + Choose your preferred experience when editing policies, triggers, and functions. + + + + - ( - - - { - field.onChange(value) - handleToggle(value) - }} - /> - - - )} - /> + + + ( + + + { + field.onChange(value) + handleToggle(value) + }} + /> + + + )} + /> + + - - + + ) } diff --git a/apps/studio/components/interfaces/Account/Preferences/ProfileInformation.tsx b/apps/studio/components/interfaces/Account/Preferences/ProfileInformation.tsx index e4e752e784b51..adb4fe08a2b71 100644 --- a/apps/studio/components/interfaces/Account/Preferences/ProfileInformation.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/ProfileInformation.tsx @@ -6,7 +6,6 @@ import { Card, CardContent, CardFooter, - CardHeader, FormControl_Shadcn_, FormField_Shadcn_, Form_Shadcn_, @@ -25,6 +24,13 @@ import { useProfile } from 'lib/profile' import { groupBy } from 'lodash' import type { FormSchema } from 'types' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { + PageSection, + PageSectionContent, + PageSectionMeta, + PageSectionSummary, + PageSectionTitle, +} from 'ui-patterns/PageSection' const FormSchema = z.object({ first_name: z.string().optional(), @@ -59,7 +65,7 @@ export const ProfileInformation = () => { values: defaultValues, }) - const { mutate: updateProfile, isPending } = useProfileUpdateMutation({ + const { mutate: updateProfile, isPending: isUpdatingProfile } = useProfileUpdateMutation({ onSuccess: (data) => { toast.success('Successfully saved profile') const { first_name, last_name, username, primary_email } = data @@ -78,111 +84,128 @@ export const ProfileInformation = () => { } return ( - -
- - Profile Information - - ( - - - - - - )} - /> - ( - - - - - - )} - /> - ( - - -
- - - - - - {isIdentitiesSuccess && - dedupedIdentityEmails.map((email) => ( - - {email} - - ))} - - - {(profile?.is_sso_user && ( -

- Primary email is managed by your SSO provider and cannot be changed here. -

- )) || ( -

- Primary email is used for account notifications. -

- )} -
-
-
- )} - /> - ( - - -
- - {(profile?.is_sso_user && ( -

- Username is managed by your SSO provider and cannot be changed here. -

- )) || ( -

- Username appears as a display name throughout the dashboard. -

- )} -
-
-
- )} - /> -
- - {form.formState.isDirty && ( - - )} - - -
-
-
+ + + + Profile information + + + + +
+ + + ( + + + + + + )} + /> + + + ( + + + + + + )} + /> + + + ( + + +
+ + + + + + {isIdentitiesSuccess && + dedupedIdentityEmails.map((email) => ( + + {email} + + ))} + + +
+
+
+ )} + /> +
+ + ( + + +
+ +
+
+
+ )} + /> +
+ + {form.formState.isDirty && ( + + )} + + +
+
+
+
+
) } diff --git a/apps/studio/components/interfaces/Account/Preferences/ThemeSettings.tsx b/apps/studio/components/interfaces/Account/Preferences/ThemeSettings.tsx index 762a1b6c67d1c..1cb2298276d16 100644 --- a/apps/studio/components/interfaces/Account/Preferences/ThemeSettings.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/ThemeSettings.tsx @@ -2,11 +2,13 @@ import { useTheme } from 'next-themes' import { useEffect, useState } from 'react' import SVG from 'react-inlinesvg' +import { LOCAL_STORAGE_KEYS } from 'common' import { DEFAULT_SIDEBAR_BEHAVIOR } from 'components/interfaces/Sidebar' -import Panel from 'components/ui/Panel' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { BASE_PATH } from 'lib/constants' import { + Card, + CardContent, Label_Shadcn_, RadioGroup_Shadcn_, RadioGroupLargeItem_Shadcn_, @@ -20,7 +22,14 @@ import { Theme, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import { LOCAL_STORAGE_KEYS } from 'common' +import { + PageSection, + PageSectionContent, + PageSectionDescription, + PageSectionMeta, + PageSectionSummary, + PageSectionTitle, +} from 'ui-patterns/PageSection' export const ThemeSettings = () => { const [mounted, setMounted] = useState(false) @@ -41,73 +50,80 @@ export const ThemeSettings = () => { function SingleThemeSelection() { return ( -
- - {singleThemes.map((theme: Theme) => ( - - - - ))} - -
+ + {singleThemes.map((theme: Theme) => ( + + + + ))} + ) } return ( - Appearance}> - -
-
- - Theme mode - -

- Choose how Supabase looks to you. Select a single theme, or sync with your system. -

-
+ + + + Appearance + + Choose how Supabase looks and behaves in the dashboard. + + + + + + +
+ + Theme mode + +

+ Choose how Supabase looks to you. Select a single theme, or sync with your system. +

+
-
-

Supabase will use your selected theme

- -
-
-
- - - - - - - - - Expanded - Collapsed - Expand on hover - - - - -
+
+ +
+ + + + + + + + + + Expanded + Collapsed + Expand on hover + + + + + + + ) } diff --git a/apps/studio/components/layouts/AccountLayout/WithSidebar.tsx b/apps/studio/components/layouts/AccountLayout/WithSidebar.tsx index 303fa13e9d2fb..d2d237b7966dc 100644 --- a/apps/studio/components/layouts/AccountLayout/WithSidebar.tsx +++ b/apps/studio/components/layouts/AccountLayout/WithSidebar.tsx @@ -48,11 +48,7 @@ export const WithSidebar = ({ /> )}
-
-
- {children} -
-
+
{children}
{ return @@ -49,63 +52,52 @@ const ProfileCard = () => { return ( <> - - - Preferences - - - -
- {isLoading && ( - -
- -
-
- )} - {isError && ( - -
- -
-
- )} - {isSuccess && ( - <> - {profileShowInformation && isSuccess ? : null} - - - )} + + + + Preferences + + Manage your account profile, connections, and dashboard experience. + + + + + + {isLoading && ( + + + + + + )} + + {isError && ( + + + + + + )} + + {isSuccess && ( + <> + {profileShowInformation ? : null} + + + )} -
- -
+ -
- -
+ -
- -
+ -
- -
+ - {profileShowAnalyticsAndMarketing && ( -
- -
- )} + {profileShowAnalyticsAndMarketing && } - {profileShowAccountDeletion && ( -
- -
- )} -
-
+ {profileShowAccountDeletion && } + ) } From 73ece8a470241c0b5a1d7e14b3003d2aa2134e28 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Fri, 12 Dec 2025 19:35:43 +1000 Subject: [PATCH 8/9] chore(studio): improve projects presentation (#41179) * improve admonition * remove redundant loader * copywriting * table empty states * improve integrations presentation * match integration styling * fix clipping and h-full container * revert test * improve status presentation * nicer status * card status improvements * comment clarification * prettier lint * fix testes * comment * Fix infinite loading behaviour * Clean u0p * Replace NoFilterResults with NoSearchResults --------- Co-authored-by: Joshen Lim --- .../content/docs/ui-patterns/empty-states.mdx | 5 + .../Home/ProjectList/EmptyStates.tsx | 77 +++------ .../Home/ProjectList/LoadMoreRow.tsx | 60 +++++++ .../Home/ProjectList/ProjectCard.tsx | 20 ++- .../Home/ProjectList/ProjectCardStatus.tsx | 102 ++++++------ .../Home/ProjectList/ProjectList.tsx | 153 +++++++++--------- .../Home/ProjectList/ProjectTableRow.tsx | 24 +-- .../components/interfaces/HomePageActions.tsx | 2 +- .../components/ui/ComputeBadgeWrapper.tsx | 2 +- apps/studio/components/ui/NoSearchResults.tsx | 19 ++- apps/studio/pages/org/[slug]/index.tsx | 31 ++-- .../ui/src/components/shadcn/ui/badge.tsx | 18 ++- .../ui/src/components/shadcn/ui/table.tsx | 2 + 13 files changed, 286 insertions(+), 229 deletions(-) create mode 100644 apps/studio/components/interfaces/Home/ProjectList/LoadMoreRow.tsx diff --git a/apps/design-system/content/docs/ui-patterns/empty-states.mdx b/apps/design-system/content/docs/ui-patterns/empty-states.mdx index 9199c4e37c89b..851fda3068e39 100644 --- a/apps/design-system/content/docs/ui-patterns/empty-states.mdx +++ b/apps/design-system/content/docs/ui-patterns/empty-states.mdx @@ -42,6 +42,11 @@ A [Table](../components/table) instance with zero results should display a singl +Studio contains two pre-built components to handle these cases consistently: + +- No Filter Results +- No Search Results + #### Data Grid [Data Grid](../ui-patterns/tables#data-grid) and [Data Table](../ui-patterns/tables#data-table) component patterns typically span the full height and width of a container. A classic example is [Users](https://supabase.com/dashboard/project/_/auth/users), which (as it sounds) displays a list of the project’s registered users. Any instance with zero results should display a more prominent empty with a clear title, description, and supporting illustration. diff --git a/apps/studio/components/interfaces/Home/ProjectList/EmptyStates.tsx b/apps/studio/components/interfaces/Home/ProjectList/EmptyStates.tsx index e7db6d16c1a68..60f4c422d2bc0 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/EmptyStates.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/EmptyStates.tsx @@ -7,7 +7,6 @@ import { BASE_PATH } from 'lib/constants' import { Button, Card, - cn, Skeleton, Table, TableBody, @@ -36,62 +35,6 @@ export const Header = () => { ) } -export const NoFilterResults = ({ - filterStatus, - resetFilterStatus, - className, -}: { - filterStatus: string[] - resetFilterStatus?: () => void - className?: string -}) => { - return ( -
-
- {/* [Joshen] Just keeping it simple for now unless we decide to extend this to other statuses */} -

- {filterStatus.length === 0 - ? `No projects found` - : `No ${filterStatus[0] === 'INACTIVE' ? 'paused' : 'active'} projects found`} -

-

- Your search for projects with the specified status did not return any results -

-
- {resetFilterStatus !== undefined && ( - - )} -
- ) -} - -export const LoadingTableRow = () => ( - - - - - - - - - - - - - - - - - -) - export const LoadingTableView = () => { return ( @@ -107,7 +50,23 @@ export const LoadingTableView = () => { {[...Array(3)].map((_, i) => ( - + + + + + + + + + + + + + + + + + ))} @@ -142,7 +101,7 @@ export const NoProjectsState = ({ slug }: { slug: string }) => { ) } -export const NoOrganizationsState = ({}) => { +export const NoOrganizationsState = () => { return ( void +} + +export const LoadMoreRows = ({ type, isFetchingNextPage, fetchNextPage }: LoadMoreRowProps) => { + const [sentinelRef, entry] = useIntersectionObserver({ + threshold: 0, + rootMargin: '200px 0px 200px 0px', + }) + + useEffect(() => { + if (entry?.isIntersecting && !isFetchingNextPage) { + fetchNextPage?.() + } + }, [entry?.isIntersecting, isFetchingNextPage, fetchNextPage]) + + if (type === 'card') { + return ( +
    + {[...Array(2)].map((_, i) => ( + + ))} +
+ ) + } + + return ( + + + + + + + + + + + + + + + + + + ) +} diff --git a/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx b/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx index 8d82d4e664192..ed36761b42f3b 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx @@ -53,9 +53,13 @@ export const ProjectCard = ({ linkHref={rewriteHref ? rewriteHref : `/project/${projectRef}`} className="h-44 !px-0 group pt-5 pb-0" title={ -
-

{name}

- {desc} +
+ {/* Text */} +
+
{name}
+

{desc}

+
+ {/* Compute and integrations */}
{project.status !== 'INACTIVE' && projectHomepageShowInstanceSize && ( )} {isVercelIntegrated && ( -
+
)} {isGithubIntegrated && ( - <> -
+
+
-

{githubRepository}

- +

{githubRepository}

+
)}
diff --git a/apps/studio/components/interfaces/Home/ProjectList/ProjectCardStatus.tsx b/apps/studio/components/interfaces/Home/ProjectList/ProjectCardStatus.tsx index 607d463836c56..ad6d47508bbc1 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/ProjectCardStatus.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/ProjectCardStatus.tsx @@ -3,21 +3,13 @@ import { AlertTriangle, Info, PauseCircle, RefreshCcw } from 'lucide-react' import { RESOURCE_WARNING_MESSAGES } from 'components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.constants' import { getWarningContent } from 'components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.utils' import type { ResourceWarning } from 'data/usage/resource-warnings-query' -import { - Alert_Shadcn_, - AlertTitle_Shadcn_, - Badge, - cn, - Tooltip, - TooltipContent, - TooltipTrigger, -} from 'ui' +import { Badge, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { InferredProjectStatus } from './ProjectCard.utils' export interface ProjectCardWarningsProps { resourceWarnings?: ResourceWarning projectStatus: InferredProjectStatus - renderMode?: 'alert' | 'badge' // New prop to control rendering mode + renderMode?: 'alert' | 'badge' } export const ProjectCardStatus = ({ @@ -130,12 +122,20 @@ export const ProjectCardStatus = ({ projectStatus === 'isHealthy' ) { if (renderMode === 'badge') { - return Active + return ( + // Badge must be wrapped in a div in order to be centered in table cell +
+ Active +
+ ) } return null } if (renderMode === 'badge') { + // Render a fallback en dash if no title is available + if (!alertTitle) return + const badgeVariant = isCritical ? 'destructive' : activeWarnings.length > 0 || @@ -146,50 +146,60 @@ export const ProjectCardStatus = ({ ? 'success' : 'default' - return alertDescription ? ( - - - - {alertTitle} - - - {alertDescription} - - ) : ( - - {alertTitle} - + return ( + // Badge must be wrapped in a div in order to be centered in table cell +
+ {alertDescription ? ( + + + {alertTitle} + + {alertDescription} + + ) : ( + {alertTitle} + )} +
) } + // Only render if an alert title is available + if (!alertTitle) return null + return ( - svg]:left-[1.25rem] [&>svg]:top-3.5 [&>svg]:border', - !isCritical ? '[&>svg]:text-foreground [&>svg]:bg-surface-100' : '' - )} - > - {['isPaused', 'isPausing'].includes(projectStatus ?? '') ? ( - - ) : ['isRestoring', 'isComingUp', 'isRestarting', 'isResizing'].includes( - projectStatus ?? '' - ) ? ( - - ) : ( - - )} -
- {alertTitle} +
+ {/* Icon */} +
svg]:text-destructive-600', + alertType === 'warning' && 'border-warning-400 [&>svg]:text-warning-600', + alertType === 'default' && 'border-strong [&>svg]:text-foreground' + )} + > + {['isPaused', 'isPausing'].includes(projectStatus ?? '') ? ( + + ) : ['isRestoring', 'isComingUp', 'isRestarting', 'isResizing'].includes( + projectStatus ?? '' + ) ? ( + + ) : ( + + )} +
+ {/* Text and tooltip icon */} +
+

{alertTitle}

- + {alertDescription}
- +
) } diff --git a/apps/studio/components/interfaces/Home/ProjectList/ProjectList.tsx b/apps/studio/components/interfaces/Home/ProjectList/ProjectList.tsx index d53ea04215331..6e25f8fa504de 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/ProjectList.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/ProjectList.tsx @@ -1,4 +1,4 @@ -import { UIEvent, useMemo } from 'react' +import { useMemo } from 'react' import { keepPreviousData } from '@tanstack/react-query' import { useDebounce } from '@uidotdev/usehooks' @@ -13,30 +13,13 @@ import { useResourceWarningsQuery } from 'data/usage/resource-warnings-query' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { IS_PLATFORM } from 'lib/constants' -import { isAtBottom } from 'lib/helpers' import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs' import type { Organization } from 'types' -import { - Card, - cn, - LoadingLine, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from 'ui' -import { - LoadingCardView, - LoadingTableRow, - LoadingTableView, - NoFilterResults, - NoProjectsState, -} from './EmptyStates' +import { Card, cn, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui' +import { LoadingCardView, LoadingTableView, NoProjectsState } from './EmptyStates' +import { LoadMoreRows } from './LoadMoreRow' import { ProjectCard } from './ProjectCard' import { ProjectTableRow } from './ProjectTableRow' -import { ShimmeringCard } from './ShimmeringCard' export interface ProjectListProps { organization?: Organization @@ -65,7 +48,6 @@ export const ProjectList = ({ organization: organization_, rewriteHref }: Projec isLoading: isLoadingProjects, isSuccess: isSuccessProjects, isError: isErrorProjects, - isFetching, isFetchingNextPage, hasNextPage, fetchNextPage, @@ -106,6 +88,8 @@ export const ProjectList = ({ organization: organization_, rewriteHref }: Projec const noResultsFromStatusFilter = filterStatus.length > 0 && isSuccessProjects && orgProjects.length === 0 + const noResults = noResultsFromStatusFilter || noResultsFromSearch + const githubConnections = connections?.map((connection) => ({ id: String(connection.id), added_by: { @@ -126,11 +110,6 @@ export const ProjectList = ({ organization: organization_, rewriteHref }: Projec ?.filter((integration) => integration.integration.name === 'Vercel') .flatMap((integration) => integration.connections) - const handleScroll = (event: UIEvent) => { - if (isLoadingProjects || isFetchingNextPage || !isAtBottom(event)) return - fetchNextPage() - } - if (isErrorPermissions) { return ( + {/* [Joshen] Ideally we can figure out sticky table headers here */} - + - Project - Status - Compute - Region - Created - - - - - + Project + Status + Compute + Region + Created {noResultsFromStatusFilter ? ( - - - setFilterStatus([])} - className="border-0" + + + setFilterStatus([])} /> ) : noResultsFromSearch ? ( - - - + + + ) : ( @@ -212,7 +191,13 @@ export const ProjectList = ({ organization: organization_, rewriteHref }: Projec )} /> ))} - {hasNextPage && } + {hasNextPage && ( + + )} )} @@ -224,40 +209,52 @@ export const ProjectList = ({ organization: organization_, rewriteHref }: Projec return ( <> {noResultsFromStatusFilter ? ( - setFilterStatus([])} + setFilterStatus([])} /> ) : noResultsFromSearch ? ( ) : ( -
    - {sortedProjects?.map((project) => ( - resourceWarning.project === project.ref - )} - githubIntegration={githubConnections?.find( - (connection) => connection.supabase_project_ref === project.ref - )} - vercelIntegration={vercelConnections?.find( - (connection) => connection.supabase_project_ref === project.ref - )} +
    +
      + {sortedProjects?.map((project) => ( + resourceWarning.project === project.ref + )} + githubIntegration={githubConnections?.find( + (connection) => connection.supabase_project_ref === project.ref + )} + vercelIntegration={vercelConnections?.find( + (connection) => connection.supabase_project_ref === project.ref + )} + /> + ))} +
    + {hasNextPage && ( + - ))} - {hasNextPage && [...Array(2)].map((_, i) => )} -
+ )} + )} ) diff --git a/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx b/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx index 3ed1f237cefb6..b3c2e4723d973 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx @@ -53,15 +53,17 @@ export const ProjectTableRow = ({ }} > -
+
+ {/* Text */}
-

{name}

-

ID: {projectRef}

+
{name}
+

ID: {projectRef}

+ {/* Integrations */} {(isGithubIntegrated || isVercelIntegrated) && ( -
+
{isVercelIntegrated && ( -
+
)} {isGithubIntegrated && ( - <> -
+
+
{githubRepository && ( - - {githubRepository} - +

{githubRepository}

)} - +
)}
)} @@ -102,7 +102,7 @@ export const ProjectTableRow = ({ computeSize={getComputeSize(project)} /> ) : ( - - + )}
diff --git a/apps/studio/components/interfaces/HomePageActions.tsx b/apps/studio/components/interfaces/HomePageActions.tsx index b1cfed2059907..1617e16d140ae 100644 --- a/apps/studio/components/interfaces/HomePageActions.tsx +++ b/apps/studio/components/interfaces/HomePageActions.tsx @@ -1,3 +1,4 @@ +import { keepPreviousData } from '@tanstack/react-query' import { useDebounce } from '@uidotdev/usehooks' import { Filter, Grid, List, Loader2, Plus, Search, X } from 'lucide-react' import Link from 'next/link' @@ -19,7 +20,6 @@ import { ToggleGroupItem, } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' -import { keepPreviousData } from '@tanstack/react-query' interface HomePageActionsProps { slug?: string diff --git a/apps/studio/components/ui/ComputeBadgeWrapper.tsx b/apps/studio/components/ui/ComputeBadgeWrapper.tsx index 2a5a9e2901bb1..80ed25cc3de0f 100644 --- a/apps/studio/components/ui/ComputeBadgeWrapper.tsx +++ b/apps/studio/components/ui/ComputeBadgeWrapper.tsx @@ -79,7 +79,7 @@ export const ComputeBadgeWrapper = ({ return ( setOpenState(!open)} openDelay={280}> e.stopPropagation()}> -
+
diff --git a/apps/studio/components/ui/NoSearchResults.tsx b/apps/studio/components/ui/NoSearchResults.tsx index ce44e4b1ae21e..65b80adaf2cbf 100644 --- a/apps/studio/components/ui/NoSearchResults.tsx +++ b/apps/studio/components/ui/NoSearchResults.tsx @@ -1,27 +1,34 @@ import { Button, cn } from 'ui' export interface NoSearchResultsProps { - searchString: string + searchString?: string + withinTableCell?: boolean onResetFilter?: () => void className?: string + label?: string + description?: string } export const NoSearchResults = ({ searchString, + withinTableCell = false, onResetFilter, className, + label, + description, }: NoSearchResultsProps) => { return (
-
-

No results found

-

- Your search for "{searchString}" did not return any results +

+

{label ?? 'No results found'}

+

+ {description ?? `Your search for “${searchString}” did not return any results`}

{onResetFilter !== undefined && ( diff --git a/apps/studio/pages/org/[slug]/index.tsx b/apps/studio/pages/org/[slug]/index.tsx index 71d5cc8872afe..bb42b54ccc196 100644 --- a/apps/studio/pages/org/[slug]/index.tsx +++ b/apps/studio/pages/org/[slug]/index.tsx @@ -1,3 +1,5 @@ +import Link from 'next/link' + import { useIsMFAEnabled } from 'common' import { ProjectList } from 'components/interfaces/Home/ProjectList/ProjectList' import { HomePageActions } from 'components/interfaces/HomePageActions' @@ -5,9 +7,9 @@ import DefaultLayout from 'components/layouts/DefaultLayout' import OrganizationLayout from 'components/layouts/OrganizationLayout' import { PageLayout } from 'components/layouts/PageLayout/PageLayout' import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' -import { InlineLink } from 'components/ui/InlineLink' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import type { NextPageWithLayout } from 'types' +import { Button } from 'ui' import { Admonition } from 'ui-patterns' const ProjectsPage: NextPageWithLayout = () => { @@ -20,17 +22,24 @@ const ProjectsPage: NextPageWithLayout = () => { {disableAccessMfa ? ( - -

- Set up MFA on your account through your{' '} - account preferences to access this - organization -

-
+ + Set up multi-factor authentication (MFA) on your account to access this + organization’s projects. + + } + actions={ + + } + /> ) : ( - // [Joshen] Very odd, but the h-px here is required for ProjectList to have a max - // height based on the remaining space that it can grow to -
+
diff --git a/packages/ui/src/components/shadcn/ui/badge.tsx b/packages/ui/src/components/shadcn/ui/badge.tsx index b304a7b9e1038..413ac13dbce26 100644 --- a/packages/ui/src/components/shadcn/ui/badge.tsx +++ b/packages/ui/src/components/shadcn/ui/badge.tsx @@ -28,12 +28,16 @@ export interface BadgeProps extends React.HTMLAttributes, VariantProps {} -function Badge({ className, variant = 'default', children, ...props }: BadgeProps) { - return ( -
- {children} -
- ) -} +// Forward refs in order to allow tooltips to be applied to the badge +const Badge = React.forwardRef( + ({ className, variant = 'default', children, ...props }, ref) => { + return ( +
+ {children} +
+ ) + } +) +Badge.displayName = 'Badge' export { Badge } diff --git a/packages/ui/src/components/shadcn/ui/table.tsx b/packages/ui/src/components/shadcn/ui/table.tsx index 3b4458a8f2818..5be4a2dcdcb3c 100644 --- a/packages/ui/src/components/shadcn/ui/table.tsx +++ b/packages/ui/src/components/shadcn/ui/table.tsx @@ -70,6 +70,8 @@ const TableHead = React.forwardRef< ref={ref} className={cn( 'h-10 px-4 text-left align-middle heading-meta whitespace-nowrap text-foreground-lighter [&:has([role=checkbox])]:pr-0', + // Transition text color when NoSearchResults or NoFilterResults empty state is shown + 'transition-colors', className )} {...props} From b97e41e20bf9fdfa1f0322d54c62d6cf6fefea64 Mon Sep 17 00:00:00 2001 From: "kemal.earth" <606977+kemaldotearth@users.noreply.github.com> Date: Fri, 12 Dec 2025 10:08:48 +0000 Subject: [PATCH 9/9] chore(design-system): tidy up mobile styling (#41267) * feat: tidy up header on mobile * fix: graceful searchbar header * fix: header classes and unused elements * feat: trim down logo on header * fix: logos again * chore: remove dead code * fix: classic dark logo header * fix: page padding tidy ups * fix: some font sizing * feat: updated homepage cards * fix: toc layout * fix: toc again * feat: homepage cards all the same size * fix: footer paddings * fix: pager if only 1 directional item * fix: sidebar padding issues * chore: killing all the defaults * fix: import for side nav * fix: sidebar complaining about dialog title * fix: homepage paddings * fix: killing one more default export * feat: update colours icon to francescos * fix: loose repeated copy --- .../app/(app)/docs/[[...slug]]/page.tsx | 14 +- apps/design-system/app/(app)/layout.tsx | 36 +-- apps/design-system/app/(app)/page.tsx | 124 ++++---- apps/design-system/app/Providers.tsx | 5 +- .../design-system/components/command-menu.tsx | 30 +- .../components/mobile-sidebar-sheet.tsx | 20 ++ apps/design-system/components/pager.tsx | 5 +- .../components/side-navigation-item.tsx | 11 +- .../components/side-navigation.tsx | 8 +- apps/design-system/components/site-footer.tsx | 4 +- .../components/theme-switcher-dropdown.tsx | 22 +- .../components/top-navigation.tsx | 47 +-- apps/design-system/config/docs.ts | 278 +++++++++--------- .../context/mobile-sidebar-context.tsx | 34 +++ apps/design-system/eslint.config.cjs | 2 +- .../design-system/hooks/use-mobile-sidebar.ts | 24 ++ .../atoms--classic-dark.svg | 8 + .../img/design-system-marks/atoms--dark.svg | 1 + .../img/design-system-marks/atoms--light.svg | 8 + .../colours--classic-dark.svg | 6 + .../img/design-system-marks/colours--dark.svg | 6 + .../design-system-marks/colours--light.svg | 6 + .../fragments--classic-dark.svg | 8 + .../design-system-marks/fragments--dark.svg | 8 + .../design-system-marks/fragments--light.svg | 8 + .../logo--classic-dark.svg | 26 ++ .../img/design-system-marks/logo--dark.svg | 26 ++ .../img/design-system-marks/logo--light.svg | 26 ++ .../ui-patterns--classic-dark.svg | 8 + .../design-system-marks/ui-patterns--dark.svg | 8 + .../ui-patterns--light.svg | 8 + 31 files changed, 512 insertions(+), 313 deletions(-) create mode 100644 apps/design-system/components/mobile-sidebar-sheet.tsx create mode 100644 apps/design-system/context/mobile-sidebar-context.tsx create mode 100644 apps/design-system/hooks/use-mobile-sidebar.ts create mode 100644 apps/design-system/public/img/design-system-marks/atoms--classic-dark.svg create mode 100644 apps/design-system/public/img/design-system-marks/atoms--dark.svg create mode 100644 apps/design-system/public/img/design-system-marks/atoms--light.svg create mode 100644 apps/design-system/public/img/design-system-marks/colours--classic-dark.svg create mode 100644 apps/design-system/public/img/design-system-marks/colours--dark.svg create mode 100644 apps/design-system/public/img/design-system-marks/colours--light.svg create mode 100644 apps/design-system/public/img/design-system-marks/fragments--classic-dark.svg create mode 100644 apps/design-system/public/img/design-system-marks/fragments--dark.svg create mode 100644 apps/design-system/public/img/design-system-marks/fragments--light.svg create mode 100644 apps/design-system/public/img/design-system-marks/logo--classic-dark.svg create mode 100644 apps/design-system/public/img/design-system-marks/logo--dark.svg create mode 100644 apps/design-system/public/img/design-system-marks/logo--light.svg create mode 100644 apps/design-system/public/img/design-system-marks/ui-patterns--classic-dark.svg create mode 100644 apps/design-system/public/img/design-system-marks/ui-patterns--dark.svg create mode 100644 apps/design-system/public/img/design-system-marks/ui-patterns--light.svg diff --git a/apps/design-system/app/(app)/docs/[[...slug]]/page.tsx b/apps/design-system/app/(app)/docs/[[...slug]]/page.tsx index b6b883b932358..9454a245b8ad8 100644 --- a/apps/design-system/app/(app)/docs/[[...slug]]/page.tsx +++ b/apps/design-system/app/(app)/docs/[[...slug]]/page.tsx @@ -4,7 +4,7 @@ import { SourcePanel } from '@/components/source-panel' import { DashboardTableOfContents } from '@/components/toc' import { siteConfig } from '@/config/site' import { getTableOfContents } from '@/lib/toc' -import { absoluteUrl, cn } from '@/lib/utils' +import { absoluteUrl } from '@/lib/utils' import '@/styles/code-block-variables.css' import '@/styles/mdx.css' import { allDocs } from 'contentlayer/generated' @@ -83,15 +83,15 @@ export default async function DocPage(props: DocPageProps) { const toc = await getTableOfContents(doc.body.raw) return ( -
-
+
+
Docs
{doc.title}
-

{doc.title}

+

{doc.title}

{doc.description && (

{doc.description} @@ -107,15 +107,15 @@ export default async function DocPage(props: DocPageProps) {

{doc.toc && (
-
+
-
+
)} -
+
) } diff --git a/apps/design-system/app/(app)/layout.tsx b/apps/design-system/app/(app)/layout.tsx index 49b81a77a8fe8..775f444ff4ffc 100644 --- a/apps/design-system/app/(app)/layout.tsx +++ b/apps/design-system/app/(app)/layout.tsx @@ -1,34 +1,28 @@ -import SideNavigation from '@/components/side-navigation' +import { SideNavigation } from '@/components/side-navigation' import { SiteFooter } from '@/components/site-footer' -// import ThemeSettings from '@/components/theme-settings' -import TopNavigation from '@/components/top-navigation' +import { TopNavigation } from '@/components/top-navigation' +import { MobileSidebarSheet } from '@/components/mobile-sidebar-sheet' import { ScrollArea } from 'ui' interface AppLayoutProps { children: React.ReactNode } -export default function AppLayout({ children }: AppLayoutProps) { +export default async function AppLayout({ children }: AppLayoutProps) { return ( <> - {/* main container */} -
- {/* main content */} -
- {/* {children} */} -
-
- - {children} -
-
-
-
+ +
+
+ + {children} +
+
) diff --git a/apps/design-system/app/(app)/page.tsx b/apps/design-system/app/(app)/page.tsx index 38bfce96e7740..9540c812a469b 100644 --- a/apps/design-system/app/(app)/page.tsx +++ b/apps/design-system/app/(app)/page.tsx @@ -1,85 +1,59 @@ import { HomepageSvgHandler } from '@/components/homepage-svg-handler' import Link from 'next/link' +import { Paintbrush } from 'lucide-react' +import { Realtime, Database, Auth } from 'icons/src/icons' export default function Home() { return ( -
-
-
- {/*
- -
*/} -
-

Supabase Design System

-

- Design resources for building consistent user experiences -

-
+
+
+
+

Supabase Design System

+

+ Design resources for building consistent user experiences +

- {/* Homepage items */} - -
- -
- +
+ +
+
+ +

Atom components

Building blocks of User interfaces

- -
- + +
+
+ +

Fragment components

Components assembled from Atoms

- -
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
- -
-
-
-
-
-
-
- -
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
+ +
+
+ +
+
+

UI Patterns components

+

+ Components assembled from Atoms & Fragments +

+
+
+ + +
+
+

Colors

@@ -88,27 +62,33 @@ export default function Home() {
- -
- + +
+
+ +

Theming

-

Components assembled from Atoms

+

Simple extensible theming system

- -
- + +
+
+ + + +

Icons

-

Components assembled from Atoms

+

Custom icons for Supabase

-
+
) } diff --git a/apps/design-system/app/Providers.tsx b/apps/design-system/app/Providers.tsx index 87bac1f1e2ff1..d2ace2cd703cd 100644 --- a/apps/design-system/app/Providers.tsx +++ b/apps/design-system/app/Providers.tsx @@ -5,12 +5,15 @@ import { ThemeProvider as NextThemesProvider } from 'next-themes' import { ThemeProviderProps } from 'next-themes/dist/types' import { TooltipProvider } from 'ui' +import { MobileSidebarProvider } from '@/context/mobile-sidebar-context' export function ThemeProvider({ children, ...props }: ThemeProviderProps) { return ( - {children} + + {children} + ) diff --git a/apps/design-system/components/command-menu.tsx b/apps/design-system/components/command-menu.tsx index 1673e8f2ed012..5a2f558b38520 100644 --- a/apps/design-system/components/command-menu.tsx +++ b/apps/design-system/components/command-menu.tsx @@ -1,6 +1,6 @@ 'use client' -import { CircleIcon, LaptopIcon, MoonIcon, SunIcon } from 'lucide-react' +import { CircleIcon, LaptopIcon, MoonIcon, SunIcon, Search } from 'lucide-react' import { useTheme } from 'next-themes' import { useRouter } from 'next/navigation' import * as React from 'react' @@ -17,6 +17,7 @@ import { CommandList_Shadcn_, CommandSeparator_Shadcn_, DialogProps, + DialogTitle, } from 'ui' export function CommandMenu({ ...props }: DialogProps) { @@ -55,8 +56,7 @@ export function CommandMenu({ ...props }: DialogProps) { + + /> Theme diff --git a/apps/design-system/components/top-navigation.tsx b/apps/design-system/components/top-navigation.tsx index 4aec103a965aa..5e9c85dff48c8 100644 --- a/apps/design-system/components/top-navigation.tsx +++ b/apps/design-system/components/top-navigation.tsx @@ -1,30 +1,37 @@ -// import { docsConfig } from '@/config/docs' +'use client' + import Link from 'next/link' +import { Menu } from 'lucide-react' +import { Button } from 'ui' import { CommandMenu } from './command-menu' import { HomepageSvgHandler } from './homepage-svg-handler' import { ThemeSwitcherDropdown } from './theme-switcher-dropdown' +import { useMobileSidebar } from '@/hooks/use-mobile-sidebar' + +export const TopNavigation = () => { + const { toggle } = useMobileSidebar() -function TopNavigation() { return ( -
-
-