From 5c685ccebaa9ad3618cc8c73e4b552d28f970550 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Thu, 11 Dec 2025 20:20:45 -0700 Subject: [PATCH 01/20] Fix flaky auth test (#41287) fix flaky test --- e2e/studio/utils/auth-helpers.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/e2e/studio/utils/auth-helpers.ts b/e2e/studio/utils/auth-helpers.ts index cbe2a8de211..ec639cbdb88 100644 --- a/e2e/studio/utils/auth-helpers.ts +++ b/e2e/studio/utils/auth-helpers.ts @@ -79,11 +79,12 @@ export const deleteUserViaUI = async (page: Page, ref: string, email: string) => } export const navigateToAuthUsers = async (page: Page, ref: string) => { + const userListResponse = waitForApiResponse(page, 'platform/pg-meta', ref, 'query?key=') await page.goto(toUrl(`/project/${ref}/auth/users`)) // Wait for the page to load by checking for the "Users" heading await expect(page.getByRole('heading', { name: 'Users', level: 3 })).toBeVisible() // Wait for initial users list to load - await waitForApiResponse(page, 'platform/pg-meta', ref, 'query?key=') + await userListResponse } From 9f3af6e5025fddd1e1411caf0c1744242c852211 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Fri, 12 Dec 2025 14:44:11 +1000 Subject: [PATCH 02/20] 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 | 307 +++++++----- .../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, 2096 insertions(+), 177 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 00000000000..25cebe323bb --- /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 ebfcc71f43e..128668a8d71 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 12d2f525bc2..80a1ba852d5 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 9041b931c92..1c09396321b 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 675c64b1223..54a4831deaf 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 b9f18ae1d18..abc9c488d59 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 00000000000..f40562843e8 --- /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 00000000000..4477cc88d23 --- /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 00000000000..278d455c5d3 --- /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 00000000000..41ed4a52835 --- /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 0533e95c1ba..66c325dd7c4 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 snap = useTableEditorStateSnapshot() - - const { data: project } = useSelectedProjectQuery() - const { selectedSchema } = useQuerySchemaState() - const isNewRecord = isUndefined(table) - const { realtimeAll: realtimeEnabled } = useIsFeatureEnabled(['realtime:all']) 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() - useEffect(() => { - if (params.create === 'table' && snap.ui.open === 'none') { - tableEditorApi.onAddTable() - setParams({ ...params, create: undefined }) - } - }, [tableEditorApi, setParams, snap.ui.open, params]) + const { data: project } = useSelectedProjectQuery() + const { selectedSchema } = useQuerySchemaState() + + const isNewRecord = table === undefined + const visibleChanged = useChanged(visible) + + 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,89 +377,97 @@ 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 - - } - > - - - )} - - {activeRealtimeVariant !== RealtimeButtonVariant.HIDE_BUTTON && realtimeEnabled && ( - { - track('realtime_toggle_table_clicked', { - newState: tableFields.isRealtimeEnabled ? 'disabled' : 'enabled', - origin: 'tableSidePanel', - }) - onUpdateField({ isRealtimeEnabled: !tableFields.isRealtimeEnabled }) - }} - size="medium" - /> - )} - + {!generatePoliciesEnabled && ( + <> + + + 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 + + } + > + + + )} + + {activeRealtimeVariant !== RealtimeButtonVariant.HIDE_BUTTON && realtimeEnabled && ( + { + track('realtime_toggle_table_clicked', { + newState: tableFields.isRealtimeEnabled ? 'disabled' : 'enabled', + origin: 'tableSidePanel', + }) + onUpdateField({ + isRealtimeEnabled: !tableFields.isRealtimeEnabled, + }) + }} + size="medium" + /> + )} + + + + + )} + {!isDuplicating && ( 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 70f7a913602..d539c9eba1a 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 00000000000..fde77f775cb --- /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 b1012f086e1..a952aeda8f0 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 8494a43e82e..797d09ffd20 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 00000000000..92cf14dee2d --- /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 00000000000..d403e9b6b35 --- /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 124d6aa3126..9f8c75aabc3 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 92c7ce67b61..664b0a4f50e 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 03/20] 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 7c018dc3f4f..154b4006cea 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 e7083db2c2a..fed71877001 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 6ca9035d2d9..bb8bd0c54c1 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 4aa8e54520c..fe4b4a82a18 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 1b1f6d51720..00000000000 --- 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 9988c45eee8..bb4d1110a84 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 b5d9bf56a09..2b0e1fb7198 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 0e31de76ea1..77b41165fa7 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 6c4902c68a5..352fbcc6e20 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 00000000000..bb79a037b79 --- /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 33a2d025637..c4c45626c75 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 e212a75b682..9ececaf5dff 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 df84fe0ee46..7ef5cefb309 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 33a8b426fe0..9b7f249341f 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 81e6d49714c..2719fc70bb4 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 31e01d4f394..08c21d744c7 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 04/20] 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 | 30 +-- .../APIKeys/CreatePublishableAPIKeyDialog.tsx | 36 +-- .../APIKeys/CreateSecretAPIKeyDialog.tsx | 34 ++- .../interfaces/APIKeys/PublishableAPIKeys.tsx | 239 +++++++++--------- .../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, 301 insertions(+), 379 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 7cacf303492..d1d7e4d0dd8 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 fc5a0e3847c..e3edee5a371 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 882258e4e66..97263f3f3fa 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 77dc6dd6112..98d7d9a7666 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 { mutate: createAPIKey } = useAPIKeyCreateMutation() + const [isCreatingKeys, setIsCreatingKeys] = useState(false) + const [createKeysDialogOpen, setCreateKeysDialogOpen] = useState(false) + + 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 f195342a307..7c430bd6cf8 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 4eb18794257..b44779eaf56 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 63605c60d51..3870dea6bab 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 -
- )} -
-
+ + {!canReadAPIKeys && !isLoadingPermissions ? ( + + ) : isLoadingApiKeys || isLoadingPermissions ? ( + + ) : isErrorApiKeys ? ( + + ) : ( + + + + + Name + API Key + + + + + + {hasApiKeys && publishableApiKeys.length === 0 && ( + + + +

No publishable keys created yet

+
+
+
+ )} + {publishableApiKeys.map((apiKey) => ( + onDeleteAPIKey(apiKey)} + setKeyToDelete={setAPIKeyToDelete} + /> + ))} +
+ + + + +

+ Publishable keys can be safely shared publicly +

+
+
+
+
+
+ )}
) } - -const ApiKeyInput = () => { - const { ref: projectRef } = useParams() - - 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] - - 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 ( - - ) -} diff --git a/apps/studio/components/interfaces/APIKeys/SecretAPIKeys.tsx b/apps/studio/components/interfaces/APIKeys/SecretAPIKeys.tsx index d4912561a0c..eaaba239b28 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 3147b1e3fc0..00000000000 --- 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 0d408e1c83f..e1ff48a069c 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 b72c5594ea8..834e78113c2 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 d15c54b571e..b27d8a35296 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 99781c99ccb..1bc865359a4 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 48eaf6e32ec..59b0e4f0b4b 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 43ec3fefbab..88a063b844d 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 4cf9325f250..18558668c93 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 6d0c54d33f7..ac54e140449 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 a4c8c3b5b1f..7819382c6fa 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 42d3abc5dcf..648dca7ecc6 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 878e535a38a..bf90128392f 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 e1c3b042322..56c6a1edf95 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 303779f0a3f..b9cc5d54af6 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 61b05b06a0c..db94752f496 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 91001e58147..47272a107c7 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 0f17f7a6fbd..5d0b0893384 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 e206290a2f1..55b2a4054c4 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 6e6e23c3723..2aedcb99c6a 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 fd96c3cfc0c..62075f62eb8 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 8ee3779dcc5..aa8c5db7748 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 49cbe8bc09d..c37b2c79b73 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 d7bf50c212f..935c1082797 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 9c5562e2bf9..3a9c3071780 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 1de58426adc..fcb66961471 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 4fbbcb20648..debc26412af 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 de2a08e28df..e0cd094e623 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 cfe3f2b82a2..363cbe9c81c 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 f6afa54d7ae..46f7a0624ce 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 99e475e2c9d..e758c4f236b 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 05/20] 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 ecd944863d4..e72c8fffd2b 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 fce520f7f29..f4432203d1a 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.9': {} - '@next/env@15.5.8': {} - - '@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 c2a4cc41f1d..c4344ed6716 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 06/20] 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 | 463 ++++++++++-------- apps/docs/features/docs/MdxBase.shared.tsx | 3 +- 5 files changed, 377 insertions(+), 254 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 00000000000..0f6bb64014f --- /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 d104d40a779..92729296195 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 ffa04358aa4..5c9dd0973a4 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 a54247fcf6d..12d24259dc7 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)`. +## Configuring and securing Supabase + +While we provided example placeholder passwords and keys in the `.env.example` file, you should NEVER start your self-hosted Supabase using these defaults. + + + + Follow all of the steps in this section to ensure you have a secure setup, and only then start all services. + + + +### Configure database password + +Change the placeholder password in the `.env` file **before** starting your Supabase for the first time. + +- `POSTGRES_PASSWORD`: the password for the `postgres` and `supabase_admin` database roles + +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. + +### Generate and configure API keys + +Use the key generator below to obtain and configure the following secure keys in `.env`: + +- `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. + + + +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. + +The generated keys expire in 5 years. You can verify them at [jwt.io](https://jwt.io) using the JWT secret. + +### Configure other keys, and important URLs + +Edit the following settings in the `.env` file: + +- `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`) + +Review and change URL environment variables: + +- `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` + + + + If you are only using self-hosted Supabase locally, you can use `localhost`. + + + +### Studio authentication + +Access to Studio dashboard and internal API is protected with **HTTP basic authentication**. + + + + The default password MUST be changed before starting Supabase. + + + +Change the password in the `.env` file: + +- `DASHBOARD_PASSWORD`: password for Studio / dashboard + +The password must include at least one letter (do not use numbers only). + +Optionally change the user: + +- `DASHBOARD_USERNAME`: username for Studio / dashboard + +## Starting and stopping + +You can start all services by using the following command in the same directory as your `docker-compose.yml` file: + +```sh +# Start the services (in detached mode) +docker compose up -d +``` + After all the services have started you can see them running in the background: ```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 `. +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., - +```sh +docker compose logs analytics +``` -Your app is now running with default credentials. -[Secure your services](#securing-your-services) as soon as possible using the instructions below. +To stop Supabase, use: - +```sh +docker compose down +``` + +## Accessing Supabase services + +After the Supabase services are configured and running, you can access the dashboard, connect to the database, and use edge functions. ### Accessing Supabase Studio -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. +You can access Supabase Studio through the API gateway on port `8000`. -You will be prompted for a username and password. By default, the credentials are: +For example: `http://example.com:8000`, or `http://:8000` (or `localhost:8000` if you are running Docker Compose locally). -- Username: `supabase` -- Password: `this_password_is_insecure_and_should_be_updated` +You will be prompted for a username and password. Use the credentials that you set up earlier in [Studio authentication](#studio-authentication). -You should change these credentials as soon as possible using the [instructions](#dashboard-authentication) below. +### 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. + +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. + +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). + +For session-based connections (equivalent to a direct Postgres connection): + +```sh +psql 'postgres://postgres.[POOLER_TENANT_ID]:[POSTGRES_PASSWORD]@[your-domain]:5432/postgres' +``` + +For pooled transactional connections: + +```sh +psql 'postgres://postgres.[POOLER_TENANT_ID]:[POSTGRES_PASSWORD]@[your-domain]:6543/postgres' +``` + +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`. + +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. + +### Accessing Edge Functions + +Edge Functions are stored in `volumes/functions`. The default setup has a `hello` function that you can invoke on `http://:8000/functions/v1/hello`. + +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` ### Accessing the APIs -Each of the APIs are available through the same API gateway: +Each of the APIs is available through the same API gateway: -- REST: `http://:8000/rest/v1/` +- REST: `http://:8000/rest/v1/` - Auth: `http://:8000/auth/v1/` - Storage: `http://:8000/storage/v1/` - Realtime: `http://:8000/realtime/v1/` -### Accessing your Edge Functions +## Updating -Edge Functions are stored in `volumes/functions`. The default setup has a `hello` Function that you can invoke on `http://:8000/functions/v1/hello`. +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). -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` +To follow the changes and updates, refer to the self-hosted Supabase [changelog](https://github.com/supabase/supabase/blob/master/docker/CHANGELOG.md). -### Accessing Postgres +You need to restart services to pick up the changes, which may result in downtime for your applications and users. -By default, the Supabase stack runs the [Supavisor](https://supabase.github.io/supavisor/development/docs/) connection pooler. Supavisor provides efficient management of database connections. +**Example:** +You'd like to update or rollback the Studio image. Follow the steps below: -You can connect to the Postgres database using the following methods: - -1. For session-based connections (equivalent to direct Postgres connections): - -```bash -psql 'postgres://postgres.your-tenant-id:your-super-secret-and-long-postgres-password@localhost:5432/postgres' -``` - -2. For pooled transactional connections: - -```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). - -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. - -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. - -``` -postgres://postgres:[POSTGRES_PASSWORD]@[your-server-ip]:5432/[POSTGRES_DB] -``` - -## Updating your services - -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). - -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. - -**Example** -You'll want to update the Studio(Dashboard) frequently to get the latest features and bug fixes. To update the Dashboard: - -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. - -## Securing your services - -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. - -### Generate API keys - -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. - -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. - - - -### Update API keys - -Run this form twice to generate new `anon` and `service` API keys. Replace the values in the `./docker/.env` file: - -- `ANON_KEY` - replace with an `anon` key -- `SERVICE_ROLE_KEY` - replace with a `service` key - -You will need to [restart](#restarting-all-services) the services for the changes to take effect. - -### Update secrets - -Update the `./docker/.env` file with your own secrets. In particular, these are required: - -- `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 will need to [restart](#restarting-all-services) the services for the changes to take effect. - -### Dashboard 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: - -- `DASHBOARD_USERNAME`: The default username for the Dashboard -- `DASHBOARD_PASSWORD`: The default password for the Dashboard - -You can also add more credentials for multiple users in `./docker/volumes/api/kong.yml`. For example: - -```yaml docker/volumes/api/kong.yml -basicauth_credentials: - - consumer: DASHBOARD - username: user_one - password: password_one - - consumer: DASHBOARD - username: user_two - password: password_two -``` - -To enable all dashboard features outside of `localhost`, update the following value in the `./docker/.env` file: - -- `SUPABASE_PUBLIC_URL`: The URL or IP used to access the dashboard - -You will need to [restart](#restarting-all-services) the services for the changes to take effect. - -## Restarting all services - -You can restart services to pick up any configuration changes by running: - -```sh -# Stop and remove the containers -docker compose down - -# Recreate and start the containers -docker compose up -d -``` - -{/* supa-mdx-lint-disable-next-line Rule004ExcludeWords */} - -Be aware that this will result in downtime. Simply restarting the services does not apply configuration changes. - -## Stopping all services - -You can stop Supabase by running `docker compose stop` in same directory as your `docker-compose.yml` file. +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: + + + Be careful — the following destroys all data, including the database and storage volumes! + + + +Remove all Postgres data: + +```sh rm -rf volumes/db/data/ ``` -This will destroy all data in the database and storage volumes, so be careful! - -## 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. Plain text files like dotenv lead to accidental costly leaks. - -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 - -## 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. +Each service has a number of configuration options you can find in the related 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/) - -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 94f8b90eda0..41a168c4a22 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 07/20] 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 a1c90d5fc24..b668b042da0 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 d35061a82ac..3a71fbcdb47 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 c1a02eae473..6972d72c4f9 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 d9b15525766..7ae49487102 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 56496082cbc..943ac726035 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 c36605d0561..b2a9580b975 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 a602a9e0051..09f21e6e093 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 83781c793ac..cab44ab4f0f 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 df1b7b0389c..74c8b636cd7 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 1b33676a6e5..98b55f0f096 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 915a6e99e92..e55f201de6d 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 24a6d10f8b3..00000000000 --- 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 a71b0a4915d..b4420c6af97 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 08/20] 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 | 257 ++++++++++-------- .../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, 749 insertions(+), 622 deletions(-) diff --git a/apps/studio/components/interfaces/Account/Preferences/AccountConnections.tsx b/apps/studio/components/interfaces/Account/Preferences/AccountConnections.tsx index be47dd6f31a..339521850b2 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 c7b848848bb..2d8b9173e15 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 8a1f9bcf4d1..08ebeb932bd 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' - - return ( - 1 ? 'last:border-t' : '')} - > -
- {`${identity.provider} -
-
-

{providerName}

- {provider === 'email' && data.new_email && !isChangeExpired && ( - - - Pending change - - Changing to {data.new_email} - - )} - {/* [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)} /> + + + + 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' - 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'}`, - }} - /> - + 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' } }} + /> + )} +
+
+ ) + })} +
+ )} + + + { + 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'}`, + }} + /> + + ) } diff --git a/apps/studio/components/interfaces/Account/Preferences/AnalyticsSettings.tsx b/apps/studio/components/interfaces/Account/Preferences/AnalyticsSettings.tsx index 35267f776f6..3b9a41739dd 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 b97f3d0a19c..669cce3c2ab 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 c28bcaa39e6..3d702465056 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 e4e752e784b..adb4fe08a2b 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 762a1b6c67d..1cb2298276d 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 303fa13e9d2..d2d237b7966 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 && ( -
- -
- )} + - {profileShowAccountDeletion && ( -
- -
- )} -
-
+ + + {profileShowAnalyticsAndMarketing && } + + {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 09/20] 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 9199c4e37c8..851fda3068e 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 e7db6d16c1a..60f4c422d2b 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 8d82d4e6641..ed36761b42f 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 607d463836c..ad6d47508bb 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 d53ea042153..6e25f8fa504 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 3ed1f237cef..b3c2e4723d9 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 b1cfed20599..1617e16d140 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 2a5a9e2901b..80ed25cc3de 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 ce44e4b1ae2..65b80adaf2c 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 71d5cc8872a..bb42b54ccc1 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 b304a7b9e10..413ac13dbce 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 3b4458a8f28..5be4a2dcdcb 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 10/20] 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 b6b883b9323..9454a245b8a 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 49b81a77a8f..775f444ff4f 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 38bfce96e77..9540c812a46 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 87bac1f1e2f..d2ace2cd703 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 1673e8f2ed0..5a2f558b385 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 4aec103a965..5e9c85dff48 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 ( -
-
-