From b9ad5cede77f83e5aa6df7654fe4730bdfa05b21 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 20 Aug 2026 12:26:31 +0800 Subject: [PATCH 1/9] Bump monaco to 4.80 rc (#49265) ## Context Resolves FE-4209 Client crash occurs when re-ordering a QueryCell in the new explorer UI with the error "InstantiationService has been disposed" Investigated this with Claude which eluded that it's a bug that's within the Monaco package which `4.8.0-rc.3` actually patched hence opting to upgrade the package. Verified that monaco still functions as expected + re-ordering query cells in the explorer UI no longer crashes ## Summary by CodeRabbit * **Chores** * Updated the Monaco Editor integration to release candidate version 4.8.0-rc.3. * No visible end-user functionality changes. --- pnpm-lock.yaml | 24 ++++++++++++------------ pnpm-workspace.yaml | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 305d81153a8a9..f71fbfbc99555 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,8 +7,8 @@ settings: catalogs: default: '@monaco-editor/react': - specifier: ^4.7.0 - version: 4.7.0 + specifier: 4.8.0-rc.3 + version: 4.8.0-rc.3 '@sentry/nextjs': specifier: ^10.59.0 version: 10.59.0 @@ -946,7 +946,7 @@ importers: version: 1.29.0(supports-color@8.1.1)(zod@3.25.76) '@monaco-editor/react': specifier: 'catalog:' - version: 4.7.0(monaco-editor@0.52.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 4.8.0-rc.3(monaco-editor@0.52.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@next/bundle-analyzer': specifier: 16.2.3 version: 16.2.3 @@ -1427,7 +1427,7 @@ importers: version: 3.3.1(react-hook-form@7.72.1(react@19.2.6)) '@monaco-editor/react': specifier: 'catalog:' - version: 4.7.0(monaco-editor@0.52.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 4.8.0-rc.3(monaco-editor@0.52.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-router/fs-routes': specifier: ^7.4.0 version: 7.4.0(@react-router/dev@7.13.2(@react-router/serve@7.17.0(react-router@7.18.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(supports-color@8.1.1)(typescript@6.0.2))(@types/node@22.13.14)(babel-plugin-macros@3.1.0)(jiti@2.7.0)(lightningcss@1.33.0)(react-router@7.18.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(sass@1.77.4)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.22.4)(typescript@6.0.2)(vite@8.0.16(@types/node@22.13.14)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.22.4)(yaml@2.9.0))(yaml@2.9.0))(typescript@6.0.2) @@ -2666,7 +2666,7 @@ importers: version: 3.3.1(react-hook-form@7.72.1(react@19.2.6)) '@monaco-editor/react': specifier: 'catalog:' - version: 4.7.0(monaco-editor@0.52.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 4.8.0-rc.3(monaco-editor@0.52.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@supabase/sql-to-rest': specifier: ^0.1.6 version: 0.1.6(encoding@0.1.13)(supports-color@8.1.1) @@ -4808,11 +4808,11 @@ packages: '@cfworker/json-schema': optional: true - '@monaco-editor/loader@1.5.0': - resolution: {integrity: sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw==} + '@monaco-editor/loader@1.7.0': + resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==} - '@monaco-editor/react@4.7.0': - resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==} + '@monaco-editor/react@4.8.0-rc.3': + resolution: {integrity: sha512-fNGFOqgHZKisVv7FlDbRL31oCRJfHBi7vIZJfy4zcbWElxqLRR5nLeG0CYwIUnq7txgPvRnRR1otcFvbs7ZSdA==} peerDependencies: monaco-editor: 0.52.2 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -20797,13 +20797,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@monaco-editor/loader@1.5.0': + '@monaco-editor/loader@1.7.0': dependencies: state-local: 1.0.7 - '@monaco-editor/react@4.7.0(monaco-editor@0.52.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@monaco-editor/react@4.8.0-rc.3(monaco-editor@0.52.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@monaco-editor/loader': 1.5.0 + '@monaco-editor/loader': 1.7.0 monaco-editor: 0.52.2 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6bf4113c0aacf..de016fe1bb63e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,7 +11,7 @@ updateNotifier: false enableGlobalVirtualStore: false catalog: - '@monaco-editor/react': ^4.7.0 + '@monaco-editor/react': 4.8.0-rc.3 '@sentry/nextjs': ^10.59.0 '@sentry/tanstackstart-react': ^10.59.0 '@supabase/auth-js': 2.112.3 From 8bdfe03fe765e1e6b4019cea0c3b0ba69cb7c980 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Thu, 20 Aug 2026 01:06:41 -0400 Subject: [PATCH 2/9] refactor(studio): drop notebook type widening now that the API supports it (#49272) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Regenerates `packages/api-types` for the content endpoints now that the Platform API's `notebook` content type has landed (list/get/upsert `type` enums, plus `UpsertContentBody`'s notebook cell shape with `_id`/`y_series`). Unrelated schema drift from the same regen (Warehouse, SSO, notification exceptions, etc.) is excluded — only the content-endpoint hunks are applied. - Removes every local widening cast added while the API support was pending (`content-query.ts`, `content-infinite-query.ts`, `notebook-query.ts`, `notebook-upsert-mutation.ts`, `sql-folders-query.ts`). - What remains is scoped and renamed to match: draft ids (`generateDraftId`/`isDraftId`), used only for cells created client-side in the editor before their first save, dropped before they'd ever reach the backend as a fake `_id`. ## Test plan - [x] `pnpm typecheck` — clean - [x] `pnpm --filter studio test` — full suite passes (518 files / 5471 tests) - [x] `pnpm --filter studio run lint:ratchet` — no new warnings - [x] `pnpm format` / prettier — clean ## Summary by CodeRabbit * **Bug Fixes** * Improved notebook cell tracking during editing, reordering, insertion, and deletion. * Preserved existing cell identifiers while removing temporary draft identifiers before saving. * Improved chart configuration for selecting and displaying multiple Y-axis series. * Strengthened notebook validation and content persistence behavior. --------- Co-authored-by: Joshen Lim --- .../Explorer/ExplorerNotebookTab.tsx | 14 +- .../interfaces/Explorer/MarkdownCell.tsx | 14 +- .../Explorer/MoveCellDropdownContent.tsx | 2 +- .../NotebookPreview/NotebookPreview.test.tsx | 4 +- .../NotebookPreview.utils.test.ts | 6 +- .../NotebookPreview/NotebookPreview.utils.ts | 21 +-- .../QueryCell/QueryCell.utils.test.ts | 12 +- .../Explorer/QueryCell/QueryCell.utils.ts | 8 +- .../interfaces/Explorer/QueryCell/index.tsx | 10 +- .../QueryEditor/DisplaySettingsButton.tsx | 38 +++-- .../Explorer/QueryEditor/QueryResultChart.tsx | 98 ++++++------- .../__tests__/ExplorerNotebookTab.test.tsx | 2 +- .../components/interfaces/Explorer/utils.ts | 6 +- .../AssistantQueryCell.utils.test.ts | 4 +- .../AssistantQueryCell.utils.ts | 2 +- .../NotebookProposalRenderer.test.tsx | 4 +- .../data/content/content-infinite-query.ts | 5 +- apps/studio/data/content/content-query.ts | 11 +- .../studio/data/content/content-remap.test.ts | 52 +++++-- apps/studio/data/content/content-remap.ts | 10 +- .../notebooks/notebook-operations.test.ts | 18 +-- .../content/notebooks/notebook-operations.ts | 16 +-- .../content/notebooks/notebook-query.test.ts | 8 +- .../data/content/notebooks/notebook-query.ts | 7 +- .../content/notebooks/notebook-schema.test.ts | 111 ++++++++++++--- .../data/content/notebooks/notebook-schema.ts | 130 ++++++++++++------ .../notebook-upsert-mutation.test.ts | 11 +- .../notebooks/notebook-upsert-mutation.ts | 23 +--- .../notebooks-infinite-query.test.ts | 4 +- apps/studio/data/content/sql-folders-query.ts | 8 +- apps/studio/lib/ai/tools/mock-tools.test.ts | 6 +- apps/studio/lib/ai/tools/mock-tools.ts | 22 +-- .../lib/ai/tools/notebook-tools.test.ts | 14 +- apps/studio/lib/ai/tools/notebook-tools.ts | 49 ++----- .../state/notebooks/notebooks-state.test.ts | 6 +- .../studio/state/notebooks/notebooks-state.ts | 12 +- packages/api-types/types/platform.d.ts | 88 ++++++++++-- 37 files changed, 500 insertions(+), 356 deletions(-) diff --git a/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx b/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx index adc39e12076bd..b0fb890f53ac8 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx @@ -41,7 +41,7 @@ export const ExplorerNotebookTab = () => { const currentNotebook = useCurrentNotebook() const { name, content } = currentNotebook?.notebook ?? {} const cells = content?.cells ?? [] - const queryCellIds = cells.filter(isQueryCell).map((cell) => cell.id) + const queryCellIds = cells.filter(isQueryCell).map((cell) => cell._id) const [isRunningNotebook, setIsRunningNotebook] = useState(false) const queryCellRefs = useRef(new Map()) @@ -82,7 +82,7 @@ export const ExplorerNotebookTab = () => { if (!notebookId) return const cell = type === 'markdown' ? createMarkdownCellSkeleton() : createQueryCellSkeleton() - const lastCellId = cells[cells.length - 1]?.id + const lastCellId = cells[cells.length - 1]?._id snap.insertCellAfter({ id: notebookId, cellId: lastCellId, cell }) } @@ -133,22 +133,22 @@ export const ExplorerNotebookTab = () => { <> cell.id)} + items={cells.map((cell) => cell._id)} strategy={verticalListSortingStrategy} >
{cells.map((cell) => isQueryCell(cell) ? ( { - if (instance) queryCellRefs.current.set(cell.id, instance) - else queryCellRefs.current.delete(cell.id) + if (instance) queryCellRefs.current.set(cell._id, instance) + else queryCellRefs.current.delete(cell._id) }} /> ) : ( - + ) )}
diff --git a/apps/studio/components/interfaces/Explorer/MarkdownCell.tsx b/apps/studio/components/interfaces/Explorer/MarkdownCell.tsx index 3cbf89238be8d..df8dc8860f813 100644 --- a/apps/studio/components/interfaces/Explorer/MarkdownCell.tsx +++ b/apps/studio/components/interfaces/Explorer/MarkdownCell.tsx @@ -41,7 +41,7 @@ export const MarkdownCell = ({ cell }: MarkdownCellProps) => { const notebookId = currentNotebook?.notebook.id if (!notebookId) return - const nextCells = cells.map((c) => (c.id === cellId ? { ...c, text } : c)) + const nextCells = cells.map((c) => (c._id === cellId ? { ...c, text } : c)) snap.updateCells({ id: notebookId, cells: nextCells }) setIsEditing(false) } @@ -50,9 +50,9 @@ export const MarkdownCell = ({ cell }: MarkdownCellProps) => { return ( } - gripDropdownContent={} + id={cell._id} + actions={} + gripDropdownContent={} gripClassName="mt-1.5 opacity-0 group-hover:opacity-100 has-[[data-state=open]]:opacity-100 transition" > {isEditing ? ( @@ -84,10 +84,10 @@ export const MarkdownCell = ({ cell }: MarkdownCellProps) => { ].join(' && ') ) editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => - handleUpdateMarkdownRef.current(cell.id, valueRef.current) + handleUpdateMarkdownRef.current(cell._id, valueRef.current) ) editor.onDidBlurEditorWidget(() => - handleUpdateMarkdownRef.current(cell.id, valueRef.current) + handleUpdateMarkdownRef.current(cell._id, valueRef.current) ) }} /> @@ -100,7 +100,7 @@ export const MarkdownCell = ({ cell }: MarkdownCellProps) => { diff --git a/apps/studio/components/interfaces/Explorer/MoveCellDropdownContent.tsx b/apps/studio/components/interfaces/Explorer/MoveCellDropdownContent.tsx index 5fb0e392ba77c..9cc1632a111ae 100644 --- a/apps/studio/components/interfaces/Explorer/MoveCellDropdownContent.tsx +++ b/apps/studio/components/interfaces/Explorer/MoveCellDropdownContent.tsx @@ -12,7 +12,7 @@ export const MoveCellDropdownContent = ({ cellId }: MoveCellDropdownContentProps const currentNotebook = useCurrentNotebook() const cells = currentNotebook?.notebook.content?.cells ?? [] - const currentIndex = cells.findIndex((c) => c.id === cellId) + const currentIndex = cells.findIndex((c) => c._id === cellId) const isFirstCell = currentIndex <= 0 const isLastCell = currentIndex === -1 || currentIndex === cells.length - 1 diff --git a/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.test.tsx b/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.test.tsx index e12d35d52b173..a1f9e8622f86c 100644 --- a/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.test.tsx +++ b/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.test.tsx @@ -8,7 +8,7 @@ import { customRender as render } from '@/tests/lib/custom-render' const wireMarkdownCell = (id: string, text: string): CellWire => ({ _tag: 'markdown_cell', - id, + _id: id, text, }) @@ -16,7 +16,7 @@ const agentMarkdownCell = (text: string): AgentCell => ({ _tag: 'markdown_cell', const wireDatabaseCell = (id: string, database_identifier?: string): CellWire => ({ _tag: 'database_cell', - id, + _id: id, sql: 'select 1', row_limit: 100, database_identifier, diff --git a/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.utils.test.ts b/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.utils.test.ts index 5f4523daea56a..f9ca459fa5e87 100644 --- a/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.utils.test.ts +++ b/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.utils.test.ts @@ -15,7 +15,7 @@ import { isoDateTimeString } from '@/lib/iso-datetime' const wireMarkdownCell = (id: string, text = 'hello'): CellWire => ({ _tag: 'markdown_cell', - id, + _id: id, text, }) @@ -23,7 +23,7 @@ const agentMarkdownCell = (text = 'hello'): AgentCell => ({ _tag: 'markdown_cell const wireDatabaseCell = (id: string, title?: string, database_identifier?: string): CellWire => ({ _tag: 'database_cell', - id, + _id: id, title, sql: 'select 1', row_limit: 100, @@ -32,7 +32,7 @@ const wireDatabaseCell = (id: string, title?: string, database_identifier?: stri const wireLogCell = (id: string): CellWire => ({ _tag: 'log_cell', - id, + _id: id, sql: 'select 1', time_range: { _tag: 'relative_time_range', unit: 'day', amount: 7 }, }) diff --git a/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.utils.ts b/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.utils.ts index 3d40260a6e87b..11959be18c817 100644 --- a/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.utils.ts +++ b/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.utils.ts @@ -1,16 +1,19 @@ import dayjs from 'dayjs' import type { CodeBlockLang } from 'ui-patterns/CodeBlock' -import type { NotebookCellDiffEntry } from '@/data/content/notebooks/notebook-operations' -import type { AgentCell, CellWire, TimeRange } from '@/data/content/notebooks/notebook-schema' +import type { + NotebookCellDiffEntry, + OperationResultCell, +} from '@/data/content/notebooks/notebook-operations' +import type { TimeRange } from '@/data/content/notebooks/notebook-schema' -/** React key for a diff entry. Added/replaced cells have no `id`, so they key off the operation. */ +/** React key for a diff entry. Added/replaced cells have no `_id`, so they key off the operation. */ export function getEntryKey(entry: NotebookCellDiffEntry): string { switch (entry._tag) { case 'unchanged': case 'removed': case 'moved': - return entry.cell.id + return entry.cell._id case 'added': case 'replaced': return `op-${entry.operationIndex}` @@ -27,7 +30,7 @@ export function isEntryExpandedByDefault(entry: NotebookCellDiffEntry): boolean } /** Human label for a collapsed/badge row. */ -export function getCellLabel(cell: CellWire | AgentCell): string { +export function getCellLabel(cell: OperationResultCell): string { switch (cell._tag) { case 'markdown_cell': return 'Markdown cell' @@ -38,7 +41,7 @@ export function getCellLabel(cell: CellWire | AgentCell): string { } /** The cell's underlying source text, regardless of backend. */ -export function getCellSourceText(cell: CellWire | AgentCell): string { +export function getCellSourceText(cell: OperationResultCell): string { switch (cell._tag) { case 'markdown_cell': return cell.text @@ -49,7 +52,7 @@ export function getCellSourceText(cell: CellWire | AgentCell): string { } /** Language for rendering the cell's source via `CodeBlock`. */ -export function getCellCodeBlockLanguage(cell: CellWire | AgentCell): CodeBlockLang { +export function getCellCodeBlockLanguage(cell: OperationResultCell): CodeBlockLang { switch (cell._tag) { case 'markdown_cell': return 'markdown' @@ -60,7 +63,7 @@ export function getCellCodeBlockLanguage(cell: CellWire | AgentCell): CodeBlockL } /** Monaco language id for rendering the cell's source via `DiffEditor`. */ -export function getCellMonacoLanguage(cell: CellWire | AgentCell): string { +export function getCellMonacoLanguage(cell: OperationResultCell): string { switch (cell._tag) { case 'markdown_cell': return 'markdown' @@ -85,7 +88,7 @@ export function formatTimeRange(range: TimeRange): string { * when the cell has none. A `replace_cell` can change only this and leave `sql` identical, * so it's compared independently of the source text rather than folded into it. */ -export function getCellMetadataLine(cell: CellWire | AgentCell): string | null { +export function getCellMetadataLine(cell: OperationResultCell): string | null { switch (cell._tag) { case 'markdown_cell': return null diff --git a/apps/studio/components/interfaces/Explorer/QueryCell/QueryCell.utils.test.ts b/apps/studio/components/interfaces/Explorer/QueryCell/QueryCell.utils.test.ts index 1d11993ee232a..2480bdc709a69 100644 --- a/apps/studio/components/interfaces/Explorer/QueryCell/QueryCell.utils.test.ts +++ b/apps/studio/components/interfaces/Explorer/QueryCell/QueryCell.utils.test.ts @@ -16,7 +16,7 @@ import { untrustedLogSql } from '@/data/logs/safe-analytics-sql' const CHART: ChartConfig = { type: 'bar', x_column: 'day', - y_columns: ['signups'], + y_series: ['signups'], cumulative: false, scale: 'linear', show_labels: true, @@ -24,7 +24,7 @@ const CHART: ChartConfig = { const DATABASE_CELL: QueryCell = { _tag: 'database_cell', - id: 'cell-1', + _id: 'cell-1', title: 'Signups', view: 'chart', chart: CHART, @@ -35,7 +35,7 @@ const DATABASE_CELL: QueryCell = { const LOG_CELL: QueryCell = { _tag: 'log_cell', - id: 'cell-2', + _id: 'cell-2', title: 'Edge errors', view: 'table', chart: CHART, @@ -66,7 +66,7 @@ describe('changeCellSource', () => { expect(next).toEqual({ _tag: 'log_cell', - id: 'cell-1', + _id: 'cell-1', title: 'Signups', view: 'chart', chart: CHART, @@ -80,7 +80,7 @@ describe('changeCellSource', () => { expect(next).toEqual({ _tag: 'database_cell', - id: 'cell-2', + _id: 'cell-2', title: 'Edge errors', view: 'table', chart: CHART, @@ -138,7 +138,7 @@ describe('cloneQueryCell', () => { const clone = cloneQueryCell(DATABASE_CELL) expect(clone).toEqual(DATABASE_CELL) - expect(clone.chart?.y_columns).not.toBe(DATABASE_CELL.chart?.y_columns) + expect(clone.chart?.y_series).not.toBe(DATABASE_CELL.chart?.y_series) }) }) diff --git a/apps/studio/components/interfaces/Explorer/QueryCell/QueryCell.utils.ts b/apps/studio/components/interfaces/Explorer/QueryCell/QueryCell.utils.ts index ca6cb3edb57c4..5eabf93bd831d 100644 --- a/apps/studio/components/interfaces/Explorer/QueryCell/QueryCell.utils.ts +++ b/apps/studio/components/interfaces/Explorer/QueryCell/QueryCell.utils.ts @@ -17,13 +17,13 @@ export const DEFAULT_CELL_ROW_LIMIT = 100 * Valtio snapshots are deep-readonly. Readonly properties assign to mutable ones, so only * the array needs rebuilding to turn a snapshot's chart back into a writable config. */ -type ReadonlyChartConfig = Omit & { - readonly y_columns: readonly string[] +type ReadonlyChartConfig = Omit & { + readonly y_series: readonly string[] } export const cloneChartConfig = ( chart: ReadonlyChartConfig | undefined -): ChartConfig | undefined => (chart ? { ...chart, y_columns: [...chart.y_columns] } : undefined) +): ChartConfig | undefined => (chart ? { ...chart, y_series: [...chart.y_series] } : undefined) /** The display state a query cell hands the shared editor. */ // `view` is already defaulted to 'table' by the domain transform, so there is nothing to @@ -35,7 +35,7 @@ export const getCellDisplay = (cell: Snapshot): QueryDisplay => ({ /** Fields every query cell carries, copied out of a snapshot so the result is writable. */ const copyQueryCellBase = (cell: Snapshot) => ({ - id: cell.id, + _id: cell._id, title: cell.title, view: cell.view, chart: cloneChartConfig(cell.chart), diff --git a/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx b/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx index dec953401334f..5293f0f3ecc3b 100644 --- a/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx @@ -53,7 +53,7 @@ export const QueryCell = forwardRef(function snap.updateCell({ id: notebookId, - cellId: cell.id, + cellId: cell._id, updater: (candidate) => (isQueryCell(candidate) ? updater(candidate) : candidate), }) } @@ -89,14 +89,14 @@ export const QueryCell = forwardRef(function return ( } - gripDropdownContent={} + id={cell._id} + actions={} + gripDropdownContent={} gripClassName="mt-2 opacity-0 group-hover:opacity-100 has-[[data-state=open]]:opacity-100 transition" > void } -const getLogScaleDisabledReason = (y_columns: string[]) => { - if (y_columns.length === 0) return 'Select a column for the Y axis first' - if (y_columns.length > 1) return 'Only available with a single Y axis column' +const getLogScaleDisabledReason = (y_series: string[]) => { + if (y_series.length === 0) return 'Select a column for the Y axis first' + if (y_series.length > 1) return 'Only available with a single Y axis column' return 'Data contains zero or negative values' } @@ -56,22 +56,22 @@ export const DisplaySettingsButton = ({ const { type = 'bar', x_column, - y_columns = [], + y_series = [], cumulative = false, show_labels = false, scale = 'linear', } = chart ?? {} const hasNonPositiveValues = useMemo( - () => checkHasNonPositiveValues(result?.rows ?? [], y_columns[0]), - [result, y_columns] + () => checkHasNonPositiveValues(result?.rows ?? [], y_series[0]), + [result, y_series] ) // Logarithmic scale only applies to a single series. const canToggleLogScale = useMemo(() => { - if (y_columns.length !== 1 || !result || (result.rows ?? []).length === 0) return false + if (y_series.length !== 1 || !result || (result.rows ?? []).length === 0) return false return !hasNonPositiveValues - }, [hasNonPositiveValues, result, y_columns.length]) + }, [hasNonPositiveValues, result, y_series.length]) const onChangeView = (view: 'table' | 'chart') => { onChange({ ...display, view }) @@ -83,7 +83,7 @@ export const DisplaySettingsButton = ({ chart: { type: chart?.type ?? 'bar', x_column: chart?.x_column ?? '', - y_columns: chart?.y_columns ?? [], + y_series: chart?.y_series ?? [], cumulative: chart?.cumulative ?? false, scale: chart?.scale ?? 'linear', show_labels: chart?.show_labels ?? false, @@ -97,10 +97,10 @@ export const DisplaySettingsButton = ({ }) useEffect(() => { - if (scale === 'log' && (hasNonPositiveValues || y_columns.length > 1)) { + if (scale === 'log' && (hasNonPositiveValues || y_series.length > 1)) { resetToLinearScale() } - }, [hasNonPositiveValues, scale, y_columns.length]) + }, [hasNonPositiveValues, scale, y_series.length]) return ( @@ -187,16 +187,16 @@ export const DisplaySettingsButton = ({ className="[&>div:first-child]:xl:w-3/5" > { - if (values.length > MAX_CHART_Y_COLUMNS) return - onUpdateChartConfig({ y_columns: values }) + if (values.length > MAX_CHART_Y_SERIES) return + onUpdateChartConfig({ y_series: values }) }} className="w-full" > = MAX_CHART_Y_COLUMNS && !y_columns.includes(x) - } + disabled={y_series.length >= MAX_CHART_Y_SERIES && !y_series.includes(x)} > {x} @@ -249,7 +247,7 @@ export const DisplaySettingsButton = ({ {!canToggleLogScale && ( - {getLogScaleDisabledReason(y_columns)} + {getLogScaleDisabledReason(y_series)} )} diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultChart.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultChart.tsx index 52958c120b795..09787e596c77f 100644 --- a/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultChart.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultChart.tsx @@ -12,7 +12,7 @@ interface QueryResultChartProps { result?: QueryResult } -const Y_COLUMN_COLORS = [ +const Y_SERIES_COLORS = [ 'hsl(var(--brand-default))', 'hsl(var(--chart-blue))', 'hsl(var(--chart-3))', @@ -25,36 +25,36 @@ const toChartValue = (value: unknown): string | number => { } export const QueryResultChart = ({ chart, result }: QueryResultChartProps) => { - const { type, x_column, y_columns = [], cumulative, show_labels, scale } = chart ?? {} + const { type, x_column, y_series = [], cumulative, show_labels, scale } = chart ?? {} - const hasConfig = !!x_column && y_columns.length > 0 + const hasConfig = !!x_column && y_series.length > 0 // Logarithmic scale only makes sense for a single series — DisplaySettingsButton // resets `scale` to linear once a second Y column is added - const effectiveScale = y_columns.length > 1 ? 'linear' : scale + const effectiveScale = y_series.length > 1 ? 'linear' : scale const chartConfig: ChartSeriesConfig = useMemo( () => - y_columns.reduce((acc, key, index) => { - acc[key] = { label: key, color: Y_COLUMN_COLORS[index] } + y_series.reduce((acc, key, index) => { + acc[key] = { label: key, color: Y_SERIES_COLORS[index] } return acc }, {} as ChartSeriesConfig), - [y_columns] + [y_series] ) const chartRows = useMemo(() => { const xKey = x_column ?? '' return (result?.rows ?? []).map((row) => { const chartRow: Record = { [xKey]: toChartValue(row[xKey]) } - y_columns.forEach((yKey) => { + y_series.forEach((yKey) => { chartRow[yKey] = toChartValue(row[yKey]) }) return chartRow }) - }, [result, x_column, y_columns]) + }, [result, x_column, y_series]) const cumulativeResults = useMemo( - () => getCumulativeResults({ rows: chartRows }, { yKey: y_columns }), - [chartRows, y_columns] + () => getCumulativeResults({ rows: chartRows }, { yKey: y_series }), + [chartRows, y_series] ) const resultToRender = cumulative ? cumulativeResults : chartRows @@ -81,43 +81,45 @@ export const QueryResultChart = ({ chart, result }: QueryResultChartProps) => { } return ( - - - - {type === 'bar' && ( - - )} - {type === 'line' && ( - - )} + + + +
+ {type === 'bar' && ( + + )} + {type === 'line' && ( + + )} +
diff --git a/apps/studio/components/interfaces/Explorer/__tests__/ExplorerNotebookTab.test.tsx b/apps/studio/components/interfaces/Explorer/__tests__/ExplorerNotebookTab.test.tsx index 882d79dfb94d6..71425a3fb5eb2 100644 --- a/apps/studio/components/interfaces/Explorer/__tests__/ExplorerNotebookTab.test.tsx +++ b/apps/studio/components/interfaces/Explorer/__tests__/ExplorerNotebookTab.test.tsx @@ -43,7 +43,7 @@ const NOTEBOOK_ID = 'notebook-test' const databaseCell = createQueryCellSkeleton({ sql: 'select 1' }) const logCell = { _tag: 'log_cell' as const, - id: 'log-cell-1', + _id: 'log-cell-1', view: 'table' as const, chart: undefined, unchecked_sql: untrustedLogSql('select * from edge_logs limit 1'), diff --git a/apps/studio/components/interfaces/Explorer/utils.ts b/apps/studio/components/interfaces/Explorer/utils.ts index 70caeba255f24..92a4cc2dcdbaa 100644 --- a/apps/studio/components/interfaces/Explorer/utils.ts +++ b/apps/studio/components/interfaces/Explorer/utils.ts @@ -1,12 +1,12 @@ import { untrustedSql } from '@supabase/pg-meta' import { DEFAULT_CELL_ROW_LIMIT } from './QueryCell/QueryCell.utils' -import { generateUuid } from '@/lib/api/snippets.browser' +import { generateDraftId } from '@/data/content/notebooks/notebook-schema' export const createQueryCellSkeleton = ({ sql }: { sql?: string } = {}) => { return { _tag: 'database_cell' as const, - id: generateUuid(), + _id: generateDraftId(), view: 'table' as const, chart: undefined, unchecked_sql: untrustedSql(sql ?? ''), @@ -26,7 +26,7 @@ export const createMarkdownCellSkeleton = ({ } = {}) => { return { _tag: 'markdown_cell' as const, - id: generateUuid(), + _id: generateDraftId(), text: content, } } diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.utils.test.ts b/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.utils.test.ts index b64eddf7e3e45..f5fc067f602cf 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.utils.test.ts +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.utils.test.ts @@ -22,7 +22,7 @@ describe('getAssistantQueryDisplay', () => { chart: { type: 'bar', x_column: 'day', - y_columns: ['signups'], + y_series: ['signups'], cumulative: false, scale: 'linear', show_labels: false, @@ -31,7 +31,7 @@ describe('getAssistantQueryDisplay', () => { }) it('keeps an empty y-axis list when only the x-axis is provided', () => { - expect(getAssistantQueryDisplay({ xAxis: 'day' }).chart?.y_columns).toEqual([]) + expect(getAssistantQueryDisplay({ xAxis: 'day' }).chart?.y_series).toEqual([]) }) }) diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.utils.ts b/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.utils.ts index a09fc6fba5875..930134aea1257 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.utils.ts +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.utils.ts @@ -25,7 +25,7 @@ export function getAssistantQueryDisplay({ ? { type: 'bar', x_column: xAxis ?? '', - y_columns: yAxis ? [yAxis] : [], + y_series: yAxis ? [yAxis] : [], cumulative: false, scale: 'linear', show_labels: false, diff --git a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx index ba4d765cc1756..61f5de48c25d3 100644 --- a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx @@ -25,8 +25,8 @@ const mockNotebookRow = (overrides: Partial> = {}) => ({ content: { schema_version: 1, cells: [ - { _tag: 'markdown_cell', id: 'cell-1', text: 'hello' }, - { _tag: 'markdown_cell', id: 'cell-2', text: 'world' }, + { _tag: 'markdown_cell', _id: 'cell-1', text: 'hello' }, + { _tag: 'markdown_cell', _id: 'cell-2', text: 'world' }, ], }, ...overrides, diff --git a/apps/studio/data/content/content-infinite-query.ts b/apps/studio/data/content/content-infinite-query.ts index 706a897eff70f..5e148cc3f01a4 100644 --- a/apps/studio/data/content/content-infinite-query.ts +++ b/apps/studio/data/content/content-infinite-query.ts @@ -28,10 +28,7 @@ export async function getContent( params: { path: { ref: projectRef }, query: { - // TODO — Charis 2026-08-06 - // Cast until the generated query param type picks up 'notebook' (see ContentBase in - // content-query.ts) - type: type as 'sql' | 'report' | 'log_sql', + type, name, sort_by: sort, limit: limit.toString(), diff --git a/apps/studio/data/content/content-query.ts b/apps/studio/data/content/content-query.ts index b555f21cdf7d6..385a15073bc96 100644 --- a/apps/studio/data/content/content-query.ts +++ b/apps/studio/data/content/content-query.ts @@ -12,12 +12,7 @@ import type { UseCustomQueryOptions, } from '@/types' -// TODO — Charis 2026-08-06 -// Temporary widening until we have API support for notebooks -export type ContentBase = Omit< - components['schemas']['GetUserContentResponse']['data'][number], - 'type' -> & { type: components['schemas']['GetUserContentResponse']['data'][number]['type'] | 'notebook' } +export type ContentBase = components['schemas']['GetUserContentResponse']['data'][number] export type Content = Omit & ( @@ -65,9 +60,7 @@ export async function getContent( const { data, error } = await get('/platform/projects/{ref}/content', { params: { path: { ref: projectRef }, - // TODO — Charis 2026-08-06 - // Cast until the generated query param type picks up 'notebook' (see ContentBase above) - query: { type: type as 'sql' | 'report' | 'log_sql', name, limit: limit.toString() }, + query: { type, name, limit: limit.toString() }, }, headers, signal, diff --git a/apps/studio/data/content/content-remap.test.ts b/apps/studio/data/content/content-remap.test.ts index 36f4648157168..fabb0d08db9d1 100644 --- a/apps/studio/data/content/content-remap.test.ts +++ b/apps/studio/data/content/content-remap.test.ts @@ -6,20 +6,20 @@ import { untrustedLogSql } from '@/data/logs/safe-analytics-sql' const MARKDOWN_CELL = { _tag: 'markdown_cell' as const, - id: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', + _id: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', text: '# Signup funnel', } const DATABASE_CELL = { _tag: 'database_cell' as const, - id: 'b1ffcd88-8d1a-4de7-aa5c-5aa8ac270b22', + _id: 'b1ffcd88-8d1a-4de7-aa5c-5aa8ac270b22', sql: 'select * from auth.users limit 100', row_limit: 100, } const LOG_CELL = { _tag: 'log_cell' as const, - id: 'c2001199-1e2b-4ef8-bb6d-6bb9bd380a33', + _id: 'c2001199-1e2b-4ef8-bb6d-6bb9bd380a33', sql: "select timestamp, event_message from edge_logs where source = 'edge_logs' limit 10", time_range: { _tag: 'relative_time_range' as const, @@ -113,10 +113,14 @@ describe('remapSqlContentField', () => { const result = remapSqlContentField(NOTEBOOK) const [markdownCell, databaseCell, logCell] = result.content.cells - expect(markdownCell).toEqual(MARKDOWN_CELL) + expect(markdownCell).toEqual({ + _tag: 'markdown_cell', + _id: MARKDOWN_CELL._id, + text: MARKDOWN_CELL.text, + }) expect(databaseCell).toEqual({ _tag: 'database_cell', - id: DATABASE_CELL.id, + _id: DATABASE_CELL._id, row_limit: 100, view: 'table', unchecked_sql: untrustedSql(DATABASE_CELL.sql), @@ -124,7 +128,7 @@ describe('remapSqlContentField', () => { expect(databaseCell).not.toHaveProperty('sql') expect(logCell).toEqual({ _tag: 'log_cell', - id: LOG_CELL.id, + _id: LOG_CELL._id, time_range: LOG_CELL.time_range, view: 'table', unchecked_sql: untrustedLogSql(LOG_CELL.sql), @@ -138,7 +142,7 @@ describe('remapSqlContentField', () => { type: 'notebook' as const, content: { schema_version: 1 as const, - cells: [{ _tag: 'database_cell' as const, id: '1', sql: 'select 1' }], + cells: [{ _tag: 'database_cell' as const, _id: '1', sql: 'select 1' }], }, } @@ -235,9 +239,9 @@ describe('unmapSqlContentField', () => { describe('unmapSqlContentField (notebooks)', () => { // Notebook content only ever reaches `unmapSqlContentField` via createNotebook/updateNotebook, - // which always hand it a WritableNotebook — plain `sql` per cell, `id` present only for - // existing cells. That's already wire-shaped, so this is a pure passthrough. - it('returns notebook content completely unchanged', () => { + // which always hand it a WritableNotebook already keyed on the wire's `_id` — present for + // an existing cell, omitted for a new one. + it('passes a real _id through, and leaves a cell with none without one', () => { const writableNotebook = { id: 'e4bbee88-8d2b-4de7-aa5c-5aa8ac270b55', type: 'notebook' as const, @@ -247,7 +251,7 @@ describe('unmapSqlContentField (notebooks)', () => { { _tag: 'markdown_cell' as const, text: '# New cell' }, { _tag: 'database_cell' as const, - id: 'b1ffcd88-8d1a-4de7-aa5c-5aa8ac270b22', + _id: 'b1ffcd88-8d1a-4de7-aa5c-5aa8ac270b22', sql: 'select * from auth.users limit 100', row_limit: 100, }, @@ -266,7 +270,31 @@ describe('unmapSqlContentField (notebooks)', () => { const result = unmapSqlContentField(writableNotebook) - expect(result).toBe(writableNotebook) + const [markdownCell, databaseCell, logCell] = result.content.cells + expect(markdownCell).not.toHaveProperty('_id') + expect(databaseCell).toMatchObject({ _id: 'b1ffcd88-8d1a-4de7-aa5c-5aa8ac270b22' }) + expect(logCell).not.toHaveProperty('_id') + }) + + it('drops a client-fabricated draft id rather than sending it back as _id', () => { + const writableNotebook = { + id: 'e4bbee88-8d2b-4de7-aa5c-5aa8ac270b55', + type: 'notebook' as const, + content: { + schema_version: 1 as const, + cells: [ + { + _tag: 'markdown_cell' as const, + _id: 'draft-b1ffcd88-8d1a-4de7-aa5c-5aa8ac270b22', + text: '# New cell', + }, + ], + }, + } + + const result = unmapSqlContentField(writableNotebook) + + expect(result.content.cells[0]).not.toHaveProperty('_id') }) }) diff --git a/apps/studio/data/content/content-remap.ts b/apps/studio/data/content/content-remap.ts index a01b09d7e3bff..c164b9ea63ef7 100644 --- a/apps/studio/data/content/content-remap.ts +++ b/apps/studio/data/content/content-remap.ts @@ -11,7 +11,8 @@ // (data/content/notebooks/notebook-schema.ts) instead of the single-field swap below. import { untrustedSql } from '@supabase/pg-meta' -import { notebookDomainSchema } from './notebooks/notebook-schema' +import type { WritableNotebook } from './notebooks/notebook-schema' +import { notebookDomainSchema, toWireWritableNotebook } from './notebooks/notebook-schema' import type { SnippetStatus } from './snippet-status' import type { SnippetWithContent } from './sql-folders-query' import { untrustedLogSql } from '@/data/logs/safe-analytics-sql' @@ -58,10 +59,9 @@ export function remapWireSnippet(row: unknown, status: SnippetStatus): SnippetWi // Reverse remap: `unchecked_sql` → `sql` before sending to the API. export function unmapSqlContentField(item: T): T { if (isNotebookContentType(item.type)) { - // Notebook content only ever reaches this function via createNotebook/updateNotebook, - // which always hand it a WritableNotebook — plain `sql` per cell, `id` present only - // for existing cells. That's already wire-shaped, so there's nothing to unmap. - return item + if (!('content' in item)) return item + const content = toWireWritableNotebook(item.content as WritableNotebook) + return { ...item, content } as T } if (!isSqlContentType(item.type)) return item if (!('content' in item)) return item diff --git a/apps/studio/data/content/notebooks/notebook-operations.test.ts b/apps/studio/data/content/notebooks/notebook-operations.test.ts index e77b1d6b56b59..77a5ba0a80497 100644 --- a/apps/studio/data/content/notebooks/notebook-operations.test.ts +++ b/apps/studio/data/content/notebooks/notebook-operations.test.ts @@ -10,9 +10,9 @@ import type { NotebookWire } from './notebook-schema' const NOTEBOOK: NotebookWire = { schema_version: 1, cells: [ - { _tag: 'markdown_cell', id: 'cell-1', text: '# Intro' }, - { _tag: 'database_cell', id: 'cell-2', sql: 'select 1', row_limit: 100 }, - { _tag: 'markdown_cell', id: 'cell-3', text: '# Outro' }, + { _tag: 'markdown_cell', _id: 'cell-1', text: '# Intro' }, + { _tag: 'database_cell', _id: 'cell-2', sql: 'select 1', row_limit: 100 }, + { _tag: 'markdown_cell', _id: 'cell-3', text: '# Outro' }, ], } @@ -29,7 +29,7 @@ describe('applyNotebookOperations', () => { expect(result.success).toBe(true) if (!result.success) return expect( - result.notebook.cells.map((cell) => ('id' in cell ? cell.id : 'text' in cell && cell.text)) + result.notebook.cells.map((cell) => ('_id' in cell ? cell._id : 'text' in cell && cell.text)) ).toEqual(['cell-1', '# New', 'cell-2', 'cell-3']) }) @@ -98,7 +98,7 @@ describe('applyNotebookOperations', () => { expect(result.success).toBe(true) if (!result.success) return - expect(result.notebook.cells.map((cell) => ('id' in cell ? cell.id : undefined))).toEqual([ + expect(result.notebook.cells.map((cell) => ('_id' in cell ? cell._id : undefined))).toEqual([ 'cell-2', 'cell-3', 'cell-1', @@ -114,7 +114,7 @@ describe('applyNotebookOperations', () => { expect(result.success).toBe(true) if (!result.success) return - expect(result.notebook.cells.map((cell) => ('id' in cell ? cell.id : undefined))).toEqual([ + expect(result.notebook.cells.map((cell) => ('_id' in cell ? cell._id : undefined))).toEqual([ 'cell-3', 'cell-1', 'cell-2', @@ -133,7 +133,7 @@ describe('applyNotebookOperations', () => { expect(result.success).toBe(true) if (!result.success) return - expect(result.notebook.cells.map((cell) => ('id' in cell ? cell.id : undefined))).toEqual([ + expect(result.notebook.cells.map((cell) => ('_id' in cell ? cell._id : undefined))).toEqual([ 'cell-3', 'cell-1', 'cell-2', @@ -152,7 +152,7 @@ describe('applyNotebookOperations', () => { expect(result.success).toBe(true) if (!result.success) return - expect(result.notebook.cells.map((cell) => ('id' in cell ? cell.id : undefined))).toEqual([ + expect(result.notebook.cells.map((cell) => ('_id' in cell ? cell._id : undefined))).toEqual([ 'cell-2', 'cell-3', 'cell-1', @@ -169,7 +169,7 @@ describe('applyNotebookOperations', () => { expect(result.success).toBe(true) if (!result.success) return - expect(result.notebook.cells.map((cell) => ('id' in cell ? cell.id : undefined))).toEqual([ + expect(result.notebook.cells.map((cell) => ('_id' in cell ? cell._id : undefined))).toEqual([ 'cell-1', 'cell-2', 'cell-3', diff --git a/apps/studio/data/content/notebooks/notebook-operations.ts b/apps/studio/data/content/notebooks/notebook-operations.ts index 2e4391e07ddba..60d2dcb27d250 100644 --- a/apps/studio/data/content/notebooks/notebook-operations.ts +++ b/apps/studio/data/content/notebooks/notebook-operations.ts @@ -110,9 +110,9 @@ function findAnchorIndex(entries: NotebookCellDiffEntry[], cellId: string): numb switch (entry._tag) { case 'unchanged': case 'moved': - return entry.cell.id === cellId + return entry.cell._id === cellId case 'replaced': - return entry.before.id === cellId + return entry.before._id === cellId case 'added': case 'removed': return false @@ -127,7 +127,7 @@ function findTargetCell( for (let index = 0; index < entries.length; index++) { const entry = entries[index] if (entry._tag !== 'unchanged' && entry._tag !== 'moved') continue - if (entry.cell.id === cellId) return { index, cell: entry.cell } + if (entry.cell._id === cellId) return { index, cell: entry.cell } } return undefined } @@ -139,11 +139,11 @@ function downgradeNoOpMoves( if (!entries.some((entry) => entry._tag === 'moved')) return entries const finalOrder = entries.flatMap((entry) => - entry._tag === 'unchanged' || entry._tag === 'moved' ? [entry.cell.id] : [] + entry._tag === 'unchanged' || entry._tag === 'moved' ? [entry.cell._id] : [] ) const survivingIds = new Set(finalOrder) const originalOrder = notebook.cells - .map((cell) => cell.id) + .map((cell) => cell._id) .filter((cellId) => survivingIds.has(cellId)) const hasSamePredecessors = (cellId: string) => { @@ -156,7 +156,7 @@ function downgradeNoOpMoves( } return entries.map((entry) => - entry._tag === 'moved' && hasSamePredecessors(entry.cell.id) + entry._tag === 'moved' && hasSamePredecessors(entry.cell._id) ? { _tag: 'unchanged', cell: entry.cell } : entry ) @@ -177,7 +177,7 @@ export function deriveNotebookDiff( targetedIds.add(cellId) } - const originalIndexById = new Map(notebook.cells.map((cell, index) => [cell.id, index])) + const originalIndexById = new Map(notebook.cells.map((cell, index) => [cell._id, index])) const entries: NotebookCellDiffEntry[] = notebook.cells.map((cell) => ({ _tag: 'unchanged', cell, @@ -259,7 +259,7 @@ export function deriveNotebookDiff( const error = insertAfter(operation.after_cell_id, { _tag: 'moved', cell: found.cell, - fromIndex: originalIndexById.get(found.cell.id) ?? found.index, + fromIndex: originalIndexById.get(found.cell._id) ?? found.index, operationIndex, }) if (error) return { success: false, error } diff --git a/apps/studio/data/content/notebooks/notebook-query.test.ts b/apps/studio/data/content/notebooks/notebook-query.test.ts index cabd96fb6d6ee..46ccd458edaf9 100644 --- a/apps/studio/data/content/notebooks/notebook-query.test.ts +++ b/apps/studio/data/content/notebooks/notebook-query.test.ts @@ -7,9 +7,7 @@ import { addAPIMock } from '@/tests/lib/msw' const NOTEBOOK_ID = 'd3aadd77-7c3c-4de7-aa5c-5aa8ac270b44' -// `type: 'notebook'` isn't in the generated GetUserContentByIdResponse['type'] enum yet (same -// gap tracked by the ContentBase TODO in content-query.ts), so the mock body needs a cast. -const NOTEBOOK_ROW = { +const NOTEBOOK_ROW: components['schemas']['GetUserContentByIdResponse'] = { id: NOTEBOOK_ID, type: 'notebook', name: 'Signup funnel', @@ -33,9 +31,7 @@ describe('getNotebook', () => { method: 'get', path: '/platform/projects/:ref/content/item/:id', response: () => - HttpResponse.json( - NOTEBOOK_ROW as unknown as components['schemas']['GetUserContentByIdResponse'] - ), + HttpResponse.json(NOTEBOOK_ROW), }) const result = await getNotebook({ projectRef: 'default', id: NOTEBOOK_ID }) diff --git a/apps/studio/data/content/notebooks/notebook-query.ts b/apps/studio/data/content/notebooks/notebook-query.ts index 66d62839cca6c..3e64aef8f353e 100644 --- a/apps/studio/data/content/notebooks/notebook-query.ts +++ b/apps/studio/data/content/notebooks/notebook-query.ts @@ -14,14 +14,11 @@ export async function getNotebook( ) { const data = await getContentById({ projectRef, id }, signal, headers) - // api-types doesn't have 'notebook' in GetUserContentByIdResponse['type'] yet — same gap - // tracked by the ContentBase TODO in content-query.ts — so this narrowing can't be static. - if ((data.type as string) !== 'notebook') { + if (data.type !== 'notebook') { throw new Error(`Content ${id} is not a notebook (got type: ${data.type})`) } - return data as unknown as Omit & { - type: 'notebook' + return data as Omit & { content: Notebooks.Content } } diff --git a/apps/studio/data/content/notebooks/notebook-schema.test.ts b/apps/studio/data/content/notebooks/notebook-schema.test.ts index 2f1615ba0e867..a459382cbe0b4 100644 --- a/apps/studio/data/content/notebooks/notebook-schema.test.ts +++ b/apps/studio/data/content/notebooks/notebook-schema.test.ts @@ -1,33 +1,35 @@ -import { untrustedSql } from '@supabase/pg-meta' +import { safeSql, untrustedSql } from '@supabase/pg-meta' import { describe, expect, it } from 'vitest' import { agentNotebookSchema, + isDraftId, isQueryCell, notebookDomainSchema, notebookSchema, timeRangeSchema, + toWireWritableNotebook, writableNotebookSchema, } from './notebook-schema' -import { untrustedLogSql } from '@/data/logs/safe-analytics-sql' +import { safeSql as safeLogSql, untrustedLogSql } from '@/data/logs/safe-analytics-sql' const FULL_NOTEBOOK = { schema_version: 1 as const, cells: [ { _tag: 'markdown_cell' as const, - id: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', + _id: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', text: '# Signup funnel', }, { _tag: 'database_cell' as const, - id: 'b1ffcd88-8d1a-4de7-aa5c-5aa8ac270b22', + _id: 'b1ffcd88-8d1a-4de7-aa5c-5aa8ac270b22', sql: 'select * from auth.users limit 100', row_limit: 100, }, { _tag: 'log_cell' as const, - id: 'c2001199-1e2b-4ef8-bb6d-6bb9bd380a33', + _id: 'c2001199-1e2b-4ef8-bb6d-6bb9bd380a33', sql: "select timestamp, event_message from edge_logs where source = 'edge_logs' limit 10", time_range: { _tag: 'relative_time_range' as const, @@ -46,7 +48,7 @@ describe('notebookSchema', () => { it('rejects an unknown cell _tag', () => { const result = notebookSchema.safeParse({ schema_version: 1, - cells: [{ _tag: 'chart_cell', id: '1', text: 'hi' }], + cells: [{ _tag: 'chart_cell', _id: '1', text: 'hi' }], }) expect(result.success).toBe(false) @@ -55,7 +57,7 @@ describe('notebookSchema', () => { it('rejects a database_cell missing row_limit', () => { const result = notebookSchema.safeParse({ schema_version: 1, - cells: [{ _tag: 'database_cell', id: '1', sql: 'select 1' }], + cells: [{ _tag: 'database_cell', _id: '1', sql: 'select 1' }], }) expect(result.success).toBe(false) @@ -67,7 +69,7 @@ describe('notebookSchema', () => { cells: [ { _tag: 'log_cell', - id: '1', + _id: '1', sql: 'select 1', time_range: { _tag: 'absolute_time_range', @@ -87,7 +89,7 @@ describe('notebookSchema', () => { cells: [ { _tag: 'log_cell', - id: '1', + _id: '1', sql: 'select 1', time_range: { _tag: 'absolute_time_range', @@ -107,7 +109,7 @@ describe('notebookSchema', () => { cells: [ { _tag: 'log_cell', - id: '1', + _id: '1', sql: 'select 1', time_range: { _tag: 'relative_time_range', unit: 'fortnight', amount: 1 }, }, @@ -123,7 +125,7 @@ describe('notebookSchema', () => { cells: [ { _tag: 'database_cell', - id: '1', + _id: '1', sql: 'select 1', row_limit: 100, database_identifier: 'replica-1', @@ -134,20 +136,29 @@ describe('notebookSchema', () => { expect(result.success).toBe(true) }) + it('rejects a cell with no _id — the backend always assigns one on save', () => { + const result = notebookSchema.safeParse({ + schema_version: 1, + cells: [{ _tag: 'markdown_cell', text: 'hi' }], + }) + + expect(result.success).toBe(false) + }) + it('keeps a chart configured while the table view is selected', () => { const result = notebookSchema.safeParse({ schema_version: 1, cells: [ { _tag: 'database_cell', - id: '1', + _id: '1', sql: 'select 1', row_limit: 100, view: 'table', chart: { type: 'bar', x_column: 'day', - y_columns: ['signups'], + y_series: ['signups'], cumulative: false, show_labels: true, }, @@ -164,7 +175,7 @@ describe('notebookSchema', () => { describe('timeRangeSchema', () => { const logCell = (time_range: unknown) => ({ schema_version: 1, - cells: [{ _tag: 'log_cell', id: '1', sql: 'select 1', time_range }], + cells: [{ _tag: 'log_cell', _id: '1', sql: 'select 1', time_range }], }) it('rejects a relative_time_range with a non-positive or fractional amount', () => { @@ -275,7 +286,7 @@ describe('writableNotebookSchema', () => { cells: [ { _tag: 'database_cell', - id: 'b1ffcd88-8d1a-4de7-aa5c-5aa8ac270b22', + _id: 'b1ffcd88-8d1a-4de7-aa5c-5aa8ac270b22', sql: 'select * from auth.users limit 100', row_limit: 100, }, @@ -346,10 +357,14 @@ describe('notebookDomainSchema', () => { if (!result.success) return const [markdownCell, databaseCell, logCell] = result.data.cells - expect(markdownCell).toEqual(FULL_NOTEBOOK.cells[0]) + expect(markdownCell).toEqual({ + _tag: 'markdown_cell', + _id: FULL_NOTEBOOK.cells[0]._id, + text: FULL_NOTEBOOK.cells[0].text, + }) expect(databaseCell).toEqual({ _tag: 'database_cell', - id: FULL_NOTEBOOK.cells[1].id, + _id: FULL_NOTEBOOK.cells[1]._id, row_limit: 100, view: 'table', unchecked_sql: untrustedSql('select * from auth.users limit 100'), @@ -364,3 +379,65 @@ describe('notebookDomainSchema', () => { expect(logCell).not.toHaveProperty('sql') }) }) + +describe('toWireWritableNotebook', () => { + it('sends a real id through as _id', () => { + const wire = toWireWritableNotebook({ + schema_version: 1, + cells: [{ _tag: 'markdown_cell', _id: 'b1ffcd88-8d1a-4de7-aa5c-5aa8ac270b22', text: 'hi' }], + }) + + expect(wire.cells[0]).toMatchObject({ _id: 'b1ffcd88-8d1a-4de7-aa5c-5aa8ac270b22' }) + }) + + it('drops a draft id rather than sending it back as _id', () => { + expect(isDraftId('draft-b1ffcd88-8d1a-4de7-aa5c-5aa8ac270b22')).toBe(true) + + const wire = toWireWritableNotebook({ + schema_version: 1, + cells: [ + { _tag: 'markdown_cell', _id: 'draft-b1ffcd88-8d1a-4de7-aa5c-5aa8ac270b22', text: 'hi' }, + ], + }) + + expect(wire.cells[0]).not.toHaveProperty('_id') + }) + + it('leaves a cell with no id at all without an _id', () => { + const wire = toWireWritableNotebook({ + schema_version: 1, + cells: [{ _tag: 'markdown_cell', text: 'hi' }], + }) + + expect(wire.cells[0]).not.toHaveProperty('_id') + }) + + it('carries a query cell (database_cell, log_cell) sql through as plain text, never unchecked_sql', () => { + const wire = toWireWritableNotebook({ + schema_version: 1, + cells: [ + { + _tag: 'database_cell', + _id: 'b1ffcd88-8d1a-4de7-aa5c-5aa8ac270b22', + sql: safeSql`select 1`, + row_limit: 100, + }, + { + _tag: 'log_cell', + sql: safeLogSql`select 1`, + time_range: { _tag: 'relative_time_range', unit: 'hour', amount: 1 }, + }, + ], + }) + + const [databaseCell, logCell] = wire.cells + expect(databaseCell).toMatchObject({ + _id: 'b1ffcd88-8d1a-4de7-aa5c-5aa8ac270b22', + sql: 'select 1', + }) + expect(databaseCell).not.toHaveProperty('unchecked_sql') + expect(logCell).toMatchObject({ sql: 'select 1' }) + expect(logCell).not.toHaveProperty('_id') + expect(logCell).not.toHaveProperty('unchecked_sql') + }) +}) diff --git a/apps/studio/data/content/notebooks/notebook-schema.ts b/apps/studio/data/content/notebooks/notebook-schema.ts index 65c2539cfc5a0..7d4b058ea76a5 100644 --- a/apps/studio/data/content/notebooks/notebook-schema.ts +++ b/apps/studio/data/content/notebooks/notebook-schema.ts @@ -14,12 +14,12 @@ const isoDateTimeSchema = z.string().transform((raw, ctx) => { return parsed }) -export const MAX_CHART_Y_COLUMNS = 3 +export const MAX_CHART_Y_SERIES = 3 const chartConfigSchema = z.object({ type: z.enum(['bar', 'line']), x_column: z.string(), - y_columns: z.array(z.string()).max(MAX_CHART_Y_COLUMNS), + y_series: z.array(z.string()).max(MAX_CHART_Y_SERIES), cumulative: z.boolean(), scale: z.enum(['linear', 'log']).default('linear'), show_labels: z.boolean(), @@ -54,17 +54,10 @@ export const timeRangeSchema = z { message: 'must be later than the start of the range', path: ['end'] } ) -// Source parameters — the per-backend values a query needs beyond its SQL. Declared -// here, in the wire contract, because this schema is the shape shared with the API and -// the agent tool surface, so it is where the validation has to be authoritative. The -// runtime registry (data/query-sources/query-source-registry.ts) borrows these rather -// than redeclaring them. Each is spread flat into its cell — no `source` wrapper — to -// keep the JSON an agent has to author as shallow as possible. export const databaseSourceSchema = z.object({ /** * Which database the query runs against: the read-replica `identifier`, or absent - * for the project's primary. Named `database_identifier` rather than `identifier` - * because every cell already carries an `id`. + * for the project's primary. */ database_identifier: z.string().optional(), }) @@ -73,14 +66,11 @@ export const logsSourceSchema = z.object({ time_range: timeRangeSchema, }) -const cellBaseSchema = z.object({ - id: z.string(), +const markdownFieldsSchema = z.object({ + text: z.string(), }) -// Fields every runnable cell shares, regardless of backend. `sql` is deliberately NOT -// here: it stays on each member so the domain transform can brand it per dialect and -// generic code holding a `QueryCell` can't hand it to the wrong wire boundary. -const queryCellBaseSchema = cellBaseSchema.extend({ +const queryFieldsBaseSchema = z.object({ title: z.string().optional(), view: z.enum(['table', 'chart']).optional(), // Persisted independently of `view` so a user who switches to the table and back @@ -88,32 +78,40 @@ const queryCellBaseSchema = cellBaseSchema.extend({ chart: chartConfigSchema.optional(), }) -const markdownCellSchema = cellBaseSchema.extend({ - _tag: z.literal('markdown_cell'), - text: z.string(), -}) - -const databaseCellSchema = queryCellBaseSchema.extend({ - _tag: z.literal('database_cell'), +const databaseFieldsSchema = queryFieldsBaseSchema.extend({ sql: z.string(), row_limit: z.number(), ...databaseSourceSchema.shape, }) -const logCellSchema = queryCellBaseSchema.extend({ - _tag: z.literal('log_cell'), +const logFieldsSchema = queryFieldsBaseSchema.extend({ sql: z.string(), ...logsSourceSchema.shape, }) +// The wire shape: a cell that's been saved always carries the backend's real `_id` — the +// backend assigns one on every write and returns it on every subsequent read. +const markdownCellSchema = markdownFieldsSchema.extend({ + _tag: z.literal('markdown_cell'), + _id: z.string(), +}) + +const databaseCellSchema = databaseFieldsSchema.extend({ + _tag: z.literal('database_cell'), + _id: z.string(), +}) + +const logCellSchema = logFieldsSchema.extend({ + _tag: z.literal('log_cell'), + _id: z.string(), +}) + const cellSchema = z.discriminatedUnion('_tag', [ markdownCellSchema, databaseCellSchema, logCellSchema, ]) -// The wire shape: every notebook fetched from the API has this shape, with backend- -// generated cell `id`s and plaintext `sql`. export const notebookSchema = z.object({ schema_version: z.literal(1), cells: z.array(cellSchema), @@ -123,16 +121,14 @@ export type NotebookWire = z.infer export type CellWire = z.infer // Cells for the create/update PUT body (data/content/notebooks/notebook-upsert-mutation.ts). -// An existing cell being kept or edited carries its real backend-assigned `id` so the -// backend can diff it against the previous version; a newly inserted cell has no `id` at -// all — the backend generates one on write. `sql` must already be a SafeSqlFragment / -// SafeLogSqlFragment — i.e. promoted at the point of user action per the -// safe-sql-execution skill — never a raw string or an UntrustedSqlFragment/unchecked_sql, -// since neither proves this specific save was user-authored. +// An existing cell being kept or edited carries its real backend-assigned `_id` so the +// backend can diff it against the previous version; a newly inserted cell — or one whose +// `_id` is a client-fabricated draft id (see DRAFT_ID_PREFIX below) — has no `_id` at all +// once it reaches the wire, so the backend generates one on write. const writableCellSchema = z.discriminatedUnion('_tag', [ - markdownCellSchema.extend({ id: z.string().optional() }), - databaseCellSchema.extend({ id: z.string().optional() }), - logCellSchema.extend({ id: z.string().optional() }), + markdownFieldsSchema.extend({ _tag: z.literal('markdown_cell'), _id: z.string().optional() }), + databaseFieldsSchema.extend({ _tag: z.literal('database_cell'), _id: z.string().optional() }), + logFieldsSchema.extend({ _tag: z.literal('log_cell'), _id: z.string().optional() }), ]) export const writableNotebookSchema = z.object({ @@ -152,12 +148,18 @@ export type WritableNotebook = Omit, 'cel cells: Array } +// Same shape as WritableCell/WritableNotebook but with `sql` left as a plain, unbranded +// string — the actual write-body wire shape (`_id` optional, unlike the required `_id` on +// CellWire/NotebookWire above, since a cell being created for the first time has none yet). +type WritableCellWire = z.infer +type WritableNotebookWire = z.infer + // Agents have restrictions on writing IDs to preserve guarantees about ID -// uniqueness. +// uniqueness export const agentCellSchema = z.discriminatedUnion('_tag', [ - markdownCellSchema.omit({ id: true }).strict(), - databaseCellSchema.omit({ id: true }).strict(), - logCellSchema.omit({ id: true }).strict(), + markdownFieldsSchema.extend({ _tag: z.literal('markdown_cell') }).strict(), + databaseFieldsSchema.extend({ _tag: z.literal('database_cell') }).strict(), + logFieldsSchema.extend({ _tag: z.literal('log_cell') }).strict(), ]) export const agentNotebookSchema = z.object({ @@ -168,8 +170,28 @@ export const agentNotebookSchema = z.object({ export type AgentNotebook = z.infer export type AgentCell = z.infer -// The domain shape: parses the same wire cell (`cellSchema`), transforms `sql` into a -// branded `unchecked_sql`, and defaults `view` to 'table' +// A backend-recognized `_id` is required once a cell is saved (see cellSchema above), but a +// cell can exist client-side before that first save — created fresh in the editor (see +// components/interfaces/Explorer/utils.ts) — and needs *some* stable identity in the +// meantime for React keys and state lookups. A draft id fills that gap. It's never sent to +// the backend as a real `_id` (see wireId below): a cell with a draft id reaches the wire +// exactly like a brand-new one, so the backend assigns its own. +const DRAFT_ID_PREFIX = 'draft-' + +export function generateDraftId(): string { + return `${DRAFT_ID_PREFIX}${crypto.randomUUID()}` +} + +export function isDraftId(id: string): boolean { + return id.startsWith(DRAFT_ID_PREFIX) +} + +function wireId(id: string | undefined): { _id: string } | Record { + return id !== undefined && !isDraftId(id) ? { _id: id } : {} +} + +// The domain shape: parses the wire cell (`cellSchema`), transforms `sql` into a branded +// `unchecked_sql`, and defaults `view` to 'table'. const cellDomainSchema = cellSchema.transform((cell) => { switch (cell._tag) { case 'markdown_cell': @@ -193,9 +215,6 @@ export const notebookDomainSchema = z.object({ export type NotebookContent = z.infer export type Cell = z.infer -// Reverse of cellDomainSchema's transform, for display-only conversions (e.g. deriving a -// diff preview client-side). Never promoted or executed — writes still go through -// acceptUntrustedSql/acceptUntrustedLogsSql at the point of user action. export function toWireCell(cell: Cell): CellWire { switch (cell._tag) { case 'markdown_cell': @@ -215,6 +234,27 @@ export function toWireNotebook(content: NotebookContent): NotebookWire { return { schema_version: content.schema_version, cells: content.cells.map(toWireCell) } } +export function toWireWritableCell(cell: WritableCell): WritableCellWire { + switch (cell._tag) { + case 'markdown_cell': { + const { _id, ...rest } = cell + return { ...rest, ...wireId(_id) } + } + case 'database_cell': { + const { _id, ...rest } = cell + return { ...rest, ...wireId(_id) } + } + case 'log_cell': { + const { _id, ...rest } = cell + return { ...rest, ...wireId(_id) } + } + } +} + +export function toWireWritableNotebook(content: WritableNotebook): WritableNotebookWire { + return { schema_version: content.schema_version, cells: content.cells.map(toWireWritableCell) } +} + export type MarkdownCell = Extract export type DatabaseCell = Extract export type LogCell = Extract diff --git a/apps/studio/data/content/notebooks/notebook-upsert-mutation.test.ts b/apps/studio/data/content/notebooks/notebook-upsert-mutation.test.ts index c32ff0df3158d..55f5a0eb1fdd0 100644 --- a/apps/studio/data/content/notebooks/notebook-upsert-mutation.test.ts +++ b/apps/studio/data/content/notebooks/notebook-upsert-mutation.test.ts @@ -12,7 +12,7 @@ const LOG_SQL = "select timestamp, event_message from edge_logs where source = ' const EXISTING_CELL_ID = 'b1ffcd88-8d1a-4de7-aa5c-5aa8ac270b22' -// Create-shaped: no cell carries an `id` — the notebook is brand new, so no cell has a +// Create-shaped: no cell carries an `_id` — the notebook is brand new, so no cell has a // prior version for the backend to diff against; ids are backend-generated on write. const VALID_CONTENT: WritableNotebook = { schema_version: 1, @@ -27,7 +27,7 @@ const VALID_CONTENT: WritableNotebook = { ], } -// Update-shaped: mixes an existing cell (kept, carries its real backend-assigned id so the +// Update-shaped: mixes an existing cell (kept, carries its real backend-assigned _id so the // backend can diff it against the previous version) with a newly inserted cell (no id — // same as create, the backend assigns one on write). const UPDATE_CONTENT: WritableNotebook = { @@ -35,7 +35,7 @@ const UPDATE_CONTENT: WritableNotebook = { cells: [ { _tag: 'database_cell', - id: EXISTING_CELL_ID, + _id: EXISTING_CELL_ID, sql: DATABASE_SQL, row_limit: 100, }, @@ -85,6 +85,7 @@ describe('createNotebook', () => { const content = sentBody?.content as WritableNotebook for (const cell of content.cells) { expect(cell).not.toHaveProperty('id') + expect(cell).not.toHaveProperty('_id') } const [, databaseCell, logCell] = content.cells as Array> @@ -125,9 +126,11 @@ describe('updateNotebook', () => { const content = sentBody?.content as WritableNotebook const [databaseCell, logCell] = content.cells as Array> - expect(databaseCell.id).toBe(EXISTING_CELL_ID) + expect(databaseCell._id).toBe(EXISTING_CELL_ID) + expect(databaseCell).not.toHaveProperty('id') expect(databaseCell.sql).toBe(DATABASE_SQL) expect(logCell).not.toHaveProperty('id') + expect(logCell).not.toHaveProperty('_id') expect(logCell.sql).toBe(LOG_SQL) }) diff --git a/apps/studio/data/content/notebooks/notebook-upsert-mutation.ts b/apps/studio/data/content/notebooks/notebook-upsert-mutation.ts index 39155ee8d49d7..e234a2f505f01 100644 --- a/apps/studio/data/content/notebooks/notebook-upsert-mutation.ts +++ b/apps/studio/data/content/notebooks/notebook-upsert-mutation.ts @@ -6,15 +6,6 @@ import { contentKeys } from '../keys' import { writableNotebookSchema, type WritableNotebook } from './notebook-schema' import type { ResponseError, UseCustomMutationOptions } from '@/types' -// TODO — Charis 2026-08-06 -// UpsertContentPayload['type'] (generated from api-types) doesn't have 'notebook' yet — same -// gap tracked by the ContentBase TODO in content-query.ts. Widen locally and cast at the -// upsertContent call site until the generated type picks it up. -type NotebookUpsertPayload = Omit & { - type: 'notebook' - content: WritableNotebook -} - function buildNotebookUpsertPayload({ id, name, @@ -25,7 +16,7 @@ function buildNotebookUpsertPayload({ name: string description?: string content: WritableNotebook -}): NotebookUpsertPayload { +}): UpsertContentPayload { writableNotebookSchema.parse(content) return { @@ -53,11 +44,7 @@ export async function createNotebook( const id = crypto.randomUUID() const payload = buildNotebookUpsertPayload({ id, name, description, content }) - await upsertContent( - { projectRef, payload: payload as unknown as UpsertContentPayload }, - signal, - headersInit - ) + await upsertContent({ projectRef, payload }, signal, headersInit) return { id } } @@ -73,11 +60,7 @@ export async function updateNotebook( ) { const payload = buildNotebookUpsertPayload({ id, name, description, content }) - return upsertContent( - { projectRef, payload: payload as unknown as UpsertContentPayload }, - signal, - headersInit - ) + return upsertContent({ projectRef, payload }, signal, headersInit) } export type UpdateNotebookData = Awaited> diff --git a/apps/studio/data/content/notebooks/notebooks-infinite-query.test.ts b/apps/studio/data/content/notebooks/notebooks-infinite-query.test.ts index e9a54dc3435de..e256ce7e309c9 100644 --- a/apps/studio/data/content/notebooks/notebooks-infinite-query.test.ts +++ b/apps/studio/data/content/notebooks/notebooks-infinite-query.test.ts @@ -7,7 +7,7 @@ import type { components } from '@/data/api' import { customRenderHook } from '@/tests/lib/custom-render' import { addAPIMock } from '@/tests/lib/msw' -const NOTEBOOK_ROW = { +const NOTEBOOK_ROW: components['schemas']['GetUserContentResponse']['data'][number] = { id: 'd3aadd77-7c3c-4de7-aa5c-5aa8ac270b44', type: 'notebook', name: 'Signup funnel', @@ -36,7 +36,7 @@ describe('useNotebooksInfiniteQuery', () => { HttpResponse.json({ cursor: undefined, data: [NOTEBOOK_ROW], - } as unknown as components['schemas']['GetUserContentResponse']), + }), }) const { result } = customRenderHook(() => useNotebooksInfiniteQuery({ projectRef: 'default' })) diff --git a/apps/studio/data/content/sql-folders-query.ts b/apps/studio/data/content/sql-folders-query.ts index 85d2ec4b5b11a..3027722faec49 100644 --- a/apps/studio/data/content/sql-folders-query.ts +++ b/apps/studio/data/content/sql-folders-query.ts @@ -24,17 +24,11 @@ export type Snippet = // brand to narrow on `type` first. The shared field names (`content_id`, // `unchecked_sql`, `schema_version`) keep the many read sites that only need the // SQL text compiling unchanged. -// -// `report` is included because the content endpoints' wire type carries it, but -// it deliberately has NO SQL content (`content?: never`): report bodies are -// `Dashboards.Content` and are loaded through the separate `Content` union -// (data/content/content-query.ts), never with SQL here. Modelling it as `never` -// (rather than reusing `SqlSnippets.Content`) keeps that honest while letting the -// broadly-typed rows the content/folder queries return assign without a cast. export type SnippetWithContent = Omit & { status: SnippetStatus } & ( | { type: 'sql'; content?: SqlSnippets.Content } | { type: 'log_sql'; content?: LogSqlSnippets.Content } | { type: 'report'; content?: never } + | { type: 'notebook'; content?: never } ) // Attaches the 'saved' lifecycle status to a snippet as it crosses from the diff --git a/apps/studio/lib/ai/tools/mock-tools.test.ts b/apps/studio/lib/ai/tools/mock-tools.test.ts index 1cd7f5ee1015b..803cf8233f783 100644 --- a/apps/studio/lib/ai/tools/mock-tools.test.ts +++ b/apps/studio/lib/ai/tools/mock-tools.test.ts @@ -149,7 +149,7 @@ describe('ai/tools/mock-tools getMockTools', () => { { toolCallId: 'test', messages: [], context: {} } ) expect(fetched.cells).toHaveLength(2) - expect(fetched.cells.every((cell) => typeof cell.id === 'string')).toBe(true) + expect(fetched.cells.every((cell) => typeof cell._id === 'string')).toBe(true) const listed = await mockTools.list_notebooks.execute( { limit: 20 }, @@ -186,10 +186,10 @@ describe('ai/tools/mock-tools getMockTools', () => { operations: [ { _tag: 'insert_cell', - after_cell_id: markdownCell.id, + after_cell_id: markdownCell._id, cell: { _tag: 'database_cell', sql: 'select 1', row_limit: 10 }, }, - { _tag: 'delete_cell', cell_id: logCell.id }, + { _tag: 'delete_cell', cell_id: logCell._id }, ], }, { toolCallId: 'test', messages: [], context: {} } diff --git a/apps/studio/lib/ai/tools/mock-tools.ts b/apps/studio/lib/ai/tools/mock-tools.ts index 69b959e033751..d288e17744f0b 100644 --- a/apps/studio/lib/ai/tools/mock-tools.ts +++ b/apps/studio/lib/ai/tools/mock-tools.ts @@ -178,18 +178,18 @@ export const MOCK_NOTEBOOKS_DATA: MockNotebook[] = [ cells: [ { _tag: 'markdown_cell', - id: 'c1a0b8e2-3f47-4a52-9d18-6b0c4e2f7a91', + _id: 'c1a0b8e2-3f47-4a52-9d18-6b0c4e2f7a91', text: '# Auth health\n\nRun this daily: signup volume, then anything the auth service logged as an error.', }, { _tag: 'database_cell', - id: 'd2b1c9f3-4a58-4b63-8e29-7c1d5f3a8b02', + _id: 'd2b1c9f3-4a58-4b63-8e29-7c1d5f3a8b02', title: 'Signups per day', sql: "select date_trunc('day', created_at) as day, count(*) as signups\nfrom auth.users\ngroup by day\norder by day desc", row_limit: 30, chart: { x_column: 'day', - y_columns: ['signups'], + y_series: ['signups'], cumulative: false, type: 'line', scale: 'log', @@ -198,7 +198,7 @@ export const MOCK_NOTEBOOKS_DATA: MockNotebook[] = [ }, { _tag: 'log_cell', - id: 'e3c2d0a4-5b69-4c74-9f3a-8d2e6a4b9c13', + _id: 'e3c2d0a4-5b69-4c74-9f3a-8d2e6a4b9c13', title: 'Auth errors', sql: "select timestamp, event_message\nfrom auth_logs\nwhere event_message like '%error%'\norder by timestamp desc", time_range: { _tag: 'relative_time_range', unit: 'hour', amount: 1 }, @@ -216,12 +216,12 @@ export const MOCK_NOTEBOOKS_DATA: MockNotebook[] = [ cells: [ { _tag: 'markdown_cell', - id: 'f4d3e1b5-7c80-4d85-8a4b-9e3f7b5c0d24', + _id: 'f4d3e1b5-7c80-4d85-8a4b-9e3f7b5c0d24', text: '# Edge function errors\n\nFailures from the last day, newest first.', }, { _tag: 'log_cell', - id: '0a5e4f2c-8d91-4e96-9b5c-af408c6d1e35', + _id: '0a5e4f2c-8d91-4e96-9b5c-af408c6d1e35', title: 'hello-world failures', sql: "select timestamp, event_message\nfrom function_edge_logs\nwhere event_message like '%TypeError%'\norder by timestamp desc", time_range: { _tag: 'relative_time_range', unit: 'day', amount: 1 }, @@ -363,15 +363,15 @@ function createMockNotebookStore() { const assignCellIds = (cells: OperationResultCell[]): CellWire[] => cells.map((cell): CellWire => { - if ('id' in cell) return cell - const id = `mock-cell-${++cellCount}` + if ('_id' in cell) return cell + const _id = `mock-cell-${++cellCount}` switch (cell._tag) { case 'markdown_cell': - return { ...cell, id } + return { ...cell, _id } case 'database_cell': - return { ...cell, id } + return { ...cell, _id } case 'log_cell': - return { ...cell, id } + return { ...cell, _id } } }) diff --git a/apps/studio/lib/ai/tools/notebook-tools.test.ts b/apps/studio/lib/ai/tools/notebook-tools.test.ts index 81f080b4c89a7..2650d326ca044 100644 --- a/apps/studio/lib/ai/tools/notebook-tools.test.ts +++ b/apps/studio/lib/ai/tools/notebook-tools.test.ts @@ -29,16 +29,16 @@ type GetUserContentResponse = components['schemas']['GetUserContentResponse'] const NOTEBOOK_CONTENT = { schema_version: 1, cells: [ - { _tag: 'markdown_cell', id: 'cell-1', text: '# Signup funnel' }, + { _tag: 'markdown_cell', _id: 'cell-1', text: '# Signup funnel' }, { _tag: 'database_cell', - id: 'cell-2', + _id: 'cell-2', sql: 'select * from auth.users limit 100', row_limit: 100, }, { _tag: 'log_cell', - id: 'cell-3', + _id: 'cell-3', sql: 'select timestamp, event_message from edge_logs limit 10', time_range: { _tag: 'relative_time_range', unit: 'hour', amount: 1 }, }, @@ -204,17 +204,17 @@ describe('ai/tools/notebook-tools', () => { visibility: 'project', updated_at: '2026-01-01T00:00:00.000Z', cells: [ - { _tag: 'markdown_cell', id: 'cell-1', text: '# Signup funnel' }, + { _tag: 'markdown_cell', _id: 'cell-1', text: '# Signup funnel' }, { _tag: 'database_cell', - id: 'cell-2', + _id: 'cell-2', row_limit: 100, view: 'table', sql: 'select * from auth.users limit 100', }, { _tag: 'log_cell', - id: 'cell-3', + _id: 'cell-3', time_range: { _tag: 'relative_time_range', unit: 'hour', amount: 1 }, view: 'table', sql: 'select timestamp, event_message from edge_logs limit 10', @@ -423,7 +423,7 @@ describe('ai/tools/notebook-tools', () => { expect(sentBody?.type).toBe('notebook') const content = sentBody?.content as { cells: Array> } - expect(content.cells.map((cell) => cell.id ?? cell.text)).toEqual([ + expect(content.cells.map((cell) => cell._id ?? cell.text)).toEqual([ 'cell-1', '# New section', 'cell-2', diff --git a/apps/studio/lib/ai/tools/notebook-tools.ts b/apps/studio/lib/ai/tools/notebook-tools.ts index a0d62f1adc800..3383816b4f7f9 100644 --- a/apps/studio/lib/ai/tools/notebook-tools.ts +++ b/apps/studio/lib/ai/tools/notebook-tools.ts @@ -11,8 +11,7 @@ import { import { getNotebook } from '@/data/content/notebooks/notebook-query' import { agentNotebookSchema, - type CellWire, - type NotebookWire, + toWireNotebook, type WritableCell, type WritableNotebook, } from '@/data/content/notebooks/notebook-schema' @@ -80,28 +79,15 @@ export const getNotebookTools = (ctx: NotebookToolsContext = {}) => { execute: async ({ id }) => { const notebook = await getNotebook({ projectRef, id }, undefined, authHeaders) + // toWireNotebook discards the `unchecked_sql` brand for display purposes only — the + // result is returned to the agent, never written back. return { id: notebook.id, name: notebook.name, description: notebook.description, visibility: notebook.visibility, updated_at: notebook.updated_at, - // Inlined rather than a shared helper: this discards the `unchecked_sql` brand for - // display purposes only — the result is returned to the agent, never written back. - cells: notebook.content.cells.map((cell) => { - switch (cell._tag) { - case 'markdown_cell': - return cell - case 'database_cell': { - const { unchecked_sql, ...rest } = cell - return { ...rest, sql: unchecked_sql } - } - case 'log_cell': { - const { unchecked_sql, ...rest } = cell - return { ...rest, sql: unchecked_sql } - } - } - }), + cells: toWireNotebook(notebook.content).cells, } }, }), @@ -172,29 +158,10 @@ export const getNotebookTools = (ctx: NotebookToolsContext = {}) => { ) } - // Inlined rather than a shared helper, right beside this tool's own - // `needsApproval: true`: this discards each cell's `unchecked_sql` brand so - // applyNotebookOperations can splice cells as plain data. The result is never - // written or executed as-is — every cell is re-promoted via - // acceptUntrustedSql/acceptUntrustedLogsSql further down in this same execute, - // right before the PUT. - const wireNotebook: NotebookWire = { - schema_version: notebook.content.schema_version, - cells: notebook.content.cells.map((cell): CellWire => { - switch (cell._tag) { - case 'markdown_cell': - return cell - case 'database_cell': { - const { unchecked_sql, ...rest } = cell - return { ...rest, sql: unchecked_sql } - } - case 'log_cell': { - const { unchecked_sql, ...rest } = cell - return { ...rest, sql: unchecked_sql } - } - } - }), - } + // The result of applyNotebookOperations is never written or executed as-is — every + // cell is re-promoted via acceptUntrustedSql/acceptUntrustedLogsSql further down in + // this same execute, right before the PUT. + const wireNotebook = toWireNotebook(notebook.content) const result = applyNotebookOperations(wireNotebook, operations) if (!result.success) { diff --git a/apps/studio/state/notebooks/notebooks-state.test.ts b/apps/studio/state/notebooks/notebooks-state.test.ts index c7316c8aa7d73..7619736810f47 100644 --- a/apps/studio/state/notebooks/notebooks-state.test.ts +++ b/apps/studio/state/notebooks/notebooks-state.test.ts @@ -44,7 +44,7 @@ describe('notebooksState', () => { notebooksState.updateCells({ id: 'notebook-1', - cells: [{ _tag: 'markdown_cell', id: 'cell-1', text: 'hello' }], + cells: [{ _tag: 'markdown_cell', _id: 'cell-1', text: 'hello' }], }) expect(notebooksState.notebooks['notebook-1'].status).toBe('unsaved') @@ -56,7 +56,7 @@ describe('notebooksState', () => { notebooksState.updateCells({ id: 'notebook-1', - cells: [{ _tag: 'markdown_cell', id: 'cell-1', text: 'hello' }], + cells: [{ _tag: 'markdown_cell', _id: 'cell-1', text: 'hello' }], }) expect(notebooksState.notebooks['notebook-1'].status).toBe('new') @@ -66,7 +66,7 @@ describe('notebooksState', () => { notebooksState.setNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') }) notebooksState.updateCells({ id: 'notebook-1', - cells: [{ _tag: 'markdown_cell', id: 'cell-1', text: 'hello' }], + cells: [{ _tag: 'markdown_cell', _id: 'cell-1', text: 'hello' }], }) expect(notebooksState.notebooks['notebook-1'].status).toBe('unsaved') diff --git a/apps/studio/state/notebooks/notebooks-state.ts b/apps/studio/state/notebooks/notebooks-state.ts index 14449cf92796a..486129e460730 100644 --- a/apps/studio/state/notebooks/notebooks-state.ts +++ b/apps/studio/state/notebooks/notebooks-state.ts @@ -114,7 +114,7 @@ export const notebooksState = proxy({ if (!stateNotebook?.notebook.content) return const cells = stateNotebook.notebook.content.cells - const insertAt = cellId ? cells.findIndex((c) => c.id === cellId) : -1 + const insertAt = cellId ? cells.findIndex((c) => c._id === cellId) : -1 const nextCells = [...cells] nextCells.splice(insertAt === -1 ? cells.length : insertAt + 1, 0, cell) @@ -140,7 +140,7 @@ export const notebooksState = proxy({ if (!stateNotebook?.notebook.content) return const nextCells = stateNotebook.notebook.content.cells.map((cell) => - cell.id === cellId ? updater(cell) : cell + cell._id === cellId ? updater(cell) : cell ) notebooksState.updateCells({ id, cells: nextCells }) }, @@ -152,7 +152,7 @@ export const notebooksState = proxy({ const stateNotebook = notebooksState.notebooks[id] if (!stateNotebook?.notebook.content) return - const nextCells = stateNotebook.notebook.content.cells.filter((c) => c.id !== cellId) + const nextCells = stateNotebook.notebook.content.cells.filter((c) => c._id !== cellId) notebooksState.updateCells({ id, cells: nextCells }) }, @@ -173,7 +173,7 @@ export const notebooksState = proxy({ if (!stateNotebook?.notebook.content) return const cells = stateNotebook.notebook.content.cells - const currentIndex = cells.findIndex((c) => c.id === cellId) + const currentIndex = cells.findIndex((c) => c._id === cellId) if (currentIndex === -1) return const nextIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1 @@ -199,8 +199,8 @@ export const notebooksState = proxy({ if (!stateNotebook?.notebook.content) return const cells = stateNotebook.notebook.content.cells - const oldIndex = cells.findIndex((c) => c.id === activeCellId) - const newIndex = cells.findIndex((c) => c.id === overCellId) + const oldIndex = cells.findIndex((c) => c._id === activeCellId) + const newIndex = cells.findIndex((c) => c._id === overCellId) if (oldIndex === -1 || newIndex === -1) return notebooksState.updateCells({ id, cells: arrayMove([...cells], oldIndex, newIndex) }) diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts index 5b5b673b49555..2cc15ae84dc8c 100644 --- a/packages/api-types/types/platform.d.ts +++ b/packages/api-types/types/platform.d.ts @@ -8088,7 +8088,7 @@ export interface components { owner_id: number project_id: number /** @enum {string} */ - type: 'sql' | 'report' | 'log_sql' + type: 'sql' | 'report' | 'log_sql' | 'notebook' updated_at: string /** @enum {string} */ visibility: 'user' | 'project' | 'org' | 'public' @@ -8107,7 +8107,7 @@ export interface components { owner_id: number project_id: number /** @enum {string} */ - type: 'sql' | 'report' | 'log_sql' + type: 'sql' | 'report' | 'log_sql' | 'notebook' updated_at: string /** @enum {string} */ visibility: 'user' | 'project' | 'org' | 'public' @@ -8141,7 +8141,7 @@ export interface components { owner_id: number project_id: number /** @enum {string} */ - type: 'sql' | 'report' | 'log_sql' + type: 'sql' | 'report' | 'log_sql' | 'notebook' updated_at: string updated_by: { id: number @@ -13663,22 +13663,88 @@ export interface components { enabled: boolean } UpsertContentBody: { - content?: { - [key: string]: unknown - } + content?: + | { + [key: string]: unknown + } + | { + cells: ( + | { + _id?: string + /** @enum {string} */ + _tag: 'database_cell' + chart?: { + cumulative: boolean + /** @enum {string} */ + scale: 'linear' | 'log' + show_labels: boolean + /** @enum {string} */ + type: 'bar' | 'line' + x_column: string + y_series: string[] + } + row_limit: number + sql: string + title?: string + /** @enum {string} */ + view?: 'table' | 'chart' + } + | { + _id?: string + /** @enum {string} */ + _tag: 'log_cell' + chart?: { + cumulative: boolean + /** @enum {string} */ + scale: 'linear' | 'log' + show_labels: boolean + /** @enum {string} */ + type: 'bar' | 'line' + x_column: string + y_series: string[] + } + sql: string + time_range: + | { + /** @enum {string} */ + _tag: 'absolute_time_range' + end: string + start: string + } + | { + /** @enum {string} */ + _tag: 'relative_time_range' + amount: number + /** @enum {string} */ + unit: 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year' + } + title?: string + /** @enum {string} */ + view?: 'table' | 'chart' + } + | { + _id?: string + /** @enum {string} */ + _tag: 'markdown_cell' + text: string + } + )[] + schema_version: number + } description?: string /** * @description A missing `favorite` value means that the value will remained unchanged for updates. Defaults to `false` for inserts. * @default false */ favorite?: boolean - folder_id?: (null | (string | null)) | null + /** Format: uuid */ + folder_id?: string | null id?: string name: string owner_id?: number project_id?: number /** @enum {string} */ - type: 'sql' | 'report' | 'log_sql' + type: 'sql' | 'report' | 'log_sql' | 'notebook' /** @enum {string} */ visibility: 'user' | 'project' | 'org' | 'public' } @@ -25421,7 +25487,7 @@ export interface operations { name?: string sort_by?: 'name' | 'inserted_at' sort_order?: 'asc' | 'desc' - type?: 'sql' | 'report' | 'log_sql' + type?: 'sql' | 'report' | 'log_sql' | 'notebook' visibility?: string } header?: never @@ -25579,7 +25645,7 @@ export interface operations { parameters: { query?: { name?: string - type?: 'sql' | 'report' | 'log_sql' + type?: 'sql' | 'report' | 'log_sql' | 'notebook' } header?: never path: { @@ -25636,7 +25702,7 @@ export interface operations { name?: string sort_by?: 'name' | 'inserted_at' sort_order?: 'asc' | 'desc' - type?: 'sql' | 'report' | 'log_sql' + type?: 'sql' | 'report' | 'log_sql' | 'notebook' visibility?: string } header?: never From cf2322d1989ee15f931688161849f15b4703b0e1 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 20 Aug 2026 13:14:13 +0800 Subject: [PATCH 3/9] Add delete chat functionality to explorer chats (#49250) ## Context Just realised that chats in the new Explorer UI have no delete functionality so this patches it image Also added a confirmation modal for deletion image ## Summary by CodeRabbit - **New Features** - Added an option to delete Explorer chats from the chat toolbar. - Added a confirmation prompt before permanently deleting chat history. - Added success feedback after deletion is completed. --- .../Explorer/ExplorerChatToolbar.tsx | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/apps/studio/components/interfaces/Explorer/ExplorerChatToolbar.tsx b/apps/studio/components/interfaces/Explorer/ExplorerChatToolbar.tsx index 0444012d883ca..14edcaf9f6dfc 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerChatToolbar.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerChatToolbar.tsx @@ -1,4 +1,4 @@ -import { Clipboard, MessageSquare, MoreVertical, Settings } from 'lucide-react' +import { Clipboard, MessageSquare, MoreVertical, Settings, Trash } from 'lucide-react' import { useState } from 'react' import { toast } from 'sonner' import { @@ -9,6 +9,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from 'ui' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { ExplorerToolbar, @@ -41,6 +42,7 @@ export const ExplorerChatToolbar = ({ const snap = useAiAssistantStateSnapshot() const chat = snap.chats[chatId] const [isOptInModalOpen, setIsOptInModalOpen] = useState(false) + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) const handleCopyChatId = () => { copyToClipboard(chatId, () => toast.success(`Copied chat ID for ${chat?.name}`)) @@ -50,6 +52,12 @@ export const ExplorerChatToolbar = ({ if (name.trim()) snap.renameChat(chatId, name.trim()) } + const handleDeleteChat = () => { + snap.deleteChat(chatId) + setIsDeleteModalOpen(false) + toast.success(`Deleted "${chat?.name}"`) + } + useShortcut(SHORTCUT_IDS.AI_ASSISTANT_COPY_CHAT_ID, handleCopyChatId, { enabled: shortcutsEnabled && !isChatLoading, }) @@ -83,7 +91,6 @@ export const ExplorerChatToolbar = ({ sequence={SHORTCUT_DEFINITIONS[SHORTCUT_IDS.AI_ASSISTANT_COPY_CHAT_ID].sequence} /> - setIsOptInModalOpen(true)} @@ -98,10 +105,16 @@ export const ExplorerChatToolbar = ({ } /> + + setIsDeleteModalOpen(true)}> + + Delete chat + + + + setIsDeleteModalOpen(false)} + onConfirm={handleDeleteChat} + > +

+ This will permanently delete this chat and its message history. This action cannot be + undone. +

+
) } From dde45ab06cc3b806488fbf0256c9f3917491b122 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 20 Aug 2026 14:05:38 +0800 Subject: [PATCH 4/9] Joshenlim/fe 4195 explorer queryeditor cmd k completion support (#49248) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context Adds the inline AI completion functionality into Explorer QueryEditor, similar to what we've got for the existing SQL editor - Shifts the `ResizableAIWidget` and `InlineWidget` components out of the SQL Editor folder into `components/ui/AiEditor` to be used by both SQL Editor and Query Editor - Consolidates the "proposal" logic that was initially set up for the Clickhouse Migration functionality with this Inline AI stuff - Also added the prompt into the proposal header (Refer to the screenshots below) - SQL Editor didn't have this - but figured this is useful as context for the user image Source selected: Database image Source selected: Logs image ## Summary by CodeRabbit - **New Features** - Added AI-assisted SQL generation with contextual prompts and OS-specific guidance. - Review generated SQL changes in a diff, then accept, reject, or cancel suggestions. - Added inline, resizable AI prompt controls with loading and submission states. - Added the Ctrl/Cmd+Shift+K shortcut to run AI SQL generation. - **Improvements** - Added options to disable query execution and run custom actions. - Renamed “Recent” to “Recently updated.” - Improved editor widget positioning and display behavior. - Added clearer error notifications when AI generation fails. --- .../studio/.github/eslint-rule-baselines.json | 6 +- .../interfaces/Explorer/QueryEditor/index.tsx | 172 ++++++++++++++---- .../Explorer/QueryEditor/useQueryEditorAi.ts | 97 ++++++++++ .../SQLEditor/SQLEditorEditorPanel.tsx | 2 +- .../layouts/ExplorerLayout/ExplorerLayout.tsx | 3 +- .../ExplorerLayout/ExplorerNavHome.tsx | 2 +- .../SQLEditor => ui/AIEditor}/AskAIWidget.tsx | 0 .../AIEditor}/InlineWidget.tsx | 4 +- .../ui/AIEditor/ResizableAIWidget.tsx | 8 +- apps/studio/components/ui/AIEditor/index.tsx | 2 +- .../ui/CodeEditor/CodeEditor.utils.ts | 2 +- 11 files changed, 249 insertions(+), 49 deletions(-) create mode 100644 apps/studio/components/interfaces/Explorer/QueryEditor/useQueryEditorAi.ts rename apps/studio/components/{interfaces/SQLEditor => ui/AIEditor}/AskAIWidget.tsx (100%) rename apps/studio/components/{interfaces/SQLEditor => ui/AIEditor}/InlineWidget.tsx (98%) diff --git a/apps/studio/.github/eslint-rule-baselines.json b/apps/studio/.github/eslint-rule-baselines.json index 20ebe9716740d..26a486ececdf2 100644 --- a/apps/studio/.github/eslint-rule-baselines.json +++ b/apps/studio/.github/eslint-rule-baselines.json @@ -72,8 +72,8 @@ "components/interfaces/Reports/ReportBlock/ChartBlock.tsx": 1, "components/interfaces/Reports/ReportFilterBar.tsx": 1, "components/interfaces/Reports/Reports.tsx": 1, - "components/interfaces/SQLEditor/AskAIWidget.tsx": 1, - "components/interfaces/SQLEditor/InlineWidget.tsx": 2, + "components/ui/AIEditor/AskAIWidget.tsx": 1, + "components/ui/AIEditor/InlineWidget.tsx": 2, "components/interfaces/SQLEditor/MoveQueryModal.tsx": 1, "components/interfaces/SQLEditor/OngoingQueriesPanel.tsx": 1, "components/interfaces/SQLEditor/useSnippetIdentity.ts": 1, @@ -754,7 +754,7 @@ "components/interfaces/Reports/ReportStickyNav.tsx": 1, "components/interfaces/Reports/ReportWidget.tsx": 1, "components/interfaces/Reports/Reports.tsx": 1, - "components/interfaces/SQLEditor/InlineWidget.tsx": 1, + "components/ui/AIEditor/InlineWidget.tsx": 1, "components/interfaces/SQLEditor/SQLTemplates/SQLExamples.tsx": 1, "components/interfaces/SQLEditor/SQLTemplates/SQLTemplates.tsx": 1, "components/interfaces/SQLEditor/UtilityPanel/ReadOnlyBadge.tsx": 1, diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx index 50985ecf9e98e..8ddfc51f5d8de 100644 --- a/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx @@ -1,7 +1,16 @@ import { acceptUntrustedSql, untrustedSql, type UntrustedSqlFragment } from '@supabase/pg-meta' import { useFlag } from 'common' import { CodeSquare, Eye, EyeOff, Play } from 'lucide-react' -import { forwardRef, useImperativeHandle, useState, type ReactNode } from 'react' +import type { editor as monacoEditor } from 'monaco-editor' +import { + forwardRef, + useEffect, + useEffectEvent, + useImperativeHandle, + useRef, + useState, + type ReactNode, +} from 'react' import { Button, cn } from 'ui' import { resolveLogTimeRange } from '../../QuerySources/LogTimeRange.utils' @@ -23,7 +32,10 @@ import { type QueryDisplay, type QueryResult } from '../types' import { DisplaySettingsButton } from './DisplaySettingsButton' import { QueryResultRenderer } from './QueryResultRenderer' import { QuerySourceMenu } from './QuerySourceMenu' +import { useQueryEditorAi } from './useQueryEditorAi' import { LegacyLogsRewriteBanner } from '@/components/interfaces/Settings/Logs/LegacyLogsRewriteBanner' +import { ResizableAIWidget } from '@/components/ui/AIEditor/ResizableAIWidget' +import { getEditorSelectionParts, type EditorSelection } from '@/components/ui/AIEditor/utils' import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' import { DiffEditor } from '@/components/ui/DiffEditor' import { @@ -45,15 +57,25 @@ import { import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation' import { applyAutoLimit } from '@/data/sql/utils' -import { type LegacyLogsRewriteProposal } from '@/hooks/analytics/useLegacyLogsRewrite' import { useLatest } from '@/hooks/misc/useLatest' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { detectOS } from '@/lib/helpers' import { wrapWithRoleImpersonation } from '@/lib/role-impersonation' import { isRoleImpersonationEnabled, type RoleImpersonationController, } from '@/state/role-impersonation-state' +const generatePlaceholder = (os: string | undefined) => + `Hit ${os === 'macos' ? 'CMD+SHIFT+K' : 'CTRL+SHIFT+K'} to generate query or just start typing` + +type PendingProposal = { + original: string + modified: string + label: string + prompt?: string +} + /** * The query this editor is showing, tagged by backend. The tag correlates the SQL's * dialect brand with that backend's parameters, so a single `_tag` check inside @@ -71,7 +93,11 @@ export type ExplorerQueryModel = uncheckedSql: UntrustedLogSqlFragment } & LogsSourceParameters) -export type QueryEditorProps = { +export type QueryEditorHandle = { + run: () => Promise +} + +type QueryEditorProps = { id: string variant: 'embedded' | 'viewport' title: string @@ -93,10 +119,6 @@ export type QueryEditorProps = { onRun?: () => void } -export type QueryEditorHandle = { - run: () => Promise -} - /** * Shared query editor used by query tabs, notebook cells, and other Explorer surfaces. * The consuming surface owns persistence and surrounding chrome; this component owns @@ -125,6 +147,7 @@ export const QueryEditor = forwardRef(funct }: QueryEditorProps, ref ) { + const os = detectOS() const sql = query.uncheckedSql const sqlRef = useLatest(sql) const onSqlCommitRef = useLatest(onSqlCommit) @@ -138,7 +161,17 @@ export const QueryEditor = forwardRef(funct const databaseIdentifier = query._tag === 'database' ? query.database_identifier : undefined const [showQuery, setShowQuery] = useState(true) - const [rewriteProposal, setRewriteProposal] = useState(null) + const [promptInput, setPromptInput] = useState('') + const [pendingProposal, setPendingProposal] = useState(null) + const pendingProposalRef = useLatest(pendingProposal) + + const editorInstanceRef = useRef(null) + const [promptState, setPromptState] = useState<(EditorSelection & { isOpen: boolean }) | null>( + null + ) + + const dialect = query._tag === 'logs' ? 'clickhouse' : 'postgres' + const { requestCompletion, isCompletionLoading } = useQueryEditorAi({ dialect }) const { data: databases, isPending: isLoadingDatabases } = useReadReplicasQuery( { projectRef: project?.ref }, @@ -173,7 +206,7 @@ export const QueryEditor = forwardRef(funct * Postgres SQL cannot reach the analytics wire or vice versa. */ const handleRunQuery = async (rawSql: string = sql) => { - if (!project || isBusy || rewriteProposal || isRunDisabled || rawSql.trim().length === 0) return + if (!project || isBusy || pendingProposal || isRunDisabled || rawSql.trim().length === 0) return onRun?.() onSqlCommit?.(rawSql) @@ -219,21 +252,55 @@ export const QueryEditor = forwardRef(funct }).catch(() => {}) } - const acceptRewrite = () => { - if (!rewriteProposal) return - if (sql === rewriteProposal.original) { - onSqlChange(rewriteProposal.modified) - onSqlCommit?.(rewriteProposal.modified) + const acceptSqlProposal = () => { + if (!pendingProposal) return + if (sql === pendingProposal.original) { + onSqlChange(pendingProposal.modified) + onSqlCommit?.(pendingProposal.modified) } - setRewriteProposal(null) + setPendingProposal(null) } - const discardRewrite = () => setRewriteProposal(null) + const discardSqlProposal = () => setPendingProposal(null) - useImperativeHandle(ref, () => ({ run: () => handleRunQuery() })) + const closePrompt = () => { + setPromptState(null) + setPromptInput('') + } + + const handleEscapeKey = useEffectEvent((e: KeyboardEvent) => { + if (e.key !== 'Escape') return + closePrompt() + editorInstanceRef.current?.focus() + }) + + const handleGenerateSql = async (prompt: string) => { + if (!promptState) return + + const { original, modified } = await requestCompletion(prompt, promptState) + if (original !== undefined && modified !== undefined) { + setPendingProposal({ + original, + modified, + label: 'Review the suggested SQL edit before accepting it', + prompt, + }) + closePrompt() + } + } const Shell = variant === 'viewport' ? ExplorerQueryViewport : ExplorerQuery + useImperativeHandle(ref, () => ({ run: () => handleRunQuery() })) + + useEffect(() => { + if (!promptState?.isOpen) return + const node = editorInstanceRef.current?.getDomNode() + if (!node) return + node.addEventListener('keydown', handleEscapeKey) + return () => node.removeEventListener('keydown', handleEscapeKey) + }, [promptState?.isOpen]) + return ( @@ -245,10 +312,10 @@ export const QueryEditor = forwardRef(funct {toolbarActions} {onSourceChange && ( { - setRewriteProposal(null) + setPendingProposal(null) onSourceChange(source) }} rowLimit={rowLimit} @@ -267,7 +334,7 @@ export const QueryEditor = forwardRef(funct )} : } - disabled={rewriteProposal !== null} + disabled={pendingProposal !== null} tooltip={showQuery ? 'Hide query' : 'Show query'} onClick={() => setShowQuery((value) => !value)} /> @@ -278,7 +345,7 @@ export const QueryEditor = forwardRef(funct disabled={ isLoadingProject || isExecuting || - rewriteProposal !== null || + pendingProposal !== null || isRunDisabled || sql.trim().length === 0 } @@ -295,8 +362,14 @@ export const QueryEditor = forwardRef(funct isLogsSource={query._tag === 'logs'} sql={sql} readSql={() => sqlRef.current} - onProposal={setRewriteProposal} - hidden={rewriteProposal !== null} + onProposal={({ original, modified }) => + setPendingProposal({ + original, + modified, + label: 'Review the ClickHouse SQL rewrite before accepting it', + }) + } + hidden={pendingProposal !== null} /> (funct id={`explorer-query-${id}`} language="pgsql" value={sql} - placeholder="select * from your_table limit 100;" + placeholder={!promptState?.isOpen ? generatePlaceholder(os) : ''} placeholderClassName="top-[13px]" className={variant === 'embedded' ? 'h-44' : undefined} actions={{ runQuery: { enabled: !isRunDisabled, callback: handleRunQuery } }} options={{ minimap: { enabled: false }, padding: { top: 8 } }} onInputChange={(value) => onSqlChange(value ?? '')} - onMount={(editor) => { + onMount={(editor, monaco) => { editor.onDidBlurEditorWidget(() => onSqlCommitRef.current?.(sqlRef.current)) + editorInstanceRef.current = editor + + editor.addAction({ + id: 'generate-sql', + label: 'Generate SQL', + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyK], + run: () => { + if (pendingProposalRef.current) return + const selectionParts = getEditorSelectionParts(editor) + if (selectionParts) setPromptState({ isOpen: true, ...selectionParts }) + }, + }) }} /> - {rewriteProposal && ( + + {promptState?.isOpen && editorInstanceRef.current && !pendingProposal && ( + + )} + + {pendingProposal && (
- - Review the ClickHouse SQL rewrite before accepting it - +
+

{pendingProposal.label}

+ {pendingProposal.prompt && ( +

+ Prompt: {pendingProposal.prompt} +

+ )} +
- -
@@ -333,8 +439,8 @@ export const QueryEditor = forwardRef(funct
diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor/useQueryEditorAi.ts b/apps/studio/components/interfaces/Explorer/QueryEditor/useQueryEditorAi.ts new file mode 100644 index 0000000000000..a8b7ad9b64c61 --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/QueryEditor/useQueryEditorAi.ts @@ -0,0 +1,97 @@ +import { useState } from 'react' +import { toast } from 'sonner' +import * as z from 'zod' + +import { + assembleCompletionDiff, + buildCompletionRequestBody, + type SqlDialect, +} from '@/components/interfaces/SQLEditor/SQLEditor.utils' +import { type EditorSelection } from '@/components/ui/AIEditor/utils' +import { constructHeaders } from '@/data/fetchers' +import { stripSqlCodeFences } from '@/data/logs/logs-sql-rewrite' +import { useLogsAttributeKeys } from '@/hooks/analytics/useLogsAttributeKeys' +import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { BASE_PATH } from '@/lib/constants' +import { formatSql } from '@/lib/formatSql' +import { getErrorMessage } from '@/lib/get-error-message' + +export function useQueryEditorAi({ dialect }: { dialect: SqlDialect }) { + const { data: project } = useSelectedProjectQuery() + const { data: organization } = useSelectedOrganizationQuery() + const { fetchAttributeKeys } = useLogsAttributeKeys() + + const [isCompletionLoading, setIsCompletionLoading] = useState(false) + + const isClickhouse = dialect === 'clickhouse' + + const requestCompletion = async ( + prompt: string, + context: Pick + ) => { + setIsCompletionLoading(true) + try { + const [headerData, availableKeys] = await Promise.all([ + constructHeaders(), + isClickhouse + ? fetchAttributeKeys(context.beforeSelection + context.selection + context.afterSelection) + : undefined, + ]) + const authorizationHeader = headerData.get('Authorization') + + const response = await fetch(`${BASE_PATH}/api/ai/code/complete`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(authorizationHeader ? { Authorization: authorizationHeader } : {}), + }, + body: JSON.stringify( + buildCompletionRequestBody({ + projectRef: project?.ref, + connectionString: project?.connectionString, + orgSlug: organization?.slug, + dialect, + options: { + completionMetadata: { + textBeforeCursor: context.beforeSelection, + textAfterCursor: context.afterSelection, + language: 'pgsql', + prompt, + selection: context.selection, + ...(availableKeys ? { availableKeys } : {}), + }, + }, + }) + ), + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(errorText || 'Failed to generate completion') + } + + const text = z.string().parse(await response.json()) + + const meta = { + textBeforeCursor: context.beforeSelection, + textAfterCursor: context.afterSelection, + selection: context.selection, + } + + const { original, modified } = assembleCompletionDiff( + meta, + isClickhouse ? stripSqlCodeFences(text) : text + ) + + return { original, modified: isClickhouse ? modified : formatSql(modified) } + } catch (error) { + toast.error(`Failed to generate SQL: ${getErrorMessage(error, 'Unknown error')}`) + return { original: undefined, modified: undefined } + } finally { + setIsCompletionLoading(false) + } + } + + return { requestCompletion, isCompletionLoading } +} diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx index b42c6b9778560..3872e5fa531ab 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx @@ -12,7 +12,7 @@ import { useSqlEditorSnippet, useSqlEditorUi, } from './SQLEditorControllers' -import ResizableAIWidget from '@/components/ui/AIEditor/ResizableAIWidget' +import { ResizableAIWidget } from '@/components/ui/AIEditor/ResizableAIWidget' import { acceptUntrustedLogsSql } from '@/data/logs/safe-analytics-sql' import { detectOS } from '@/lib/helpers' diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx index 75ed7b5d2bb21..7e2f1920e3fbe 100644 --- a/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx +++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx @@ -36,9 +36,10 @@ export interface ExplorerLayoutProps extends ComponentProps { - const [section, setSection] = useState() const tabs = useTabsStateSnapshot() + const [section, setSection] = useState() + const activeTab = tabs.activeTab ? tabs.tabsMap[tabs.activeTab] : undefined const isActiveExplorerTab = activeTab !== undefined && editorEntityTypes.explorer.includes(activeTab.type) diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerNavHome.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavHome.tsx index da7ce2a25a3f0..e25e74a697d81 100644 --- a/apps/studio/components/layouts/ExplorerLayout/ExplorerNavHome.tsx +++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavHome.tsx @@ -46,7 +46,7 @@ export const ExplorerNavHome = ({

- Recent + Recently updated

Nothing edited yet

diff --git a/apps/studio/components/interfaces/SQLEditor/AskAIWidget.tsx b/apps/studio/components/ui/AIEditor/AskAIWidget.tsx similarity index 100% rename from apps/studio/components/interfaces/SQLEditor/AskAIWidget.tsx rename to apps/studio/components/ui/AIEditor/AskAIWidget.tsx diff --git a/apps/studio/components/interfaces/SQLEditor/InlineWidget.tsx b/apps/studio/components/ui/AIEditor/InlineWidget.tsx similarity index 98% rename from apps/studio/components/interfaces/SQLEditor/InlineWidget.tsx rename to apps/studio/components/ui/AIEditor/InlineWidget.tsx index 4bc085ec62c5b..cd98259708078 100644 --- a/apps/studio/components/interfaces/SQLEditor/InlineWidget.tsx +++ b/apps/studio/components/ui/AIEditor/InlineWidget.tsx @@ -35,7 +35,7 @@ export interface InlineWidgetProps { * widgets (such as "Go to References"), but built for React. * Uses a combination of a view zone and overlay widget. */ -const InlineWidget = ({ +export const InlineWidget = ({ children, editor, id, @@ -125,5 +125,3 @@ const InlineWidget = ({ return createPortal(children, containerElement, key) } - -export default InlineWidget diff --git a/apps/studio/components/ui/AIEditor/ResizableAIWidget.tsx b/apps/studio/components/ui/AIEditor/ResizableAIWidget.tsx index aa0075b119d27..11a6fa8e3c829 100644 --- a/apps/studio/components/ui/AIEditor/ResizableAIWidget.tsx +++ b/apps/studio/components/ui/AIEditor/ResizableAIWidget.tsx @@ -1,8 +1,8 @@ import type { editor as monacoEditor } from 'monaco-editor' import { useCallback, useEffect, useRef, useState } from 'react' -import { AskAIWidget } from '@/components/interfaces/SQLEditor/AskAIWidget' -import InlineWidget from '@/components/interfaces/SQLEditor/InlineWidget' +import { AskAIWidget } from './AskAIWidget' +import { InlineWidget } from './InlineWidget' interface ResizableAIWidgetProps { editor: monacoEditor.IStandaloneCodeEditor | monacoEditor.IStandaloneDiffEditor @@ -22,7 +22,7 @@ interface ResizableAIWidgetProps { const LINE_HEIGHT = 20 // height of each line in pixels const MIN_LINES = 3 // minimum number of lines to show -const ResizableAIWidget = ({ +export const ResizableAIWidget = ({ editor, id, value, @@ -85,5 +85,3 @@ const ResizableAIWidget = ({ ) } - -export default ResizableAIWidget diff --git a/apps/studio/components/ui/AIEditor/index.tsx b/apps/studio/components/ui/AIEditor/index.tsx index 8e348a485f7df..b9b8cb8ec23eb 100644 --- a/apps/studio/components/ui/AIEditor/index.tsx +++ b/apps/studio/components/ui/AIEditor/index.tsx @@ -8,7 +8,7 @@ import { useSetCommandMenuOpen } from 'ui-patterns/CommandMenu' import { CodeEditor, type ValidLanguages } from '../CodeEditor/CodeEditor' import { DiffEditor } from '../DiffEditor' -import ResizableAIWidget from './ResizableAIWidget' +import { ResizableAIWidget } from './ResizableAIWidget' import { getEditorSelectionParts } from './utils' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { constructHeaders } from '@/data/fetchers' diff --git a/apps/studio/components/ui/CodeEditor/CodeEditor.utils.ts b/apps/studio/components/ui/CodeEditor/CodeEditor.utils.ts index a33b18bf56e1e..8f3b0c5aab691 100644 --- a/apps/studio/components/ui/CodeEditor/CodeEditor.utils.ts +++ b/apps/studio/components/ui/CodeEditor/CodeEditor.utils.ts @@ -13,7 +13,7 @@ export const BASE_MONACO_EDITOR_OPTIONS: editor.IStandaloneEditorConstructionOpt fontSize: 13, minimap: { enabled: false }, wordWrap: 'on', - fixedOverflowWidgets: true, + fixedOverflowWidgets: false, contextmenu: true, scrollBeyondLastLine: false, } From 9ae6e54dd585fb7f71dfc6917ab9fc09fe3a408a Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Thu, 20 Aug 2026 15:23:13 +0800 Subject: [PATCH 5/9] fix(www): exclude redirected slugs from generated markdown (#48476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 11 of the generated `MD_PAGES` entries are blog slugs whose HTML pages 308-redirect away via `apps/www/lib/redirects.js` before any `` renders. They can never carry an alternate tag and Accept negotiation never fires (Next.js `redirects()` runs before middleware), so their `.md` siblings are orphaned content reachable only by guessing the suffixed URL. Six of them duplicate live, correctly-tagged pages (`/customers/*`, `/pricing`). **Changed:** - `generateMdContent.mjs` derives an exclusion set from `lib/redirects.js` at generation time: any unconditional exact-match redirect source (wildcard/param patterns and conditional `has`/`missing` redirects are skipped) drops the matching slug from both `MD_CONTENT` and `MD_PAGES`. The build log names every excluded slug, currently the 11 known ones. - Self-maintaining by design (per the decision recorded on the issue): a future redirected post auto-excludes on the next build, and removing a redirect brings its `.md` sibling back. The MDX sources stay in the repo; nothing is deleted. - Effect on the 11 slugs: alternate tags stay absent (nothing rendered them anyway), and explicit `.md` URLs go from serving orphaned markdown to 404, the same external effect deletion would have had. ## To test Tested locally: - [x] `node scripts/generateMdContent.mjs` logs `🚫 Excluded 11 redirected slugs: ...` naming exactly the 11 known slugs; output drops 483 → 472 pages - [x] Generated file carries no MD_CONTENT/MD_PAGES key for any excluded slug (raw URL mentions inside other posts' bodies remain, as expected) - [x] `apps/www` vitest: 71/71 (GROWTH-1013 drift tests unaffected) Post-merge: - [ ] `https://supabase.com/blog/case-study-xendit.md` returns 404 (previously 200 orphaned markdown); `https://supabase.com/pricing.md` still 200 ## Linear - fixes GROWTH-1022 ## Summary by CodeRabbit * **Bug Fixes** * Excluded content with valid exact-path redirects from generated documentation. * Preserved content associated with conditional or wildcard redirects. * Updated generated page counts and output statistics to reflect the filtered content. --- apps/www/scripts/generateMdContent.mjs | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/apps/www/scripts/generateMdContent.mjs b/apps/www/scripts/generateMdContent.mjs index 378b18cefbf46..2dddb0351e3cb 100644 --- a/apps/www/scripts/generateMdContent.mjs +++ b/apps/www/scripts/generateMdContent.mjs @@ -11,6 +11,7 @@ import path from 'path' import { fileURLToPath } from 'url' import matter from 'gray-matter' +import redirects from '../lib/redirects.js' import { mdxBodyToMarkdown } from './lib/mdxToMarkdown.mjs' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -170,6 +171,20 @@ for (const section of MDX_SECTIONS) { const allEntries = [...staticEntries, ...mdxEntries] +const redirectedSlugs = new Set( + redirects + .filter((redirect) => !redirect.has && !redirect.missing && /^\/[^:*]+$/.test(redirect.source)) + .map((redirect) => redirect.source.slice(1)) +) + +const liveEntries = allEntries.filter((entry) => !redirectedSlugs.has(entry.slug)) +const excludedSlugs = allEntries + .filter((entry) => redirectedSlugs.has(entry.slug)) + .map((entry) => entry.slug) +if (excludedSlugs.length > 0) { + console.log(`🚫 Excluded ${excludedSlugs.length} redirected slugs: ${excludedSlugs.join(', ')}`) +} + const dynamicCollisions = staticSlugs.filter((s) => DYNAMIC_SLUGS.includes(s)) if (dynamicCollisions.length > 0) { console.error( @@ -198,11 +213,11 @@ for (const entry of allEntries) { seen.add(entry.slug) } -const contentEntries = allEntries +const contentEntries = liveEntries .map((e) => ` [${JSON.stringify(e.slug)}, ${JSON.stringify(e.content)}]`) .join(',\n') -const allPageSlugs = [...allEntries.map((e) => e.slug), ...DYNAMIC_SLUGS] +const allPageSlugs = [...liveEntries.map((e) => e.slug), ...DYNAMIC_SLUGS] const pageEntries = allPageSlugs.map((s) => ` ${JSON.stringify(s)}`).join(',\n') const output = `// AUTO-GENERATED by scripts/generateMdContent.mjs — do not edit @@ -216,4 +231,6 @@ ${pageEntries}, ` await fs.writeFile(outputPath, output, 'utf-8') -console.log(`✅ Generated ${outputPath} (${allEntries.length} files, ${allPageSlugs.length} pages)`) +console.log( + `✅ Generated ${outputPath} (${liveEntries.length} files, ${allPageSlugs.length} pages)` +) From facc2df09ea5aaac91380181b1faf818edda6473 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:34:46 +1000 Subject: [PATCH 6/9] chore(design-system): generate and ignore registry output (#49290) ## What kind of change does this PR introduce? Chore. Stops committing generated design-system registry output ([DEPR-647](https://linear.app/supabase/issue/DEPR-647/generate-and-ignore-design-system-registry-output)). ## What is the current behavior? `apps/design-system/__registry__` is build output from `registry/`, but the chart snapshots and index are committed. That makes reviews noisy, and a forgotten `build:registry` leaves `master` out of date until someone else regenerates it. ## What is the new behavior? `pnpm dev` and `pnpm typecheck` generate `__registry__` automatically. The directory is gitignored, and the previously tracked snapshots are removed. `pnpm build` still generates it as before. ## To test - From the repo root, run `pnpm --filter=design-system generate:registry` and confirm `apps/design-system/__registry__/index.tsx` is created locally and is untracked. - Run `pnpm dev:design-system`, open [http://localhost:3003](http://localhost:3003), and open any component docs page with a live preview (for example Charts). Previews and source panels should still load. ## Summary by CodeRabbit * **Documentation** * Clarified when the component registry is generated and how to regenerate it. * Expanded component documentation guidance, including content sources and generated-file editing restrictions. * **Chores** * Improved registry generation across development, type checking, builds, and cleanup. * Generated registry files are now excluded from version control and linting. * Improved reliability when creating and refreshing generated registry files. --- apps/design-system/.gitignore | 5 +- apps/design-system/README.md | 44 +- .../default/block/chart-bar-interactive.tsx | 211 - .../default/block/chart-composed-actions.tsx | 106 - .../default/block/chart-composed-basic.tsx | 142 - .../default/block/chart-composed-demo.tsx | 73 - .../default/block/chart-composed-metrics.tsx | 100 - .../default/block/chart-composed-states.tsx | 121 - .../default/block/chart-composed-table.tsx | 106 - apps/design-system/__registry__/index.tsx | 3738 ----------------- apps/design-system/eslint.config.cjs | 3 +- apps/design-system/package.json | 11 +- apps/design-system/scripts/build-registry.mts | 15 +- 13 files changed, 39 insertions(+), 4636 deletions(-) delete mode 100644 apps/design-system/__registry__/default/block/chart-bar-interactive.tsx delete mode 100644 apps/design-system/__registry__/default/block/chart-composed-actions.tsx delete mode 100644 apps/design-system/__registry__/default/block/chart-composed-basic.tsx delete mode 100644 apps/design-system/__registry__/default/block/chart-composed-demo.tsx delete mode 100644 apps/design-system/__registry__/default/block/chart-composed-metrics.tsx delete mode 100644 apps/design-system/__registry__/default/block/chart-composed-states.tsx delete mode 100644 apps/design-system/__registry__/default/block/chart-composed-table.tsx delete mode 100644 apps/design-system/__registry__/index.tsx diff --git a/apps/design-system/.gitignore b/apps/design-system/.gitignore index 57c2e5aba20fa..2e7fc1fc9dac7 100644 --- a/apps/design-system/.gitignore +++ b/apps/design-system/.gitignore @@ -35,4 +35,7 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts -.contentlayer \ No newline at end of file +.contentlayer + +# Generated by `pnpm generate:registry` (`scripts/build-registry.mts`) +/__registry__ diff --git a/apps/design-system/README.md b/apps/design-system/README.md index 893f21e36e697..a726c23098577 100644 --- a/apps/design-system/README.md +++ b/apps/design-system/README.md @@ -12,13 +12,15 @@ pnpm i pnpm dev ``` -The `dev` command runs both the Next.js development server and Contentlayer concurrently, which is recommended for most development workflows. +The `dev` command generates `__registry__`, then runs the Next.js development server and Contentlayer together. That is the recommended workflow. ### Alternative commands -You can also run the development server and content watcher separately: +You can also run the development server and content watcher separately. Generate the registry first, because `dev:next` and `dev:content` do not: ```bash +pnpm generate:registry + # Run only the Next.js development server pnpm dev:next @@ -26,19 +28,11 @@ pnpm dev:next pnpm dev:content ``` -Or run the development server from the root directory: - -```bash -pnpm dev:design-system -``` - -To run both the development server and content watcher from the root directory, you can use: +From the repo root, `pnpm dev:design-system` runs the same `dev` script, so it also generates `__registry__`. If you split the watchers from the root, generate first: ```bash -# Run the development server -pnpm dev:design-system - -# Run the content watcher (in a separate terminal shell) +pnpm --filter=design-system generate:registry +pnpm --filter=design-system dev:next pnpm --filter=design-system dev:content ``` @@ -46,27 +40,29 @@ Open [http://localhost:3003](http://localhost:3003) in your browser to see the r ### Watching for MDX changes -The `dev` command automatically watches for changes to MDX files with hot reload. If you're running the `pnpm dev:next` separately, you'll need to run `pnpm dev:content` in a separate terminal shell to watch for content changes. +The `dev` command watches MDX files and hot-reloads them. If you are running `pnpm dev:next` on its own, also run `pnpm dev:content` in another terminal. ### Adding components -The design system _references_ components rather than housing them. That’s an important distinction to make, as everything that follows here is about the documentation of components. You can add or edit components in one of these two places: +The design system _references_ components rather than housing them. That distinction matters: everything below is about documenting components, not implementing them. Add or edit the components themselves in one of these two places: - [`packages/ui`](https://github.com/supabase/supabase/tree/master/packages/ui): basic UI components -- [`packages/ui-patterns`](https://github.com/supabase/supabase/tree/master/packages/ui-patterns): components which are built using NPM libraries or amalgamations of components from `patterns/ui` +- [`packages/ui-patterns`](https://github.com/supabase/supabase/tree/master/packages/ui-patterns): components built from libraries or from `packages/ui` -There are several parts of this design system that need to be manually updated after components have been added or removed (from documentation). These include: +After you add or remove documented components, update these source files: - `config/docs.ts`: list of components in the sidebar -- `content/docs`: the actual component documentation -- `registry/examples.ts`: list of example components -- `registry/fragments.ts`: list of fragment components -- `registry/charts.ts`: list of chart components -- `registry/default/example/*`: the actual example components +- `content/docs`: the component documentation +- `registry/examples.ts`: example components +- `registry/fragments.ts`: fragment components +- `registry/charts.ts`: chart components +- `registry/copy-writing.ts`: copywriting examples +- `registry/default/example/*`: the example component implementations +- `registry/default/block/*`: chart block implementations, when you add a chart -You will need to rebuild the design system’s registry after making new additions: +Do not edit `__registry__`. `pnpm dev`, `pnpm typecheck`, and `pnpm build` generate it from the files above, and it is gitignored. If you add registry entries while the app is already running, regenerate it: ```bash cd apps/design-system -pnpm build:registry +pnpm generate:registry ``` diff --git a/apps/design-system/__registry__/default/block/chart-bar-interactive.tsx b/apps/design-system/__registry__/default/block/chart-bar-interactive.tsx deleted file mode 100644 index 128664bb431e8..0000000000000 --- a/apps/design-system/__registry__/default/block/chart-bar-interactive.tsx +++ /dev/null @@ -1,211 +0,0 @@ -'use client' - -import * as React from 'react' -import { Bar, BarChart, CartesianGrid, XAxis } from 'recharts' -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, - ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from 'ui' - -export const description = 'An interactive bar chart' - -const chartData = [ - { date: '2024-04-01', desktop: 222, mobile: 150 }, - { date: '2024-04-02', desktop: 97, mobile: 180 }, - { date: '2024-04-03', desktop: 167, mobile: 120 }, - { date: '2024-04-04', desktop: 242, mobile: 260 }, - { date: '2024-04-05', desktop: 373, mobile: 290 }, - { date: '2024-04-06', desktop: 301, mobile: 340 }, - { date: '2024-04-07', desktop: 245, mobile: 180 }, - { date: '2024-04-08', desktop: 409, mobile: 320 }, - { date: '2024-04-09', desktop: 59, mobile: 110 }, - { date: '2024-04-10', desktop: 261, mobile: 190 }, - { date: '2024-04-11', desktop: 327, mobile: 350 }, - { date: '2024-04-12', desktop: 292, mobile: 210 }, - { date: '2024-04-13', desktop: 342, mobile: 380 }, - { date: '2024-04-14', desktop: 137, mobile: 220 }, - { date: '2024-04-15', desktop: 120, mobile: 170 }, - { date: '2024-04-16', desktop: 138, mobile: 190 }, - { date: '2024-04-17', desktop: 446, mobile: 360 }, - { date: '2024-04-18', desktop: 364, mobile: 410 }, - { date: '2024-04-19', desktop: 243, mobile: 180 }, - { date: '2024-04-20', desktop: 89, mobile: 150 }, - { date: '2024-04-21', desktop: 137, mobile: 200 }, - { date: '2024-04-22', desktop: 224, mobile: 170 }, - { date: '2024-04-23', desktop: 138, mobile: 230 }, - { date: '2024-04-24', desktop: 387, mobile: 290 }, - { date: '2024-04-25', desktop: 215, mobile: 250 }, - { date: '2024-04-26', desktop: 75, mobile: 130 }, - { date: '2024-04-27', desktop: 383, mobile: 420 }, - { date: '2024-04-28', desktop: 122, mobile: 180 }, - { date: '2024-04-29', desktop: 315, mobile: 240 }, - { date: '2024-04-30', desktop: 454, mobile: 380 }, - { date: '2024-05-01', desktop: 165, mobile: 220 }, - { date: '2024-05-02', desktop: 293, mobile: 310 }, - { date: '2024-05-03', desktop: 247, mobile: 190 }, - { date: '2024-05-04', desktop: 385, mobile: 420 }, - { date: '2024-05-05', desktop: 481, mobile: 390 }, - { date: '2024-05-06', desktop: 498, mobile: 520 }, - { date: '2024-05-07', desktop: 388, mobile: 300 }, - { date: '2024-05-08', desktop: 149, mobile: 210 }, - { date: '2024-05-09', desktop: 227, mobile: 180 }, - { date: '2024-05-10', desktop: 293, mobile: 330 }, - { date: '2024-05-11', desktop: 335, mobile: 270 }, - { date: '2024-05-12', desktop: 197, mobile: 240 }, - { date: '2024-05-13', desktop: 197, mobile: 160 }, - { date: '2024-05-14', desktop: 448, mobile: 490 }, - { date: '2024-05-15', desktop: 473, mobile: 380 }, - { date: '2024-05-16', desktop: 338, mobile: 400 }, - { date: '2024-05-17', desktop: 499, mobile: 420 }, - { date: '2024-05-18', desktop: 315, mobile: 350 }, - { date: '2024-05-19', desktop: 235, mobile: 180 }, - { date: '2024-05-20', desktop: 177, mobile: 230 }, - { date: '2024-05-21', desktop: 82, mobile: 140 }, - { date: '2024-05-22', desktop: 81, mobile: 120 }, - { date: '2024-05-23', desktop: 252, mobile: 290 }, - { date: '2024-05-24', desktop: 294, mobile: 220 }, - { date: '2024-05-25', desktop: 201, mobile: 250 }, - { date: '2024-05-26', desktop: 213, mobile: 170 }, - { date: '2024-05-27', desktop: 420, mobile: 460 }, - { date: '2024-05-28', desktop: 233, mobile: 190 }, - { date: '2024-05-29', desktop: 78, mobile: 130 }, - { date: '2024-05-30', desktop: 340, mobile: 280 }, - { date: '2024-05-31', desktop: 178, mobile: 230 }, - { date: '2024-06-01', desktop: 178, mobile: 200 }, - { date: '2024-06-02', desktop: 470, mobile: 410 }, - { date: '2024-06-03', desktop: 103, mobile: 160 }, - { date: '2024-06-04', desktop: 439, mobile: 380 }, - { date: '2024-06-05', desktop: 88, mobile: 140 }, - { date: '2024-06-06', desktop: 294, mobile: 250 }, - { date: '2024-06-07', desktop: 323, mobile: 370 }, - { date: '2024-06-08', desktop: 385, mobile: 320 }, - { date: '2024-06-09', desktop: 438, mobile: 480 }, - { date: '2024-06-10', desktop: 155, mobile: 200 }, - { date: '2024-06-11', desktop: 92, mobile: 150 }, - { date: '2024-06-12', desktop: 492, mobile: 420 }, - { date: '2024-06-13', desktop: 81, mobile: 130 }, - { date: '2024-06-14', desktop: 426, mobile: 380 }, - { date: '2024-06-15', desktop: 307, mobile: 350 }, - { date: '2024-06-16', desktop: 371, mobile: 310 }, - { date: '2024-06-17', desktop: 475, mobile: 520 }, - { date: '2024-06-18', desktop: 107, mobile: 170 }, - { date: '2024-06-19', desktop: 341, mobile: 290 }, - { date: '2024-06-20', desktop: 408, mobile: 450 }, - { date: '2024-06-21', desktop: 169, mobile: 210 }, - { date: '2024-06-22', desktop: 317, mobile: 270 }, - { date: '2024-06-23', desktop: 480, mobile: 530 }, - { date: '2024-06-24', desktop: 132, mobile: 180 }, - { date: '2024-06-25', desktop: 141, mobile: 190 }, - { date: '2024-06-26', desktop: 434, mobile: 380 }, - { date: '2024-06-27', desktop: 448, mobile: 490 }, - { date: '2024-06-28', desktop: 149, mobile: 200 }, - { date: '2024-06-29', desktop: 103, mobile: 160 }, - { date: '2024-06-30', desktop: 446, mobile: 400 }, -] - -const chartConfig = { - views: { - label: 'Page Views', - }, - desktop: { - label: 'Desktop', - color: 'hsl(var(--chart-1))', - }, - mobile: { - label: 'Mobile', - color: 'hsl(var(--chart-2))', - }, -} satisfies ChartConfig - -export default function Component() { - const [activeChart, setActiveChart] = React.useState('desktop') - - const total = React.useMemo( - () => ({ - desktop: chartData.reduce((acc, curr) => acc + curr.desktop, 0), - mobile: chartData.reduce((acc, curr) => acc + curr.mobile, 0), - }), - [] - ) - - return ( - - -
- Bar Chart - Interactive - Showing total visitors for the last 3 months -
-
- {['desktop', 'mobile'].map((key) => { - const chart = key as keyof typeof chartConfig - return ( - - ) - })} -
-
- - - - - { - const date = new Date(value) - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - }) - }} - /> - { - return new Date(value).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }) - }} - /> - } - /> - - - - -
- ) -} diff --git a/apps/design-system/__registry__/default/block/chart-composed-actions.tsx b/apps/design-system/__registry__/default/block/chart-composed-actions.tsx deleted file mode 100644 index ee4212e416ed3..0000000000000 --- a/apps/design-system/__registry__/default/block/chart-composed-actions.tsx +++ /dev/null @@ -1,106 +0,0 @@ -'use client' - -import { BarChart2, ExternalLink, LineChart } from 'lucide-react' -import { useEffect, useState } from 'react' -import { - Chart, - ChartActions, - ChartBar, - ChartCard, - ChartContent, - ChartEmptyState, - ChartHeader, - ChartLine, - ChartLoadingState, - ChartTitle, -} from 'ui-patterns/Chart' - -export default function ChartComposedActions() { - const [isLoading, setIsLoading] = useState(true) - const [chartType, setChartType] = useState<'bar' | 'line'>('bar') - - const actions = [ - { - label: 'Open in Logs Explorer', - onClick: () => { - alert('Opening in Logs Explorer...') - }, - icon: , - }, - { - label: 'Toggle Chart Type', - onClick: () => { - setChartType(chartType === 'bar' ? 'line' : 'bar') - }, - icon: chartType === 'bar' ? : , - }, - ] - - const data = Array.from({ length: 46 }, (_, i) => { - const date = new Date() - date.setMinutes(date.getMinutes() - i * 5) // Each point 5 minutes apart - - return { - timestamp: date.toISOString(), - requests: Math.floor(Math.random() * 100), - } - }).reverse() - - useEffect(() => { - setTimeout(() => { - setIsLoading(false) - }, 1000) - }, []) - - return ( -
- - - - Total Requests - - - } - title="No data to show" - description="It may take up to 24 hours for data to refresh" - /> - } - loadingState={} - > -
- {chartType === 'bar' ? ( - `${value}k`, - width: 80, - }} - isFullHeight={true} - /> - ) : ( - `${value}k`, - width: 80, - }} - isFullHeight={true} - /> - )} -
-
-
-
-
- ) -} diff --git a/apps/design-system/__registry__/default/block/chart-composed-basic.tsx b/apps/design-system/__registry__/default/block/chart-composed-basic.tsx deleted file mode 100644 index 607363710eb09..0000000000000 --- a/apps/design-system/__registry__/default/block/chart-composed-basic.tsx +++ /dev/null @@ -1,142 +0,0 @@ -'use client' - -import { BarChart2, ExternalLink } from 'lucide-react' -import { useEffect, useState } from 'react' -import { - Chart, - ChartActions, - ChartBar, - ChartCard, - ChartContent, - ChartEmptyState, - ChartHeader, - ChartLine, - ChartLoadingState, - ChartTitle, -} from 'ui-patterns/Chart' - -export default function ComposedChartBasic() { - const [isLoading, setIsLoading] = useState(true) - - const actions = [ - { - label: 'Open in Logs Explorer', - onClick: () => { - alert('Ta da! You clicked me! 🎉') - }, - icon: , - }, - ] - - const data = Array.from({ length: 40 }, (_, i) => { - const date = new Date() - date.setMinutes(date.getMinutes() - i * 3) // Each point 3 minutes apart - - const progress = i / 40 - const standard_score = Math.floor(55 + progress * 55 + (Math.random() - 0.5) * 12) - const performance = Math.floor(35 + progress * 35 + (Math.random() - 0.5) * 10) - const efficiency = Math.floor(25 + progress * 25 + (Math.random() - 0.5) * 12) - - return { - timestamp: date.toISOString(), - standard_score: Math.max(0, Math.min(100, standard_score)), - performance: Math.max(0, Math.min(100, performance)), - efficiency: Math.max(0, Math.min(100, efficiency)), - } - }).reverse() - - const chartConfig = { - standard_score: { - label: 'Standard Score', - color: 'hsl(var(--brand-default))', - }, - performance: { - label: 'Performance', - color: 'hsl(var(--chart-2))', - }, - efficiency: { - label: 'Efficiency', - color: 'hsl(var(--chart-5))', - }, - } - - useEffect(() => { - setTimeout(() => { - setIsLoading(false) - }, 1000) - }, []) - - return ( -
- - - - Standard Bar Chart - - - } - title="No data to show" - description="It may take up to 24 hours for data to refresh" - /> - } - loadingState={} - > -
- `${value}k`, - width: 80, - }} - isFullHeight={true} - /> -
-
-
-
- - - - - Standard Line Chart - - - } - title="No data to show" - description="It may take up to 24 hours for data to refresh" - /> - } - loadingState={} - > -
- `${value}k`, - width: 80, - }} - isFullHeight={true} - /> -
-
-
-
-
- ) -} diff --git a/apps/design-system/__registry__/default/block/chart-composed-demo.tsx b/apps/design-system/__registry__/default/block/chart-composed-demo.tsx deleted file mode 100644 index 88b131af9254d..0000000000000 --- a/apps/design-system/__registry__/default/block/chart-composed-demo.tsx +++ /dev/null @@ -1,73 +0,0 @@ -'use client' - -import { BarChart2 } from 'lucide-react' -import { useEffect, useState } from 'react' -import { - Chart, - ChartCard, - ChartContent, - ChartEmptyState, - ChartHeader, - ChartLoadingState, - ChartMetric, -} from 'ui-patterns/Chart' -import { LogsBarChart } from 'ui-patterns/LogsBarChart' - -export default function ComposedChartDemo() { - const [isLoading, setIsLoading] = useState(true) - - const data = Array.from({ length: 60 }, (_, i) => { - const date = new Date() - date.setMinutes(date.getMinutes() - i * 5) // Each point 5 minutes apart - - return { - timestamp: date.toISOString(), - ok_count: Math.floor(Math.random() * 100), // Random value 0-99 - error_count: Math.floor(Math.random() * 50), // Random value 0-50 - warning_count: Math.floor(Math.random() * 50), // Random value 0-50 - } - }).reverse() - - const totalUsersValue = 4663 - - useEffect(() => { - setTimeout(() => { - setIsLoading(false) - }, 1000) - }, []) - - return ( -
- - - - -
- - -
-
- } - title="No data to show" - description="It may take up to 24 hours for data to refresh" - /> - } - loadingState={} - > -
- -
-
-
-
-
- ) -} diff --git a/apps/design-system/__registry__/default/block/chart-composed-metrics.tsx b/apps/design-system/__registry__/default/block/chart-composed-metrics.tsx deleted file mode 100644 index deab28e4ee23a..0000000000000 --- a/apps/design-system/__registry__/default/block/chart-composed-metrics.tsx +++ /dev/null @@ -1,100 +0,0 @@ -'use client' - -import { ExternalLink } from 'lucide-react' -import { useEffect, useState } from 'react' -import { Skeleton } from 'ui' -import { - Chart, - ChartActions, - ChartCard, - ChartContent, - ChartHeader, - ChartMetric, - ChartSparkline, -} from 'ui-patterns/Chart' - -export default function ChartComposedMetrics() { - const [data, setData] = useState>([]) - - const actions = [ - { - label: 'Open in Logs Explorer', - onClick: () => { - alert('Ta da! You clicked me! 🎉') - }, - icon: , - }, - ] - - useEffect(() => { - const now = new Date() - setData( - Array.from({ length: 12 }, (_, i) => ({ - value: Math.floor(4000 + i * 100 + (Math.random() * 2000 - 800)), - timestamp: new Date(now.getTime() - (11 - i) * 60 * 60 * 1000).toISOString(), - })) - ) - }, []) - - const averageValue = data.reduce((acc, curr) => acc + curr.value, 0) / data.length - - const diff = data[data.length - 1]?.value - data[0]?.value || 0 - const diffPercentage = (diff / averageValue) * 100 - - return ( -
- - - - - - - - - - - - - - - - - - - - - - - } - > - - - - - - - - - - - } - > - - - - -
- ) -} diff --git a/apps/design-system/__registry__/default/block/chart-composed-states.tsx b/apps/design-system/__registry__/default/block/chart-composed-states.tsx deleted file mode 100644 index 2d3657cca2c72..0000000000000 --- a/apps/design-system/__registry__/default/block/chart-composed-states.tsx +++ /dev/null @@ -1,121 +0,0 @@ -'use client' - -import { BarChart2, ExternalLink } from 'lucide-react' -import { Badge, CriticalIcon } from 'ui' -import { - Chart, - ChartActions, - ChartCard, - ChartContent, - ChartDisabledState, - ChartEmptyState, - ChartHeader, - ChartLoadingState, - ChartTitle, -} from 'ui-patterns/Chart' - -export default function ChartComposedStates() { - const actions = [ - { - label: 'Open in Logs Explorer', - onClick: () => { - alert('Ta da! You clicked me! 🎉') - }, - icon: , - }, - ] - - const disabledActions = [ - { - label: 'Upgrade to Pro', - href: '#', - }, - ] - - return ( -
- - - - Response Errors - - - }>My chart here... - - - - - - - Response Errors - - - } - title="Some error happened" - description="Error happened why trying to fetch data. Please try again later." - /> - } - emptyState={ - } - title="No data to show" - description="It may take up to 24 hours for data to refresh" - /> - } - loadingState={} - > - My chart here... - - - - - - - - Response Errors - - - } - title="No data to show" - description="It may take up to 24 hours for data to refresh" - /> - } - loadingState={} - > - My chart here... - - - - - - - - Response Errors - - - } - disabledState={ - Pro} - label="API Processing Time" - description="This chart is available on the Pro plan and above" - actions={disabledActions} - /> - } - > - My chart here... - - - -
- ) -} diff --git a/apps/design-system/__registry__/default/block/chart-composed-table.tsx b/apps/design-system/__registry__/default/block/chart-composed-table.tsx deleted file mode 100644 index a8089ed1f57d0..0000000000000 --- a/apps/design-system/__registry__/default/block/chart-composed-table.tsx +++ /dev/null @@ -1,106 +0,0 @@ -'use client' - -import { format } from 'date-fns' -import { BarChart2, ExternalLink } from 'lucide-react' -import { useEffect, useState } from 'react' -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui' -import { - Chart, - ChartActions, - ChartBar, - ChartCard, - ChartContent, - ChartEmptyState, - ChartFooter, - ChartHeader, - ChartLoadingState, - ChartTitle, -} from 'ui-patterns/Chart' - -export default function ChartComposedTable() { - const [isLoading, setIsLoading] = useState(true) - - const actions = [ - { - label: 'Open in Logs Explorer', - onClick: () => { - alert('Opening in Logs Explorer...') - }, - icon: , - }, - ] - - const data = Array.from({ length: 46 }, (_, i) => { - const date = new Date() - date.setMinutes(date.getMinutes() - i * 5) // Each point 5 minutes apart - - return { - timestamp: date.toISOString(), - standard_score: Math.floor(Math.random() * 100), - } - }).reverse() - - useEffect(() => { - setTimeout(() => { - setIsLoading(false) - }, 1000) - }, []) - - return ( -
- - - - Standard Bar Chart - - - } - title="No data to show" - description="It may take up to 24 hours for data to refresh" - /> - } - loadingState={} - > -
- `${value}k`, - width: 80, - }} - isFullHeight={true} - /> -
-
- - - - - Timestamp - Standard Score - - - - {data.slice(0, 3).map((item) => ( - - - {format(new Date(item.timestamp), 'MMM d, yyyy h:mm a')} - - {item.standard_score} - - ))} - -
-
-
-
-
- ) -} diff --git a/apps/design-system/__registry__/index.tsx b/apps/design-system/__registry__/index.tsx deleted file mode 100644 index 7e9ec446ef2e0..0000000000000 --- a/apps/design-system/__registry__/index.tsx +++ /dev/null @@ -1,3738 +0,0 @@ -// @ts-nocheck -// This file is autogenerated by scripts/build-registry.ts -// Do not edit this file directly. -import * as React from "react" - -export const Index: Record = { - "default": { - "CollapsibleCardSection": { - name: "CollapsibleCardSection", - type: "components:fragment", - registryDependencies: undefined, - component: React.lazy(() => import("@/../../packages/ui-patterns/src/CollapsibleCardSection")), - source: "", - files: ["registry/default//CollapsibleCardSection.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "ConfirmationModal": { - name: "ConfirmationModal", - type: "components:fragment", - registryDependencies: undefined, - component: React.lazy(() => import("@/../../packages/ui-patterns/src/Dialogs/ConfirmationModal")), - source: "", - files: ["registry/default//Dialogs/ConfirmationModal.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "EmptyStatePresentational": { - name: "EmptyStatePresentational", - type: "components:fragment", - registryDependencies: undefined, - component: React.lazy(() => import("@/../../packages/ui-patterns/src/EmptyStatePresentational")), - source: "", - files: ["registry/default//EmptyStatePresentational/index.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "TextConfirmModal": { - name: "TextConfirmModal", - type: "components:fragment", - registryDependencies: undefined, - component: React.lazy(() => import("@/../../packages/ui-patterns/src/Dialogs/TextConfirmModal")), - source: "", - files: ["registry/default//Dialogs/TextConfirmModal.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "PageContainer": { - name: "PageContainer", - type: "components:fragment", - registryDependencies: undefined, - component: React.lazy(() => import("@/../../packages/ui-patterns/src/PageContainer")), - source: "", - files: ["registry/default//PageContainer/index.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "PageHeader": { - name: "PageHeader", - type: "components:fragment", - registryDependencies: undefined, - component: React.lazy(() => import("@/../../packages/ui-patterns/src/PageHeader")), - source: "", - files: ["registry/default//PageHeader/index.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "PageBreadcrumbs": { - name: "PageBreadcrumbs", - type: "components:fragment", - registryDependencies: undefined, - component: React.lazy(() => import("@/../../packages/ui-patterns/src/PageBreadcrumbs")), - source: "", - files: ["registry/default//PageBreadcrumbs/index.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "PageNav": { - name: "PageNav", - type: "components:fragment", - registryDependencies: undefined, - component: React.lazy(() => import("@/../../packages/ui-patterns/src/PageNav")), - source: "", - files: ["registry/default//PageNav/index.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "PageSection": { - name: "PageSection", - type: "components:fragment", - registryDependencies: undefined, - component: React.lazy(() => import("@/../../packages/ui-patterns/src/PageSection")), - source: "", - files: ["registry/default//PageSection/index.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "StatusCode": { - name: "StatusCode", - type: "components:fragment", - registryDependencies: undefined, - component: React.lazy(() => import("@/../../packages/ui-patterns/src/StatusCode")), - source: "", - files: ["registry/default//StatusCode/index.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "accordion-demo": { - name: "accordion-demo", - type: "components:example", - registryDependencies: ["accordion"], - component: React.lazy(() => import("@/registry/default/example/accordion-demo")), - source: "", - files: ["registry/default/example/accordion-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "admonition-demo": { - name: "admonition-demo", - type: "components:example", - registryDependencies: ["admonition"], - component: React.lazy(() => import("@/registry/default/example/admonition-demo")), - source: "", - files: ["registry/default/example/admonition-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "admonition-responsive": { - name: "admonition-responsive", - type: "components:example", - registryDependencies: ["admonition"], - component: React.lazy(() => import("@/registry/default/example/admonition-responsive")), - source: "", - files: ["registry/default/example/admonition-responsive.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "admonition-button": { - name: "admonition-button", - type: "components:example", - registryDependencies: ["admonition"], - component: React.lazy(() => import("@/registry/default/example/admonition-button")), - source: "", - files: ["registry/default/example/admonition-button.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "admonition-button-split": { - name: "admonition-button-split", - type: "components:example", - registryDependencies: ["admonition","button","dropdown-menu"], - component: React.lazy(() => import("@/registry/default/example/admonition-button-split")), - source: "", - files: ["registry/default/example/admonition-button-split.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "admonition-description-only": { - name: "admonition-description-only", - type: "components:example", - registryDependencies: ["admonition"], - component: React.lazy(() => import("@/registry/default/example/admonition-description-only")), - source: "", - files: ["registry/default/example/admonition-description-only.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "admonition-warning": { - name: "admonition-warning", - type: "components:example", - registryDependencies: ["admonition"], - component: React.lazy(() => import("@/registry/default/example/admonition-warning")), - source: "", - files: ["registry/default/example/admonition-warning.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "admonition-success": { - name: "admonition-success", - type: "components:example", - registryDependencies: ["admonition"], - component: React.lazy(() => import("@/registry/default/example/admonition-success")), - source: "", - files: ["registry/default/example/admonition-success.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "admonition-destructive": { - name: "admonition-destructive", - type: "components:example", - registryDependencies: ["admonition"], - component: React.lazy(() => import("@/registry/default/example/admonition-destructive")), - source: "", - files: ["registry/default/example/admonition-destructive.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "admonition-sandwiched": { - name: "admonition-sandwiched", - type: "components:example", - registryDependencies: ["admonition"], - component: React.lazy(() => import("@/registry/default/example/admonition-sandwiched")), - source: "", - files: ["registry/default/example/admonition-sandwiched.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "alert-demo": { - name: "alert-demo", - type: "components:example", - registryDependencies: ["alert"], - component: React.lazy(() => import("@/registry/default/example/alert-demo")), - source: "", - files: ["registry/default/example/alert-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "collapsible-alert-demo": { - name: "collapsible-alert-demo", - type: "components:example", - registryDependencies: ["collapsible-alert"], - component: React.lazy(() => import("@/registry/default/example/collapsible-alert-demo")), - source: "", - files: ["registry/default/example/collapsible-alert-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "collapsible-card-section-demo": { - name: "collapsible-card-section-demo", - type: "components:example", - registryDependencies: ["collapsible-card-section"], - component: React.lazy(() => import("@/registry/default/example/collapsible-card-section-demo")), - source: "", - files: ["registry/default/example/collapsible-card-section-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "alert-dialog-demo": { - name: "alert-dialog-demo", - type: "components:example", - registryDependencies: ["alert-dialog","button"], - component: React.lazy(() => import("@/registry/default/example/alert-dialog-demo")), - source: "", - files: ["registry/default/example/alert-dialog-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "alert-dialog-close-only": { - name: "alert-dialog-close-only", - type: "components:example", - registryDependencies: ["alert-dialog","button"], - component: React.lazy(() => import("@/registry/default/example/alert-dialog-close-only")), - source: "", - files: ["registry/default/example/alert-dialog-close-only.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "alert-dialog-async": { - name: "alert-dialog-async", - type: "components:example", - registryDependencies: ["alert-dialog","button"], - component: React.lazy(() => import("@/registry/default/example/alert-dialog-async")), - source: "", - files: ["registry/default/example/alert-dialog-async.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "alert-dialog-async-error": { - name: "alert-dialog-async-error", - type: "components:example", - registryDependencies: ["alert-dialog","button","admonition"], - component: React.lazy(() => import("@/registry/default/example/alert-dialog-async-error")), - source: "", - files: ["registry/default/example/alert-dialog-async-error.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "alert-dialog-destructive": { - name: "alert-dialog-destructive", - type: "components:example", - registryDependencies: ["alert-dialog","button"], - component: React.lazy(() => import("@/registry/default/example/alert-dialog-destructive")), - source: "", - files: ["registry/default/example/alert-dialog-destructive.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "alert-dialog-warning": { - name: "alert-dialog-warning", - type: "components:example", - registryDependencies: ["alert-dialog","button"], - component: React.lazy(() => import("@/registry/default/example/alert-dialog-warning")), - source: "", - files: ["registry/default/example/alert-dialog-warning.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "aspect-ratio-demo": { - name: "aspect-ratio-demo", - type: "components:example", - registryDependencies: ["aspect-ratio"], - component: React.lazy(() => import("@/registry/default/example/aspect-ratio-demo")), - source: "", - files: ["registry/default/example/aspect-ratio-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "avatar-demo": { - name: "avatar-demo", - type: "components:example", - registryDependencies: ["avatar"], - component: React.lazy(() => import("@/registry/default/example/avatar-demo")), - source: "", - files: ["registry/default/example/avatar-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "badge-demo": { - name: "badge-demo", - type: "components:example", - registryDependencies: ["badge"], - component: React.lazy(() => import("@/registry/default/example/badge-demo")), - source: "", - files: ["registry/default/example/badge-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "badge-state": { - name: "badge-state", - type: "components:example", - registryDependencies: ["badge"], - component: React.lazy(() => import("@/registry/default/example/badge-state")), - source: "", - files: ["registry/default/example/badge-state.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "badge-secondary": { - name: "badge-secondary", - type: "components:example", - registryDependencies: ["badge"], - component: React.lazy(() => import("@/registry/default/example/badge-secondary")), - source: "", - files: ["registry/default/example/badge-secondary.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "breadcrumb-demo": { - name: "breadcrumb-demo", - type: "components:example", - registryDependencies: ["breadcrumb"], - component: React.lazy(() => import("@/registry/default/example/breadcrumb-demo")), - source: "", - files: ["registry/default/example/breadcrumb-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "breadcrumb-separator": { - name: "breadcrumb-separator", - type: "components:example", - registryDependencies: ["breadcrumb"], - component: React.lazy(() => import("@/registry/default/example/breadcrumb-separator")), - source: "", - files: ["registry/default/example/breadcrumb-separator.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "breadcrumb-dropdown": { - name: "breadcrumb-dropdown", - type: "components:example", - registryDependencies: ["breadcrumb","dropdown-menu"], - component: React.lazy(() => import("@/registry/default/example/breadcrumb-dropdown")), - source: "", - files: ["registry/default/example/breadcrumb-dropdown.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "breadcrumb-ellipsis": { - name: "breadcrumb-ellipsis", - type: "components:example", - registryDependencies: ["breadcrumb"], - component: React.lazy(() => import("@/registry/default/example/breadcrumb-ellipsis")), - source: "", - files: ["registry/default/example/breadcrumb-ellipsis.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "breadcrumb-link": { - name: "breadcrumb-link", - type: "components:example", - registryDependencies: ["breadcrumb"], - component: React.lazy(() => import("@/registry/default/example/breadcrumb-link")), - source: "", - files: ["registry/default/example/breadcrumb-link.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "breadcrumb-responsive": { - name: "breadcrumb-responsive", - type: "components:example", - registryDependencies: ["breadcrumb","button","drawer","dropdown-menu"], - component: React.lazy(() => import("@/registry/default/example/breadcrumb-responsive")), - source: "", - files: ["registry/default/example/breadcrumb-responsive.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "button-demo": { - name: "button-demo", - type: "components:example", - registryDependencies: ["button"], - component: React.lazy(() => import("@/registry/default/example/button-demo")), - source: "", - files: ["registry/default/example/button-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "button-sizes": { - name: "button-sizes", - type: "components:example", - registryDependencies: ["button"], - component: React.lazy(() => import("@/registry/default/example/button-sizes")), - source: "", - files: ["registry/default/example/button-sizes.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "button-default": { - name: "button-default", - type: "components:example", - registryDependencies: ["button"], - component: React.lazy(() => import("@/registry/default/example/button-default")), - source: "", - files: ["registry/default/example/button-default.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "button-warning": { - name: "button-warning", - type: "components:example", - registryDependencies: ["button"], - component: React.lazy(() => import("@/registry/default/example/button-warning")), - source: "", - files: ["registry/default/example/button-warning.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "button-secondary": { - name: "button-secondary", - type: "components:example", - registryDependencies: ["button"], - component: React.lazy(() => import("@/registry/default/example/button-secondary")), - source: "", - files: ["registry/default/example/button-secondary.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "button-destructive": { - name: "button-destructive", - type: "components:example", - registryDependencies: ["button"], - component: React.lazy(() => import("@/registry/default/example/button-destructive")), - source: "", - files: ["registry/default/example/button-destructive.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "button-outline": { - name: "button-outline", - type: "components:example", - registryDependencies: ["button"], - component: React.lazy(() => import("@/registry/default/example/button-outline")), - source: "", - files: ["registry/default/example/button-outline.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "button-ghost": { - name: "button-ghost", - type: "components:example", - registryDependencies: ["button"], - component: React.lazy(() => import("@/registry/default/example/button-ghost")), - source: "", - files: ["registry/default/example/button-ghost.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "button-link": { - name: "button-link", - type: "components:example", - registryDependencies: ["button"], - component: React.lazy(() => import("@/registry/default/example/button-link")), - source: "", - files: ["registry/default/example/button-link.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "button-with-icon": { - name: "button-with-icon", - type: "components:example", - registryDependencies: ["button"], - component: React.lazy(() => import("@/registry/default/example/button-with-icon")), - source: "", - files: ["registry/default/example/button-with-icon.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "button-loading": { - name: "button-loading", - type: "components:example", - registryDependencies: ["button"], - component: React.lazy(() => import("@/registry/default/example/button-loading")), - source: "", - files: ["registry/default/example/button-loading.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "button-icon": { - name: "button-icon", - type: "components:example", - registryDependencies: ["button"], - component: React.lazy(() => import("@/registry/default/example/button-icon")), - source: "", - files: ["registry/default/example/button-icon.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "button-as-child": { - name: "button-as-child", - type: "components:example", - registryDependencies: ["button"], - component: React.lazy(() => import("@/registry/default/example/button-as-child")), - source: "", - files: ["registry/default/example/button-as-child.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "button-split-dropdown": { - name: "button-split-dropdown", - type: "components:example", - registryDependencies: ["button","dropdown-menu"], - component: React.lazy(() => import("@/registry/default/example/button-split-dropdown")), - source: "", - files: ["registry/default/example/button-split-dropdown.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "calendar-demo": { - name: "calendar-demo", - type: "components:example", - registryDependencies: ["calendar"], - component: React.lazy(() => import("@/registry/default/example/calendar-demo")), - source: "", - files: ["registry/default/example/calendar-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "calendar-form": { - name: "calendar-form", - type: "components:example", - registryDependencies: ["calendar","form","popover"], - component: React.lazy(() => import("@/registry/default/example/calendar-form")), - source: "", - files: ["registry/default/example/calendar-form.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "calendar-disabled-days-demo": { - name: "calendar-disabled-days-demo", - type: "components:example", - registryDependencies: ["calendar","form","popover"], - component: React.lazy(() => import("@/registry/default/example/calendar-disabled-days-demo")), - source: "", - files: ["registry/default/example/calendar-disabled-days-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "single-value-field-array-demo": { - name: "single-value-field-array-demo", - type: "components:example", - registryDependencies: ["button","form","input"], - component: React.lazy(() => import("@/registry/default/example/single-value-field-array-demo")), - source: "", - files: ["registry/default/example/single-value-field-array-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "checkbox-demo": { - name: "checkbox-demo", - type: "components:example", - registryDependencies: ["checkbox"], - component: React.lazy(() => import("@/registry/default/example/checkbox-demo")), - source: "", - files: ["registry/default/example/checkbox-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "checkbox-disabled": { - name: "checkbox-disabled", - type: "components:example", - registryDependencies: ["checkbox"], - component: React.lazy(() => import("@/registry/default/example/checkbox-disabled")), - source: "", - files: ["registry/default/example/checkbox-disabled.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "checkbox-form-multiple": { - name: "checkbox-form-multiple", - type: "components:example", - registryDependencies: ["checkbox","form"], - component: React.lazy(() => import("@/registry/default/example/checkbox-form-multiple")), - source: "", - files: ["registry/default/example/checkbox-form-multiple.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "checkbox-form-single": { - name: "checkbox-form-single", - type: "components:example", - registryDependencies: ["checkbox","form"], - component: React.lazy(() => import("@/registry/default/example/checkbox-form-single")), - source: "", - files: ["registry/default/example/checkbox-form-single.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "checkbox-with-text": { - name: "checkbox-with-text", - type: "components:example", - registryDependencies: ["checkbox"], - component: React.lazy(() => import("@/registry/default/example/checkbox-with-text")), - source: "", - files: ["registry/default/example/checkbox-with-text.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "collapsible-demo": { - name: "collapsible-demo", - type: "components:example", - registryDependencies: ["collapsible"], - component: React.lazy(() => import("@/registry/default/example/collapsible-demo")), - source: "", - files: ["registry/default/example/collapsible-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "combobox-demo": { - name: "combobox-demo", - type: "components:example", - registryDependencies: ["command"], - component: React.lazy(() => import("@/registry/default/example/combobox-demo")), - source: "", - files: ["registry/default/example/combobox-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "combobox-dropdown-menu": { - name: "combobox-dropdown-menu", - type: "components:example", - registryDependencies: ["command","dropdown-menu","button"], - component: React.lazy(() => import("@/registry/default/example/combobox-dropdown-menu")), - source: "", - files: ["registry/default/example/combobox-dropdown-menu.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "combobox-form": { - name: "combobox-form", - type: "components:example", - registryDependencies: ["command","form"], - component: React.lazy(() => import("@/registry/default/example/combobox-form")), - source: "", - files: ["registry/default/example/combobox-form.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "combobox-popover": { - name: "combobox-popover", - type: "components:example", - registryDependencies: ["combobox","popover"], - component: React.lazy(() => import("@/registry/default/example/combobox-popover")), - source: "", - files: ["registry/default/example/combobox-popover.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "combobox-responsive": { - name: "combobox-responsive", - type: "components:example", - registryDependencies: ["combobox","popover","drawer"], - component: React.lazy(() => import("@/registry/default/example/combobox-responsive")), - source: "", - files: ["registry/default/example/combobox-responsive.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "command-demo": { - name: "command-demo", - type: "components:example", - registryDependencies: ["command"], - component: React.lazy(() => import("@/registry/default/example/command-demo")), - source: "", - files: ["registry/default/example/command-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "command-dialog": { - name: "command-dialog", - type: "components:example", - registryDependencies: ["command","dialog"], - component: React.lazy(() => import("@/registry/default/example/command-dialog")), - source: "", - files: ["registry/default/example/command-dialog.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "commandmenu-demo": { - name: "commandmenu-demo", - type: "components:example", - registryDependencies: ["commandmenu"], - component: React.lazy(() => import("@/registry/default/example/commandmenu-demo")), - source: "", - files: ["registry/default/example/commandmenu-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "commandmenu-badge": { - name: "commandmenu-badge", - type: "components:example", - registryDependencies: ["commandmenu"], - component: React.lazy(() => import("@/registry/default/example/commandmenu-badge")), - source: "", - files: ["registry/default/example/commandmenu-badge.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "commandmenu-icon": { - name: "commandmenu-icon", - type: "components:example", - registryDependencies: ["commandmenu"], - component: React.lazy(() => import("@/registry/default/example/commandmenu-icon")), - source: "", - files: ["registry/default/example/commandmenu-icon.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "commandmenu-hidden": { - name: "commandmenu-hidden", - type: "components:example", - registryDependencies: ["commandmenu"], - component: React.lazy(() => import("@/registry/default/example/commandmenu-hidden")), - source: "", - files: ["registry/default/example/commandmenu-hidden.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "commandmenu-force": { - name: "commandmenu-force", - type: "components:example", - registryDependencies: ["commandmenu"], - component: React.lazy(() => import("@/registry/default/example/commandmenu-force")), - source: "", - files: ["registry/default/example/commandmenu-force.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "commandmenu-conditional": { - name: "commandmenu-conditional", - type: "components:example", - registryDependencies: ["commandmenu"], - component: React.lazy(() => import("@/registry/default/example/commandmenu-conditional")), - source: "", - files: ["registry/default/example/commandmenu-conditional.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "commandmenu-subpage": { - name: "commandmenu-subpage", - type: "components:example", - registryDependencies: ["commandmenu"], - component: React.lazy(() => import("@/registry/default/example/commandmenu-subpage")), - source: "", - files: ["registry/default/example/commandmenu-subpage.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "commandmenu-subpage-custom": { - name: "commandmenu-subpage-custom", - type: "components:example", - registryDependencies: ["commandmenu"], - component: React.lazy(() => import("@/registry/default/example/commandmenu-subpage-custom")), - source: "", - files: ["registry/default/example/commandmenu-subpage-custom.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "context-menu-demo": { - name: "context-menu-demo", - type: "components:example", - registryDependencies: ["context-menu"], - component: React.lazy(() => import("@/registry/default/example/context-menu-demo")), - source: "", - files: ["registry/default/example/context-menu-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "data-input-demo": { - name: "data-input-demo", - type: "components:example", - registryDependencies: ["data-input"], - component: React.lazy(() => import("@/registry/default/example/data-input-demo")), - source: "", - files: ["registry/default/example/data-input-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "data-input-with-copy": { - name: "data-input-with-copy", - type: "components:example", - registryDependencies: ["data-input"], - component: React.lazy(() => import("@/registry/default/example/data-input-with-copy")), - source: "", - files: ["registry/default/example/data-input-with-copy.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "data-input-with-copy-secret": { - name: "data-input-with-copy-secret", - type: "components:example", - registryDependencies: ["data-input"], - component: React.lazy(() => import("@/registry/default/example/data-input-with-copy-secret")), - source: "", - files: ["registry/default/example/data-input-with-copy-secret.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "data-input-with-reveal-copy": { - name: "data-input-with-reveal-copy", - type: "components:example", - registryDependencies: ["data-input"], - component: React.lazy(() => import("@/registry/default/example/data-input-with-reveal-copy")), - source: "", - files: ["registry/default/example/data-input-with-reveal-copy.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "data-input-with-reveal-copy-editable": { - name: "data-input-with-reveal-copy-editable", - type: "components:example", - registryDependencies: ["data-input"], - component: React.lazy(() => import("@/registry/default/example/data-input-with-reveal-copy-editable")), - source: "", - files: ["registry/default/example/data-input-with-reveal-copy-editable.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "data-input-with-reveal-copy-editable-empty": { - name: "data-input-with-reveal-copy-editable-empty", - type: "components:example", - registryDependencies: ["data-input"], - component: React.lazy(() => import("@/registry/default/example/data-input-with-reveal-copy-editable-empty")), - source: "", - files: ["registry/default/example/data-input-with-reveal-copy-editable-empty.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "date-picker-demo": { - name: "date-picker-demo", - type: "components:example", - registryDependencies: ["button","calendar","popover"], - component: React.lazy(() => import("@/registry/default/example/date-picker-demo")), - source: "", - files: ["registry/default/example/date-picker-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "date-picker-form": { - name: "date-picker-form", - type: "components:example", - registryDependencies: ["button","calendar","form","popover"], - component: React.lazy(() => import("@/registry/default/example/date-picker-form")), - source: "", - files: ["registry/default/example/date-picker-form.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "date-picker-with-presets": { - name: "date-picker-with-presets", - type: "components:example", - registryDependencies: ["button","calendar","popover","select"], - component: React.lazy(() => import("@/registry/default/example/date-picker-with-presets")), - source: "", - files: ["registry/default/example/date-picker-with-presets.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "date-picker-with-range": { - name: "date-picker-with-range", - type: "components:example", - registryDependencies: ["button","calendar","popover"], - component: React.lazy(() => import("@/registry/default/example/date-picker-with-range")), - source: "", - files: ["registry/default/example/date-picker-with-range.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "dialog-demo": { - name: "dialog-demo", - type: "components:example", - registryDependencies: ["dialog"], - component: React.lazy(() => import("@/registry/default/example/dialog-demo")), - source: "", - files: ["registry/default/example/dialog-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "dialog-close-button": { - name: "dialog-close-button", - type: "components:example", - registryDependencies: ["dialog","button"], - component: React.lazy(() => import("@/registry/default/example/dialog-close-button")), - source: "", - files: ["registry/default/example/dialog-close-button.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "dialog-centered-off": { - name: "dialog-centered-off", - type: "components:example", - registryDependencies: ["dialog","button"], - component: React.lazy(() => import("@/registry/default/example/dialog-centered-off")), - source: "", - files: ["registry/default/example/dialog-centered-off.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "drawer-demo": { - name: "drawer-demo", - type: "components:example", - registryDependencies: ["drawer"], - component: React.lazy(() => import("@/registry/default/example/drawer-demo")), - source: "", - files: ["registry/default/example/drawer-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "drawer-dialog": { - name: "drawer-dialog", - type: "components:example", - registryDependencies: ["drawer","dialog"], - component: React.lazy(() => import("@/registry/default/example/drawer-dialog")), - source: "", - files: ["registry/default/example/drawer-dialog.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "dropdown-menu-demo": { - name: "dropdown-menu-demo", - type: "components:example", - registryDependencies: ["dropdown-menu"], - component: React.lazy(() => import("@/registry/default/example/dropdown-menu-demo")), - source: "", - files: ["registry/default/example/dropdown-menu-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "dropdown-menu-checkboxes": { - name: "dropdown-menu-checkboxes", - type: "components:example", - registryDependencies: ["dropdown-menu","checkbox"], - component: React.lazy(() => import("@/registry/default/example/dropdown-menu-checkboxes")), - source: "", - files: ["registry/default/example/dropdown-menu-checkboxes.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "dropdown-menu-radio-group": { - name: "dropdown-menu-radio-group", - type: "components:example", - registryDependencies: ["dropdown-menu","radio-group"], - component: React.lazy(() => import("@/registry/default/example/dropdown-menu-radio-group")), - source: "", - files: ["registry/default/example/dropdown-menu-radio-group.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "filter-bar-demo": { - name: "filter-bar-demo", - type: "components:example", - registryDependencies: ["filter-bar"], - component: React.lazy(() => import("@/registry/default/example/filter-bar-demo")), - source: "", - files: ["registry/default/example/filter-bar-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "hover-card-demo": { - name: "hover-card-demo", - type: "components:example", - registryDependencies: ["hover-card"], - component: React.lazy(() => import("@/registry/default/example/hover-card-demo")), - source: "", - files: ["registry/default/example/hover-card-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "input-demo": { - name: "input-demo", - type: "components:example", - registryDependencies: ["input"], - component: React.lazy(() => import("@/registry/default/example/input-demo")), - source: "", - files: ["registry/default/example/input-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "input-disabled": { - name: "input-disabled", - type: "components:example", - registryDependencies: ["input"], - component: React.lazy(() => import("@/registry/default/example/input-disabled")), - source: "", - files: ["registry/default/example/input-disabled.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "input-file": { - name: "input-file", - type: "components:example", - registryDependencies: ["input"], - component: React.lazy(() => import("@/registry/default/example/input-file")), - source: "", - files: ["registry/default/example/input-file.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "input-form": { - name: "input-form", - type: "components:example", - registryDependencies: ["input","button","form"], - component: React.lazy(() => import("@/registry/default/example/input-form")), - source: "", - files: ["registry/default/example/input-form.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "input-with-button": { - name: "input-with-button", - type: "components:example", - registryDependencies: ["input","button"], - component: React.lazy(() => import("@/registry/default/example/input-with-button")), - source: "", - files: ["registry/default/example/input-with-button.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "input-with-label": { - name: "input-with-label", - type: "components:example", - registryDependencies: ["input","button","label"], - component: React.lazy(() => import("@/registry/default/example/input-with-label")), - source: "", - files: ["registry/default/example/input-with-label.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "input-with-text": { - name: "input-with-text", - type: "components:example", - registryDependencies: ["input","button","label"], - component: React.lazy(() => import("@/registry/default/example/input-with-text")), - source: "", - files: ["registry/default/example/input-with-text.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "input-otp-demo": { - name: "input-otp-demo", - type: "components:example", - registryDependencies: ["input-otp"], - component: React.lazy(() => import("@/registry/default/example/input-otp-demo")), - source: "", - files: ["registry/default/example/input-otp-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "input-otp-pattern": { - name: "input-otp-pattern", - type: "components:example", - registryDependencies: ["input-otp"], - component: React.lazy(() => import("@/registry/default/example/input-otp-pattern")), - source: "", - files: ["registry/default/example/input-otp-pattern.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "input-otp-separator": { - name: "input-otp-separator", - type: "components:example", - registryDependencies: ["input-otp"], - component: React.lazy(() => import("@/registry/default/example/input-otp-separator")), - source: "", - files: ["registry/default/example/input-otp-separator.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "input-otp-controlled": { - name: "input-otp-controlled", - type: "components:example", - registryDependencies: ["input-otp"], - component: React.lazy(() => import("@/registry/default/example/input-otp-controlled")), - source: "", - files: ["registry/default/example/input-otp-controlled.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "input-otp-form": { - name: "input-otp-form", - type: "components:example", - registryDependencies: ["input-otp","form"], - component: React.lazy(() => import("@/registry/default/example/input-otp-form")), - source: "", - files: ["registry/default/example/input-otp-form.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "keyboard-shortcut-demo": { - name: "keyboard-shortcut-demo", - type: "components:example", - registryDependencies: ["button"], - component: React.lazy(() => import("@/registry/default/example/keyboard-shortcut-demo")), - source: "", - files: ["registry/default/example/keyboard-shortcut-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "keyboard-shortcut-pill": { - name: "keyboard-shortcut-pill", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/keyboard-shortcut-pill")), - source: "", - files: ["registry/default/example/keyboard-shortcut-pill.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "keyboard-shortcut-inline": { - name: "keyboard-shortcut-inline", - type: "components:example", - registryDependencies: ["button"], - component: React.lazy(() => import("@/registry/default/example/keyboard-shortcut-inline")), - source: "", - files: ["registry/default/example/keyboard-shortcut-inline.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "label-demo": { - name: "label-demo", - type: "components:example", - registryDependencies: ["label"], - component: React.lazy(() => import("@/registry/default/example/label-demo")), - source: "", - files: ["registry/default/example/label-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "menubar-demo": { - name: "menubar-demo", - type: "components:example", - registryDependencies: ["menubar"], - component: React.lazy(() => import("@/registry/default/example/menubar-demo")), - source: "", - files: ["registry/default/example/menubar-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "navigation-menu-demo": { - name: "navigation-menu-demo", - type: "components:example", - registryDependencies: ["navigation-menu"], - component: React.lazy(() => import("@/registry/default/example/navigation-menu-demo")), - source: "", - files: ["registry/default/example/navigation-menu-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "navigation-menu-responsive": { - name: "navigation-menu-responsive", - type: "components:example", - registryDependencies: ["navigation-menu"], - component: React.lazy(() => import("@/registry/default/example/navigation-menu-responsive")), - source: "", - files: ["registry/default/example/navigation-menu-responsive.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "nav-menu-demo": { - name: "nav-menu-demo", - type: "components:example", - registryDependencies: ["nav-menu"], - component: React.lazy(() => import("@/registry/default/example/nav-menu-demo")), - source: "", - files: ["registry/default/example/nav-menu-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "nav-menu-badges": { - name: "nav-menu-badges", - type: "components:example", - registryDependencies: ["nav-menu"], - component: React.lazy(() => import("@/registry/default/example/nav-menu-badges")), - source: "", - files: ["registry/default/example/nav-menu-badges.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "nav-menu-icons": { - name: "nav-menu-icons", - type: "components:example", - registryDependencies: ["nav-menu"], - component: React.lazy(() => import("@/registry/default/example/nav-menu-icons")), - source: "", - files: ["registry/default/example/nav-menu-icons.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "popover-demo": { - name: "popover-demo", - type: "components:example", - registryDependencies: ["popover"], - component: React.lazy(() => import("@/registry/default/example/popover-demo")), - source: "", - files: ["registry/default/example/popover-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "progress-demo": { - name: "progress-demo", - type: "components:example", - registryDependencies: ["progress"], - component: React.lazy(() => import("@/registry/default/example/progress-demo")), - source: "", - files: ["registry/default/example/progress-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "radio-group-demo": { - name: "radio-group-demo", - type: "components:example", - registryDependencies: ["radio-group"], - component: React.lazy(() => import("@/registry/default/example/radio-group-demo")), - source: "", - files: ["registry/default/example/radio-group-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "radio-group-form": { - name: "radio-group-form", - type: "components:example", - registryDependencies: ["radio-group","form"], - component: React.lazy(() => import("@/registry/default/example/radio-group-form")), - source: "", - files: ["registry/default/example/radio-group-form.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "radio-group-stacked-demo": { - name: "radio-group-stacked-demo", - type: "components:example", - registryDependencies: ["radio-group"], - component: React.lazy(() => import("@/registry/default/example/radio-group-stacked-demo")), - source: "", - files: ["registry/default/example/radio-group-stacked-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "radio-group-stacked-form": { - name: "radio-group-stacked-form", - type: "components:example", - registryDependencies: ["radio-group","form"], - component: React.lazy(() => import("@/registry/default/example/radio-group-stacked-form")), - source: "", - files: ["registry/default/example/radio-group-stacked-form.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "radio-group-card-demo": { - name: "radio-group-card-demo", - type: "components:example", - registryDependencies: ["radio-group"], - component: React.lazy(() => import("@/registry/default/example/radio-group-card-demo")), - source: "", - files: ["registry/default/example/radio-group-card-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "radio-group-card-form": { - name: "radio-group-card-form", - type: "components:example", - registryDependencies: ["radio-group","form"], - component: React.lazy(() => import("@/registry/default/example/radio-group-card-form")), - source: "", - files: ["registry/default/example/radio-group-card-form.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "radio-group-card-with-children": { - name: "radio-group-card-with-children", - type: "components:example", - registryDependencies: ["radio-group","form"], - component: React.lazy(() => import("@/registry/default/example/radio-group-card-with-children")), - source: "", - files: ["registry/default/example/radio-group-card-with-children.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "resizable-demo": { - name: "resizable-demo", - type: "components:example", - registryDependencies: ["resizable"], - component: React.lazy(() => import("@/registry/default/example/resizable-demo")), - source: "", - files: ["registry/default/example/resizable-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "resizable-demo-with-handle": { - name: "resizable-demo-with-handle", - type: "components:example", - registryDependencies: ["resizable"], - component: React.lazy(() => import("@/registry/default/example/resizable-demo-with-handle")), - source: "", - files: ["registry/default/example/resizable-demo-with-handle.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "resizable-vertical": { - name: "resizable-vertical", - type: "components:example", - registryDependencies: ["resizable"], - component: React.lazy(() => import("@/registry/default/example/resizable-vertical")), - source: "", - files: ["registry/default/example/resizable-vertical.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "resizable-handle": { - name: "resizable-handle", - type: "components:example", - registryDependencies: ["resizable"], - component: React.lazy(() => import("@/registry/default/example/resizable-handle")), - source: "", - files: ["registry/default/example/resizable-handle.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "scroll-area-demo": { - name: "scroll-area-demo", - type: "components:example", - registryDependencies: ["scroll-area"], - component: React.lazy(() => import("@/registry/default/example/scroll-area-demo")), - source: "", - files: ["registry/default/example/scroll-area-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "scroll-area-horizontal-demo": { - name: "scroll-area-horizontal-demo", - type: "components:example", - registryDependencies: ["scroll-area"], - component: React.lazy(() => import("@/registry/default/example/scroll-area-horizontal-demo")), - source: "", - files: ["registry/default/example/scroll-area-horizontal-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "select-demo": { - name: "select-demo", - type: "components:example", - registryDependencies: ["select"], - component: React.lazy(() => import("@/registry/default/example/select-demo")), - source: "", - files: ["registry/default/example/select-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "select-scrollable": { - name: "select-scrollable", - type: "components:example", - registryDependencies: ["select"], - component: React.lazy(() => import("@/registry/default/example/select-scrollable")), - source: "", - files: ["registry/default/example/select-scrollable.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "select-form": { - name: "select-form", - type: "components:example", - registryDependencies: ["select"], - component: React.lazy(() => import("@/registry/default/example/select-form")), - source: "", - files: ["registry/default/example/select-form.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "separator-demo": { - name: "separator-demo", - type: "components:example", - registryDependencies: ["separator"], - component: React.lazy(() => import("@/registry/default/example/separator-demo")), - source: "", - files: ["registry/default/example/separator-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "sheet-confirm-on-close-demo": { - name: "sheet-confirm-on-close-demo", - type: "components:example", - registryDependencies: ["alert-dialog","button","input","label","separator","sheet"], - component: React.lazy(() => import("@/registry/default/example/sheet-confirm-on-close-demo")), - source: "", - files: ["registry/default/example/sheet-confirm-on-close-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "sheet-demo": { - name: "sheet-demo", - type: "components:example", - registryDependencies: ["sheet"], - component: React.lazy(() => import("@/registry/default/example/sheet-demo")), - source: "", - files: ["registry/default/example/sheet-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "sheet-nonmodal": { - name: "sheet-nonmodal", - type: "components:example", - registryDependencies: ["sheet"], - component: React.lazy(() => import("@/registry/default/example/sheet-nonmodal")), - source: "", - files: ["registry/default/example/sheet-nonmodal.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "sheet-side": { - name: "sheet-side", - type: "components:example", - registryDependencies: ["sheet"], - component: React.lazy(() => import("@/registry/default/example/sheet-side")), - source: "", - files: ["registry/default/example/sheet-side.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "skeleton-demo": { - name: "skeleton-demo", - type: "components:example", - registryDependencies: ["skeleton"], - component: React.lazy(() => import("@/registry/default/example/skeleton-demo")), - source: "", - files: ["registry/default/example/skeleton-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "skeleton-card": { - name: "skeleton-card", - type: "components:example", - registryDependencies: ["skeleton"], - component: React.lazy(() => import("@/registry/default/example/skeleton-card")), - source: "", - files: ["registry/default/example/skeleton-card.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "slider-demo": { - name: "slider-demo", - type: "components:example", - registryDependencies: ["slider"], - component: React.lazy(() => import("@/registry/default/example/slider-demo")), - source: "", - files: ["registry/default/example/slider-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "slider-minMax-range": { - name: "slider-minMax-range", - type: "components:example", - registryDependencies: ["slider"], - component: React.lazy(() => import("@/registry/default/example/slider-minMax-range")), - source: "", - files: ["registry/default/example/slider-minMax-range.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "sonner-demo": { - name: "sonner-demo", - type: "components:example", - registryDependencies: ["sonner"], - component: React.lazy(() => import("@/registry/default/example/sonner-demo")), - source: "", - files: ["registry/default/example/sonner-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "sonner-types": { - name: "sonner-types", - type: "components:example", - registryDependencies: ["sonner"], - component: React.lazy(() => import("@/registry/default/example/sonner-types")), - source: "", - files: ["registry/default/example/sonner-types.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "sonner-upload": { - name: "sonner-upload", - type: "components:example", - registryDependencies: ["sonner"], - component: React.lazy(() => import("@/registry/default/example/sonner-upload")), - source: "", - files: ["registry/default/example/sonner-upload.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "success-check-demo": { - name: "success-check-demo", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/success-check-demo")), - source: "", - files: ["registry/default/example/success-check-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "success-check-selected": { - name: "success-check-selected", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/success-check-selected")), - source: "", - files: ["registry/default/example/success-check-selected.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "success-check-progress": { - name: "success-check-progress", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/success-check-progress")), - source: "", - files: ["registry/default/example/success-check-progress.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "switch-demo": { - name: "switch-demo", - type: "components:example", - registryDependencies: ["switch"], - component: React.lazy(() => import("@/registry/default/example/switch-demo")), - source: "", - files: ["registry/default/example/switch-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "switch-form": { - name: "switch-form", - type: "components:example", - registryDependencies: ["switch","form"], - component: React.lazy(() => import("@/registry/default/example/switch-form")), - source: "", - files: ["registry/default/example/switch-form.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "table-cross-link": { - name: "table-cross-link", - type: "components:example", - registryDependencies: ["table"], - component: React.lazy(() => import("@/registry/default/example/table-cross-link")), - source: "", - files: ["registry/default/example/table-cross-link.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "table-demo": { - name: "table-demo", - type: "components:example", - registryDependencies: ["table"], - component: React.lazy(() => import("@/registry/default/example/table-demo")), - source: "", - files: ["registry/default/example/table-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "table-actions": { - name: "table-actions", - type: "components:example", - registryDependencies: ["table"], - component: React.lazy(() => import("@/registry/default/example/table-actions")), - source: "", - files: ["registry/default/example/table-actions.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "table-icons": { - name: "table-icons", - type: "components:example", - registryDependencies: ["table"], - component: React.lazy(() => import("@/registry/default/example/table-icons")), - source: "", - files: ["registry/default/example/table-icons.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "table-row-link": { - name: "table-row-link", - type: "components:example", - registryDependencies: ["table"], - component: React.lazy(() => import("@/registry/default/example/table-row-link")), - source: "", - files: ["registry/default/example/table-row-link.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "table-row-link-actions": { - name: "table-row-link-actions", - type: "components:example", - registryDependencies: ["table"], - component: React.lazy(() => import("@/registry/default/example/table-row-link-actions")), - source: "", - files: ["registry/default/example/table-row-link-actions.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "table-sort": { - name: "table-sort", - type: "components:example", - registryDependencies: ["table"], - component: React.lazy(() => import("@/registry/default/example/table-sort")), - source: "", - files: ["registry/default/example/table-sort.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "tabs-demo": { - name: "tabs-demo", - type: "components:example", - registryDependencies: ["tabs"], - component: React.lazy(() => import("@/registry/default/example/tabs-demo")), - source: "", - files: ["registry/default/example/tabs-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "textarea-demo": { - name: "textarea-demo", - type: "components:example", - registryDependencies: ["textarea"], - component: React.lazy(() => import("@/registry/default/example/textarea-demo")), - source: "", - files: ["registry/default/example/textarea-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "textarea-disabled": { - name: "textarea-disabled", - type: "components:example", - registryDependencies: ["textarea"], - component: React.lazy(() => import("@/registry/default/example/textarea-disabled")), - source: "", - files: ["registry/default/example/textarea-disabled.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "textarea-form": { - name: "textarea-form", - type: "components:example", - registryDependencies: ["textarea","form"], - component: React.lazy(() => import("@/registry/default/example/textarea-form")), - source: "", - files: ["registry/default/example/textarea-form.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "textarea-with-button": { - name: "textarea-with-button", - type: "components:example", - registryDependencies: ["textarea","button"], - component: React.lazy(() => import("@/registry/default/example/textarea-with-button")), - source: "", - files: ["registry/default/example/textarea-with-button.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "textarea-with-label": { - name: "textarea-with-label", - type: "components:example", - registryDependencies: ["textarea","label"], - component: React.lazy(() => import("@/registry/default/example/textarea-with-label")), - source: "", - files: ["registry/default/example/textarea-with-label.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "textarea-with-text": { - name: "textarea-with-text", - type: "components:example", - registryDependencies: ["textarea","label"], - component: React.lazy(() => import("@/registry/default/example/textarea-with-text")), - source: "", - files: ["registry/default/example/textarea-with-text.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "toggle-group-demo": { - name: "toggle-group-demo", - type: "components:example", - registryDependencies: ["toggle-group"], - component: React.lazy(() => import("@/registry/default/example/toggle-group-demo")), - source: "", - files: ["registry/default/example/toggle-group-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "toggle-group-disabled": { - name: "toggle-group-disabled", - type: "components:example", - registryDependencies: ["toggle-group"], - component: React.lazy(() => import("@/registry/default/example/toggle-group-disabled")), - source: "", - files: ["registry/default/example/toggle-group-disabled.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "toggle-group-lg": { - name: "toggle-group-lg", - type: "components:example", - registryDependencies: ["toggle-group"], - component: React.lazy(() => import("@/registry/default/example/toggle-group-lg")), - source: "", - files: ["registry/default/example/toggle-group-lg.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "toggle-group-outline": { - name: "toggle-group-outline", - type: "components:example", - registryDependencies: ["toggle-group"], - component: React.lazy(() => import("@/registry/default/example/toggle-group-outline")), - source: "", - files: ["registry/default/example/toggle-group-outline.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "toggle-group-sm": { - name: "toggle-group-sm", - type: "components:example", - registryDependencies: ["toggle-group"], - component: React.lazy(() => import("@/registry/default/example/toggle-group-sm")), - source: "", - files: ["registry/default/example/toggle-group-sm.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "toggle-group-single": { - name: "toggle-group-single", - type: "components:example", - registryDependencies: ["toggle-group"], - component: React.lazy(() => import("@/registry/default/example/toggle-group-single")), - source: "", - files: ["registry/default/example/toggle-group-single.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "toggle-demo": { - name: "toggle-demo", - type: "components:example", - registryDependencies: ["toggle"], - component: React.lazy(() => import("@/registry/default/example/toggle-demo")), - source: "", - files: ["registry/default/example/toggle-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "toggle-disabled": { - name: "toggle-disabled", - type: "components:example", - registryDependencies: ["toggle"], - component: React.lazy(() => import("@/registry/default/example/toggle-disabled")), - source: "", - files: ["registry/default/example/toggle-disabled.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "toggle-lg": { - name: "toggle-lg", - type: "components:example", - registryDependencies: ["toggle"], - component: React.lazy(() => import("@/registry/default/example/toggle-lg")), - source: "", - files: ["registry/default/example/toggle-lg.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "toggle-outline": { - name: "toggle-outline", - type: "components:example", - registryDependencies: ["toggle"], - component: React.lazy(() => import("@/registry/default/example/toggle-outline")), - source: "", - files: ["registry/default/example/toggle-outline.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "toggle-sm": { - name: "toggle-sm", - type: "components:example", - registryDependencies: ["toggle"], - component: React.lazy(() => import("@/registry/default/example/toggle-sm")), - source: "", - files: ["registry/default/example/toggle-sm.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "toggle-with-text": { - name: "toggle-with-text", - type: "components:example", - registryDependencies: ["toggle"], - component: React.lazy(() => import("@/registry/default/example/toggle-with-text")), - source: "", - files: ["registry/default/example/toggle-with-text.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "tooltip-demo": { - name: "tooltip-demo", - type: "components:example", - registryDependencies: ["tooltip"], - component: React.lazy(() => import("@/registry/default/example/tooltip-demo")), - source: "", - files: ["registry/default/example/tooltip-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "typography-blockquote": { - name: "typography-blockquote", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/typography-blockquote")), - source: "", - files: ["registry/default/example/typography-blockquote.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "typography-demo": { - name: "typography-demo", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/typography-demo")), - source: "", - files: ["registry/default/example/typography-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "typography-h1": { - name: "typography-h1", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/typography-h1")), - source: "", - files: ["registry/default/example/typography-h1.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "typography-h2": { - name: "typography-h2", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/typography-h2")), - source: "", - files: ["registry/default/example/typography-h2.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "typography-h3": { - name: "typography-h3", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/typography-h3")), - source: "", - files: ["registry/default/example/typography-h3.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "typography-h4": { - name: "typography-h4", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/typography-h4")), - source: "", - files: ["registry/default/example/typography-h4.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "typography-inline-code": { - name: "typography-inline-code", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/typography-inline-code")), - source: "", - files: ["registry/default/example/typography-inline-code.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "typography-large": { - name: "typography-large", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/typography-large")), - source: "", - files: ["registry/default/example/typography-large.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "typography-lead": { - name: "typography-lead", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/typography-lead")), - source: "", - files: ["registry/default/example/typography-lead.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "typography-list": { - name: "typography-list", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/typography-list")), - source: "", - files: ["registry/default/example/typography-list.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "typography-muted": { - name: "typography-muted", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/typography-muted")), - source: "", - files: ["registry/default/example/typography-muted.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "typography-p": { - name: "typography-p", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/typography-p")), - source: "", - files: ["registry/default/example/typography-p.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "typography-small": { - name: "typography-small", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/typography-small")), - source: "", - files: ["registry/default/example/typography-small.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "typography-table": { - name: "typography-table", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/typography-table")), - source: "", - files: ["registry/default/example/typography-table.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "mode-toggle": { - name: "mode-toggle", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/mode-toggle")), - source: "", - files: ["registry/default/example/mode-toggle.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "text-confirm-dialog-demo": { - name: "text-confirm-dialog-demo", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/text-confirm-dialog-demo")), - source: "", - files: ["registry/default/example/text-confirm-dialog-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "text-confirm-dialog-with-size": { - name: "text-confirm-dialog-with-size", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/text-confirm-dialog-with-size")), - source: "", - files: ["registry/default/example/text-confirm-dialog-with-size.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "text-confirm-dialog-with-children": { - name: "text-confirm-dialog-with-children", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/text-confirm-dialog-with-children")), - source: "", - files: ["registry/default/example/text-confirm-dialog-with-children.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "text-confirm-dialog-with-cancel-button": { - name: "text-confirm-dialog-with-cancel-button", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/text-confirm-dialog-with-cancel-button")), - source: "", - files: ["registry/default/example/text-confirm-dialog-with-cancel-button.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "form-item-layout-demo": { - name: "form-item-layout-demo", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/form-item-layout-demo")), - source: "", - files: ["registry/default/example/form-item-layout-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "key-value-field-array-demo": { - name: "key-value-field-array-demo", - type: "components:example", - registryDependencies: ["button","form","input"], - component: React.lazy(() => import("@/registry/default/example/key-value-field-array-demo")), - source: "", - files: ["registry/default/example/key-value-field-array-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "field-choice-card": { - name: "field-choice-card", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/field-choice-card")), - source: "", - files: ["registry/default/example/field-choice-card.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "field-checkbox": { - name: "field-checkbox", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/field-checkbox")), - source: "", - files: ["registry/default/example/field-checkbox.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "field-demo": { - name: "field-demo", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/field-demo")), - source: "", - files: ["registry/default/example/field-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "field-fieldset": { - name: "field-fieldset", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/field-fieldset")), - source: "", - files: ["registry/default/example/field-fieldset.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "field-input": { - name: "field-input", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/field-input")), - source: "", - files: ["registry/default/example/field-input.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "field-group": { - name: "field-group", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/field-group")), - source: "", - files: ["registry/default/example/field-group.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "field-radio": { - name: "field-radio", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/field-radio")), - source: "", - files: ["registry/default/example/field-radio.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "field-responsive": { - name: "field-responsive", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/field-responsive")), - source: "", - files: ["registry/default/example/field-responsive.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "field-select": { - name: "field-select", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/field-select")), - source: "", - files: ["registry/default/example/field-select.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "field-slider": { - name: "field-slider", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/field-slider")), - source: "", - files: ["registry/default/example/field-slider.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "field-switch": { - name: "field-switch", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/field-switch")), - source: "", - files: ["registry/default/example/field-switch.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "field-textarea": { - name: "field-textarea", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/field-textarea")), - source: "", - files: ["registry/default/example/field-textarea.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "form-patterns-pagelayout": { - name: "form-patterns-pagelayout", - type: "components:example", - registryDependencies: ["form","card","input","button","select","switch","checkbox","textarea","radio-group","calendar","popover","multi-select"], - component: React.lazy(() => import("@/registry/default/example/form-patterns-pagelayout")), - source: "", - files: ["registry/default/example/form-patterns-pagelayout.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "form-patterns-sidepanel": { - name: "form-patterns-sidepanel", - type: "components:example", - registryDependencies: ["form","sheet","input","button","select","switch","checkbox","textarea","radio-group","calendar","popover","multi-select"], - component: React.lazy(() => import("@/registry/default/example/form-patterns-sidepanel")), - source: "", - files: ["registry/default/example/form-patterns-sidepanel.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "form-item-layout-with-select": { - name: "form-item-layout-with-select", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/form-item-layout-with-select")), - source: "", - files: ["registry/default/example/form-item-layout-with-select.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "form-item-layout-with-horizontal": { - name: "form-item-layout-with-horizontal", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/form-item-layout-with-horizontal")), - source: "", - files: ["registry/default/example/form-item-layout-with-horizontal.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "form-item-layout-with-switch": { - name: "form-item-layout-with-switch", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/form-item-layout-with-switch")), - source: "", - files: ["registry/default/example/form-item-layout-with-switch.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "form-item-layout-with-checkbox": { - name: "form-item-layout-with-checkbox", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/form-item-layout-with-checkbox")), - source: "", - files: ["registry/default/example/form-item-layout-with-switch.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "form-item-layout-with-checkbox-list": { - name: "form-item-layout-with-checkbox-list", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/form-item-layout-with-checkbox-list")), - source: "", - files: ["registry/default/example/form-item-layout-with-switch.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "form-item-layout-before-label": { - name: "form-item-layout-before-label", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/form-item-layout-before-label")), - source: "", - files: ["registry/default/example/form-item-layout-before-label.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "form-item-layout-after-label": { - name: "form-item-layout-after-label", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/form-item-layout-after-label")), - source: "", - files: ["registry/default/example/form-item-layout-after-label.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "info-tooltip-demo": { - name: "info-tooltip-demo", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/info-tooltip-demo")), - source: "", - files: ["registry/default/example/info-tooltip-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "skip-to-content-demo": { - name: "skip-to-content-demo", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/skip-to-content-demo")), - source: "", - files: ["registry/default/example/skip-to-content-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "page-container-demo": { - name: "page-container-demo", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/page-container-demo")), - source: "", - files: ["registry/default/example/page-container-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "page-breadcrumbs-demo": { - name: "page-breadcrumbs-demo", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/page-breadcrumbs-demo")), - source: "", - files: ["registry/default/example/page-breadcrumbs-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "page-nav-demo": { - name: "page-nav-demo", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/page-nav-demo")), - source: "", - files: ["registry/default/example/page-nav-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "page-layout-detail": { - name: "page-layout-detail", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/page-layout-detail")), - source: "", - files: ["registry/default/example/page-layout-detail.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "page-layout-edge-function": { - name: "page-layout-edge-function", - type: "components:example", - registryDependencies: ["filter-bar"], - component: React.lazy(() => import("@/registry/default/example/page-layout-edge-function")), - source: "", - files: ["registry/default/example/page-layout-edge-function.tsx","registry/default/example/page-layout-logs-content.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "page-layout-full-width": { - name: "page-layout-full-width", - type: "components:example", - registryDependencies: ["filter-bar"], - component: React.lazy(() => import("@/registry/default/example/page-layout-full-width")), - source: "", - files: ["registry/default/example/page-layout-full-width.tsx","registry/default/example/page-layout-logs-content.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "page-layout-list": { - name: "page-layout-list", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/page-layout-list")), - source: "", - files: ["registry/default/example/page-layout-list.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "page-layout-list-simple": { - name: "page-layout-list-simple", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/page-layout-list-simple")), - source: "", - files: ["registry/default/example/page-layout-list-simple.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "page-layout-settings": { - name: "page-layout-settings", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/page-layout-settings")), - source: "", - files: ["registry/default/example/page-layout-settings.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "connect-interstitial-demo": { - name: "connect-interstitial-demo", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/connect-interstitial-demo")), - source: "", - files: ["registry/default/example/connect-interstitial-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "connect-interstitial-action-error": { - name: "connect-interstitial-action-error", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/connect-interstitial-action-error")), - source: "", - files: ["registry/default/example/connect-interstitial-action-error.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "connect-interstitial-logo-pair": { - name: "connect-interstitial-logo-pair", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/connect-interstitial-logo-pair")), - source: "", - files: ["registry/default/example/connect-interstitial-logo-pair.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "connect-interstitial-logo-single": { - name: "connect-interstitial-logo-single", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/connect-interstitial-logo-single")), - source: "", - files: ["registry/default/example/connect-interstitial-logo-single.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "connect-interstitial-logo-unknown": { - name: "connect-interstitial-logo-unknown", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/connect-interstitial-logo-unknown")), - source: "", - files: ["registry/default/example/connect-interstitial-logo-unknown.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "connect-interstitial-logo-uploaded": { - name: "connect-interstitial-logo-uploaded", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/connect-interstitial-logo-uploaded")), - source: "", - files: ["registry/default/example/connect-interstitial-logo-uploaded.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "page-layout-auth-emails": { - name: "page-layout-auth-emails", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/page-layout-auth-emails")), - source: "", - files: ["registry/default/example/page-layout-auth-emails.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "page-header-demo": { - name: "page-header-demo", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/page-header-demo")), - source: "", - files: ["registry/default/example/page-header-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "page-section-demo": { - name: "page-section-demo", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/page-section-demo")), - source: "", - files: ["registry/default/example/page-section-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "page-section-horizontal": { - name: "page-section-horizontal", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/page-section-horizontal")), - source: "", - files: ["registry/default/example/page-section-horizontal.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "page-section-title-only": { - name: "page-section-title-only", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/page-section-title-only")), - source: "", - files: ["registry/default/example/page-section-title-only.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "page-section-with-aside": { - name: "page-section-with-aside", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/page-section-with-aside")), - source: "", - files: ["registry/default/example/page-section-with-aside.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "inner-side-menu-demo": { - name: "inner-side-menu-demo", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/inner-side-menu-demo")), - source: "", - files: ["registry/default/example/inner-side-menu-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "inner-side-menu-static-titles": { - name: "inner-side-menu-static-titles", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/inner-side-menu-static-titles")), - source: "", - files: ["registry/default/example/inner-side-menu-static-titles.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "inner-side-menu-loading": { - name: "inner-side-menu-loading", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/inner-side-menu-loading")), - source: "", - files: ["registry/default/example/inner-side-menu-loading.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "inner-side-menu-multiple-sections": { - name: "inner-side-menu-multiple-sections", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/inner-side-menu-multiple-sections")), - source: "", - files: ["registry/default/example/inner-side-menu-multiple-sections.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "inner-side-menu-empty": { - name: "inner-side-menu-empty", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/inner-side-menu-empty")), - source: "", - files: ["registry/default/example/inner-side-menu-empty.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "inner-side-menu-with-search": { - name: "inner-side-menu-with-search", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/inner-side-menu-with-search")), - source: "", - files: ["registry/default/example/inner-side-menu-with-search.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "toc-demo": { - name: "toc-demo", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/toc-demo")), - source: "", - files: ["registry/default/example/toc-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "toc-single-demo": { - name: "toc-single-demo", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/toc-single-demo")), - source: "", - files: ["registry/default/example/toc-single-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "multi-select-demo": { - name: "multi-select-demo", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/multi-select-demo")), - source: "", - files: ["registry/default/example/multi-select-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "multi-select-disabled": { - name: "multi-select-disabled", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/multi-select-disabled")), - source: "", - files: ["registry/default/example/multi-select-disabled.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "multi-select-badge-limit-wrap": { - name: "multi-select-badge-limit-wrap", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/multi-select-badge-limit-wrap")), - source: "", - files: ["registry/default/example/multi-select-badge-limit-wrap.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "multi-select-badge-limit": { - name: "multi-select-badge-limit", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/multi-select-badge-limit")), - source: "", - files: ["registry/default/example/multi-select-badge-limit.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "multi-select-combobox": { - name: "multi-select-combobox", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/multi-select-combobox")), - source: "", - files: ["registry/default/example/multi-select-combobox.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "multi-select-combobox-creatable": { - name: "multi-select-combobox-creatable", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/multi-select-combobox-creatable")), - source: "", - files: ["registry/default/example/multi-select-combobox-creatable.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "multi-select-deletable-badge": { - name: "multi-select-deletable-badge", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/multi-select-deletable-badge")), - source: "", - files: ["registry/default/example/multi-select-deletable-badge.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "multi-select-inline-search-input": { - name: "multi-select-inline-search-input", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/multi-select-inline-search-input")), - source: "", - files: ["registry/default/example/multi-select-inline-search-input.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "multi-select-in-dialog": { - name: "multi-select-in-dialog", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/multi-select-in-dialog")), - source: "", - files: ["registry/default/example/multi-select-in-dialog.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "tree-view-demo": { - name: "tree-view-demo", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/tree-view-demo")), - source: "", - files: ["registry/default/example/tree-view-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "tree-view-edit": { - name: "tree-view-edit", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/tree-view-edit")), - source: "", - files: ["registry/default/example/tree-view-edit.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "tree-view-directories": { - name: "tree-view-directories", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/tree-view-directories")), - source: "", - files: ["registry/default/example/tree-view-directories.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "tree-view-multi-select": { - name: "tree-view-multi-select", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/tree-view-multi-select")), - source: "", - files: ["registry/default/example/tree-view-multi-select.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "confirmation-modal-demo": { - name: "confirmation-modal-demo", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/confirmation-modal-demo")), - source: "", - files: ["registry/default/example/confirmation-modal-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "assistant-chat-demo": { - name: "assistant-chat-demo", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/assistant-chat-demo")), - source: "", - files: ["registry/default/example/assistant-chat-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "assistant-chat-commands": { - name: "assistant-chat-commands", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/assistant-chat-commands")), - source: "", - files: ["registry/default/example/assistant-chat-commands.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "color-usage-surface-www-and-docs": { - name: "color-usage-surface-www-and-docs", - type: "docs:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/color-usage-surface-www-and-docs")), - source: "", - files: ["registry/default/example/color-usage-surface-www-and-docs.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "color-usage-surface-studio": { - name: "color-usage-surface-studio", - type: "docs:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/color-usage-surface-studio")), - source: "", - files: ["registry/default/example/color-usage-surface-studio.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "color-usage-surface-studio-frame": { - name: "color-usage-surface-studio-frame", - type: "docs:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/color-usage-surface-studio-frame")), - source: "", - files: ["registry/default/example/color-usage-surface-studio-frame.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "chart-bar-demo": { - name: "chart-bar-demo", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/chart-bar-demo")), - source: "", - files: ["registry/default/example/chart-bar-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "chart-bar-demo-grid": { - name: "chart-bar-demo-grid", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/chart-bar-demo-grid")), - source: "", - files: ["registry/default/example/chart-bar-demo-grid.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "chart-bar-demo-axis": { - name: "chart-bar-demo-axis", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/chart-bar-demo-axis")), - source: "", - files: ["registry/default/example/chart-bar-demo-axis.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "chart-bar-demo-tooltip": { - name: "chart-bar-demo-tooltip", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/chart-bar-demo-tooltip")), - source: "", - files: ["registry/default/example/chart-bar-demo-tooltip.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "chart-bar-demo-legend": { - name: "chart-bar-demo-legend", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/chart-bar-demo-legend")), - source: "", - files: ["registry/default/example/chart-bar-demo-legend.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "chart-tooltip-demo": { - name: "chart-tooltip-demo", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/chart-tooltip-demo")), - source: "", - files: ["registry/default/example/chart-tooltip-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "expanding-textarea-demo": { - name: "expanding-textarea-demo", - type: "docs:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/expanding-textarea-demo")), - source: "", - files: ["registry/default/example/expanding-textarea-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "error-display-demo": { - name: "error-display-demo", - type: "components:example", - registryDependencies: ["error-display"], - component: React.lazy(() => import("@/registry/default/example/error-display-demo")), - source: "", - files: ["registry/default/example/error-display-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "error-display-with-children": { - name: "error-display-with-children", - type: "components:example", - registryDependencies: ["error-display"], - component: React.lazy(() => import("@/registry/default/example/error-display-with-children")), - source: "", - files: ["registry/default/example/error-display-with-children.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "logs-bar-chart": { - name: "logs-bar-chart", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/logs-bar-chart")), - source: "", - files: ["registry/default/example/logs-bar-chart.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "empty-state-missing-route": { - name: "empty-state-missing-route", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/empty-state-missing-route")), - source: "", - files: ["registry/default/example/empty-state-missing-route.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "empty-state-zero-items-table": { - name: "empty-state-zero-items-table", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/empty-state-zero-items-table")), - source: "", - files: ["registry/default/example/empty-state-zero-items-table.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "empty-state-presentational-demo": { - name: "empty-state-presentational-demo", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/empty-state-presentational-demo")), - source: "", - files: ["registry/default/example/empty-state-presentational-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "empty-state-presentational-buttons": { - name: "empty-state-presentational-buttons", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/empty-state-presentational-buttons")), - source: "", - files: ["registry/default/example/empty-state-presentational-buttons.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "empty-state-presentational-icon": { - name: "empty-state-presentational-icon", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/empty-state-presentational-icon")), - source: "", - files: ["registry/default/example/empty-state-presentational-icon.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "empty-state-initial-state-informational": { - name: "empty-state-initial-state-informational", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/empty-state-initial-state-informational")), - source: "", - files: ["registry/default/example/empty-state-initial-state-informational.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "data-grid-demo": { - name: "data-grid-demo", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/data-grid-demo")), - source: "", - files: ["registry/default/example/data-grid-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "data-grid-empty-state": { - name: "data-grid-empty-state", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/data-grid-empty-state")), - source: "", - files: ["registry/default/example/data-grid-empty-state.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "data-table-demo": { - name: "data-table-demo", - type: "components:example", - registryDependencies: ["table"], - component: React.lazy(() => import("@/registry/default/example/data-table-demo")), - source: "", - files: ["registry/default/example/data-table-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "metric-card": { - name: "metric-card", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/metric-card")), - source: "", - files: ["registry/default/example/metric-card.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "metric-card-minimal": { - name: "metric-card-minimal", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/metric-card-minimal")), - source: "", - files: ["registry/default/example/metric-card-minimal.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "metric-card-minimal-horizontal": { - name: "metric-card-minimal-horizontal", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/metric-card-minimal-horizontal")), - source: "", - files: ["registry/default/example/metric-card-minimal-horizontal.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "metric-card-with-icon-link-tooltip": { - name: "metric-card-with-icon-link-tooltip", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/metric-card-with-icon-link-tooltip")), - source: "", - files: ["registry/default/example/metric-card-with-icon-link-tooltip.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "mermaid-demo": { - name: "mermaid-demo", - type: "components:example", - registryDependencies: ["mermaid"], - component: React.lazy(() => import("@/registry/default/example/mermaid-demo")), - source: "", - files: ["registry/default/example/mermaid-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "mermaid-flowchart": { - name: "mermaid-flowchart", - type: "components:example", - registryDependencies: ["mermaid"], - component: React.lazy(() => import("@/registry/default/example/mermaid-flowchart")), - source: "", - files: ["registry/default/example/mermaid-flowchart.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "mermaid-er-diagram": { - name: "mermaid-er-diagram", - type: "components:example", - registryDependencies: ["mermaid"], - component: React.lazy(() => import("@/registry/default/example/mermaid-er-diagram")), - source: "", - files: ["registry/default/example/mermaid-er-diagram.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "mermaid-sequence-async": { - name: "mermaid-sequence-async", - type: "components:example", - registryDependencies: ["mermaid"], - component: React.lazy(() => import("@/registry/default/example/mermaid-sequence-async")), - source: "", - files: ["registry/default/example/mermaid-sequence-async.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "mermaid-sequence-sync": { - name: "mermaid-sequence-sync", - type: "components:example", - registryDependencies: ["mermaid"], - component: React.lazy(() => import("@/registry/default/example/mermaid-sequence-sync")), - source: "", - files: ["registry/default/example/mermaid-sequence-sync.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "mermaid-oauth-flow": { - name: "mermaid-oauth-flow", - type: "components:example", - registryDependencies: ["mermaid"], - component: React.lazy(() => import("@/registry/default/example/mermaid-oauth-flow")), - source: "", - files: ["registry/default/example/mermaid-oauth-flow.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "mermaid-flowchart-subgraph": { - name: "mermaid-flowchart-subgraph", - type: "components:example", - registryDependencies: ["mermaid"], - component: React.lazy(() => import("@/registry/default/example/mermaid-flowchart-subgraph")), - source: "", - files: ["registry/default/example/mermaid-flowchart-subgraph.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "mermaid-sequence-api": { - name: "mermaid-sequence-api", - type: "components:example", - registryDependencies: ["mermaid"], - component: React.lazy(() => import("@/registry/default/example/mermaid-sequence-api")), - source: "", - files: ["registry/default/example/mermaid-sequence-api.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "mermaid-er-simple": { - name: "mermaid-er-simple", - type: "components:example", - registryDependencies: ["mermaid"], - component: React.lazy(() => import("@/registry/default/example/mermaid-er-simple")), - source: "", - files: ["registry/default/example/mermaid-er-simple.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "mermaid-basic": { - name: "mermaid-basic", - type: "components:example", - registryDependencies: ["mermaid"], - component: React.lazy(() => import("@/registry/default/example/mermaid-basic")), - source: "", - files: ["registry/default/example/mermaid-basic.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "markdown-full-example": { - name: "markdown-full-example", - type: "components:example", - registryDependencies: ["markdown"], - component: React.lazy(() => import("@/registry/default/example/markdown-full-example")), - source: "", - files: ["registry/default/example/markdown-full-example.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "markdown-customization": { - name: "markdown-customization", - type: "components:example", - registryDependencies: ["markdown"], - component: React.lazy(() => import("@/registry/default/example/markdown-customization")), - source: "", - files: ["registry/default/example/markdown-customization.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "markdown-headings": { - name: "markdown-headings", - type: "components:example", - registryDependencies: ["markdown"], - component: React.lazy(() => import("@/registry/default/example/markdown-headings")), - source: "", - files: ["registry/default/example/markdown-headings.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "markdown-paragraphs": { - name: "markdown-paragraphs", - type: "components:example", - registryDependencies: ["markdown"], - component: React.lazy(() => import("@/registry/default/example/markdown-paragraphs")), - source: "", - files: ["registry/default/example/markdown-paragraphs.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "markdown-lists": { - name: "markdown-lists", - type: "components:example", - registryDependencies: ["markdown"], - component: React.lazy(() => import("@/registry/default/example/markdown-lists")), - source: "", - files: ["registry/default/example/markdown-lists.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "markdown-links": { - name: "markdown-links", - type: "components:example", - registryDependencies: ["markdown"], - component: React.lazy(() => import("@/registry/default/example/markdown-links")), - source: "", - files: ["registry/default/example/markdown-links.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "markdown-inline-code": { - name: "markdown-inline-code", - type: "components:example", - registryDependencies: ["markdown"], - component: React.lazy(() => import("@/registry/default/example/markdown-inline-code")), - source: "", - files: ["registry/default/example/markdown-inline-code.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "markdown-blockquotes": { - name: "markdown-blockquotes", - type: "components:example", - registryDependencies: ["markdown"], - component: React.lazy(() => import("@/registry/default/example/markdown-blockquotes")), - source: "", - files: ["registry/default/example/markdown-blockquotes.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "markdown-code-blocks": { - name: "markdown-code-blocks", - type: "components:example", - registryDependencies: ["markdown"], - component: React.lazy(() => import("@/registry/default/example/markdown-code-blocks")), - source: "", - files: ["registry/default/example/markdown-code-blocks.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "markdown-tables": { - name: "markdown-tables", - type: "components:example", - registryDependencies: ["markdown"], - component: React.lazy(() => import("@/registry/default/example/markdown-tables")), - source: "", - files: ["registry/default/example/markdown-tables.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "markdown-images": { - name: "markdown-images", - type: "components:example", - registryDependencies: ["markdown"], - component: React.lazy(() => import("@/registry/default/example/markdown-images")), - source: "", - files: ["registry/default/example/markdown-images.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "markdown-horizontal-rules": { - name: "markdown-horizontal-rules", - type: "components:example", - registryDependencies: ["markdown"], - component: React.lazy(() => import("@/registry/default/example/markdown-horizontal-rules")), - source: "", - files: ["registry/default/example/markdown-horizontal-rules.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "markdown-quote-component": { - name: "markdown-quote-component", - type: "components:example", - registryDependencies: ["markdown"], - component: React.lazy(() => import("@/registry/default/example/markdown-quote-component")), - source: "", - files: ["registry/default/example/markdown-quote-component.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "markdown-avatar-component": { - name: "markdown-avatar-component", - type: "components:example", - registryDependencies: ["markdown"], - component: React.lazy(() => import("@/registry/default/example/markdown-avatar-component")), - source: "", - files: ["registry/default/example/markdown-avatar-component.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "status-code-demo": { - name: "status-code-demo", - type: "components:example", - registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/status-code-demo")), - source: "", - files: ["registry/default/example/status-code-demo.tsx"], - category: "undefined", - subcategory: "undefined", - chunks: [] - }, - "chart-bar-interactive": { - name: "chart-bar-interactive", - type: "components:block", - registryDependencies: ["card","chart"], - component: React.lazy(() => import("@/registry/default/block/chart-bar-interactive")), - source: "__registry__/default/block/chart-bar-interactive.tsx", - files: ["registry/default/block/chart-bar-interactive.tsx"], - category: "Charts", - subcategory: "Bar", - chunks: [] - }, - "chart-composed-demo": { - name: "chart-composed-demo", - type: "components:block", - registryDependencies: ["chart"], - component: React.lazy(() => import("@/registry/default/block/chart-composed-demo")), - source: "__registry__/default/block/chart-composed-demo.tsx", - files: ["registry/default/block/chart-composed-demo.tsx"], - category: "Charts", - subcategory: "Composed", - chunks: [] - }, - "chart-composed-basic": { - name: "chart-composed-basic", - type: "components:block", - registryDependencies: ["chart"], - component: React.lazy(() => import("@/registry/default/block/chart-composed-basic")), - source: "__registry__/default/block/chart-composed-basic.tsx", - files: ["registry/default/block/chart-composed-basic.tsx"], - category: "Charts", - subcategory: "Composed", - chunks: [] - }, - "chart-composed-states": { - name: "chart-composed-states", - type: "components:block", - registryDependencies: ["chart"], - component: React.lazy(() => import("@/registry/default/block/chart-composed-states")), - source: "__registry__/default/block/chart-composed-states.tsx", - files: ["registry/default/block/chart-composed-states.tsx"], - category: "Charts", - subcategory: "Composed", - chunks: [] - }, - "chart-composed-table": { - name: "chart-composed-table", - type: "components:block", - registryDependencies: ["chart"], - component: React.lazy(() => import("@/registry/default/block/chart-composed-table")), - source: "__registry__/default/block/chart-composed-table.tsx", - files: ["registry/default/block/chart-composed-table.tsx"], - category: "Charts", - subcategory: "Composed", - chunks: [] - }, - "chart-composed-metrics": { - name: "chart-composed-metrics", - type: "components:block", - registryDependencies: ["chart"], - component: React.lazy(() => import("@/registry/default/block/chart-composed-metrics")), - source: "__registry__/default/block/chart-composed-metrics.tsx", - files: ["registry/default/block/chart-composed-metrics.tsx"], - category: "Charts", - subcategory: "Composed", - chunks: [] - }, - "chart-composed-actions": { - name: "chart-composed-actions", - type: "components:block", - registryDependencies: ["chart"], - component: React.lazy(() => import("@/registry/default/block/chart-composed-actions")), - source: "__registry__/default/block/chart-composed-actions.tsx", - files: ["registry/default/block/chart-composed-actions.tsx"], - category: "Charts", - subcategory: "Composed", - chunks: [] - }, - "copy-button-verbs": { - name: "copy-button-verbs", - type: "components:example", - registryDependencies: ["button"], - component: React.lazy(() => import("@/registry/default/example/copy-button-verbs")), - source: "", - files: ["registry/default/example/copy-button-verbs.tsx"], - category: "Getting Started", - subcategory: "Copywriting", - chunks: [] - }, - "copy-form-labels": { - name: "copy-form-labels", - type: "components:example", - registryDependencies: ["form"], - component: React.lazy(() => import("@/registry/default/example/copy-form-labels")), - source: "", - files: ["registry/default/example/copy-form-labels.tsx"], - category: "Getting Started", - subcategory: "Copywriting", - chunks: [] - }, - "copy-error-messages": { - name: "copy-error-messages", - type: "components:example", - registryDependencies: ["form"], - component: React.lazy(() => import("@/registry/default/example/copy-error-messages")), - source: "", - files: ["registry/default/example/copy-error-messages.tsx"], - category: "Getting Started", - subcategory: "Copywriting", - chunks: [] - }, - "copy-success-messages": { - name: "copy-success-messages", - type: "components:example", - registryDependencies: ["form"], - component: React.lazy(() => import("@/registry/default/example/copy-success-messages")), - source: "", - files: ["registry/default/example/copy-success-messages.tsx"], - category: "Getting Started", - subcategory: "Copywriting", - chunks: [] - }, - "copy-tooltips": { - name: "copy-tooltips", - type: "components:example", - registryDependencies: ["tooltip"], - component: React.lazy(() => import("@/registry/default/example/copy-tooltips")), - source: "", - files: ["registry/default/example/copy-tooltips.tsx"], - category: "Getting Started", - subcategory: "Copywriting", - chunks: [] - }, - "copy-loading-states": { - name: "copy-loading-states", - type: "components:example", - registryDependencies: ["loading-state"], - component: React.lazy(() => import("@/registry/default/example/copy-loading-states")), - source: "", - files: ["registry/default/example/copy-loading-states.tsx"], - category: "Getting Started", - subcategory: "Copywriting", - chunks: [] - }, - "copy-empty-states": { - name: "copy-empty-states", - type: "components:example", - registryDependencies: ["empty-state"], - component: React.lazy(() => import("@/registry/default/example/copy-empty-states")), - source: "", - files: ["registry/default/example/copy-empty-states.tsx"], - category: "Getting Started", - subcategory: "Copywriting", - chunks: [] - }, - "copy-confirmations": { - name: "copy-confirmations", - type: "components:example", - registryDependencies: ["confirmation"], - component: React.lazy(() => import("@/registry/default/example/copy-confirmations")), - source: "", - files: ["registry/default/example/copy-confirmations.tsx"], - category: "Getting Started", - subcategory: "Copywriting", - chunks: [] - }, - }, -} diff --git a/apps/design-system/eslint.config.cjs b/apps/design-system/eslint.config.cjs index 4f1bc5e227550..75f8057119c0b 100644 --- a/apps/design-system/eslint.config.cjs +++ b/apps/design-system/eslint.config.cjs @@ -3,8 +3,9 @@ const supabaseConfig = require('eslint-config-supabase/next') module.exports = defineConfig([ supabaseConfig, + { ignores: ['__registry__'] }, { - files: ['registry/**/*.tsx', '__registry__/**/*.tsx', 'app/**/*.tsx'], + files: ['registry/**/*.tsx', 'app/**/*.tsx'], rules: { 'no-restricted-exports': 'off', }, diff --git a/apps/design-system/package.json b/apps/design-system/package.json index 8b8da975e3653..2bc337203573b 100644 --- a/apps/design-system/package.json +++ b/apps/design-system/package.json @@ -5,16 +5,17 @@ "type": "module", "scripts": { "preinstall": "npx only-allow pnpm", - "dev": "run-p --race dev:*", + "dev": "pnpm run generate:registry && run-p --race dev:*", "dev:next": "next dev --turbopack --port 3003", "dev:content": "contentlayer2 dev", - "build": "pnpm run content:build && pnpm run build:registry && next build --turbopack", - "build:registry": "tsx ./scripts/build-registry.mts && prettier --log-level silent --write \"registry/**/*.{ts,tsx,mdx}\" --cache", + "build": "pnpm run build:registry && pnpm run content:build && next build --turbopack", + "generate:registry": "tsx ./scripts/build-registry.mts", + "build:registry": "pnpm run generate:registry && prettier --log-level silent --write \"registry/**/*.{ts,tsx,mdx}\" --cache", "start": "next start", "lint": "eslint .", "content:build": "contentlayer2 build", - "clean": "rimraf .next .turbo tsconfig.tsbuildinfo", - "typecheck": "contentlayer2 build && tsc --noEmit -p tsconfig.json" + "clean": "rimraf .next .turbo tsconfig.tsbuildinfo __registry__", + "typecheck": "pnpm run generate:registry && contentlayer2 build && tsc --noEmit -p tsconfig.json" }, "dependencies": { "@hookform/resolvers": "^3.1.1", diff --git a/apps/design-system/scripts/build-registry.mts b/apps/design-system/scripts/build-registry.mts index f645b05842bd9..a95df6b54027a 100644 --- a/apps/design-system/scripts/build-registry.mts +++ b/apps/design-system/scripts/build-registry.mts @@ -1,5 +1,5 @@ // @sts-nocheck -import { existsSync, promises as fs } from 'fs' +import { promises as fs } from 'fs' import { tmpdir } from 'os' import path from 'path' import { cwd } from 'process' @@ -191,13 +191,10 @@ export const Index: Record = { }) ) - // // Write the source file for blocks only. + // Write the source file for blocks only. sourceFilename = `__registry__/${style.name}/${type}/${item.name}.tsx` const sourcePath = path.join(process.cwd(), sourceFilename) - if (!existsSync(sourcePath)) { - await fs.mkdir(sourcePath, { recursive: true }) - } - + await fs.mkdir(path.dirname(sourcePath), { recursive: true }) rimraf.sync(sourcePath) await fs.writeFile(sourcePath, sourceFile.getText()) } @@ -273,8 +270,10 @@ export const Index: Record = { // await fs.writeFile(path.join(REGISTRY_PATH, 'index.json'), registryJson, 'utf8') // Write style index. - rimraf.sync(path.join(process.cwd(), '__registry__/index.tsx')) - await fs.writeFile(path.join(process.cwd(), '__registry__/index.tsx'), index) + const indexPath = path.join(process.cwd(), '__registry__/index.tsx') + await fs.mkdir(path.dirname(indexPath), { recursive: true }) + rimraf.sync(indexPath) + await fs.writeFile(indexPath, index) } // ---------------------------------------------------------------------------- From 768cf11b9caaebb6ebf4bb18e2df3cc098edfec6 Mon Sep 17 00:00:00 2001 From: Inder Singh <85822513+singh-inder@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:51:54 +0530 Subject: [PATCH 7/9] feat(self-hosted): add pgbouncer override (#49052) --- .github/workflows/self-host-tests-smoke.yml | 4 +- docker/.env.example | 11 ++-- docker/docker-compose.pgbouncer.yml | 56 +++++++++++++++++++++ docker/tests/test-container-logs.sh | 16 ++++-- docker/tests/test-self-hosted.sh | 43 ++++++++++++++++ 5 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 docker/docker-compose.pgbouncer.yml diff --git a/.github/workflows/self-host-tests-smoke.yml b/.github/workflows/self-host-tests-smoke.yml index ac7e0eecc6064..cc826e48b2eb7 100644 --- a/.github/workflows/self-host-tests-smoke.yml +++ b/.github/workflows/self-host-tests-smoke.yml @@ -20,7 +20,7 @@ jobs: strategy: fail-fast: false matrix: - config: [default, logs, kong, rustfs, kong-rustfs] + config: [default, logs, kong, rustfs, kong-rustfs, pgbouncer] steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -57,6 +57,8 @@ jobs: docker compose -f docker-compose.yml -f docker-compose.rustfs.yml up --quiet-pull --wait --wait-timeout 180 elif [ "${{ matrix.config }}" = "kong-rustfs" ]; then docker compose -f docker-compose.yml -f docker-compose.kong.yml -f docker-compose.rustfs.yml up --quiet-pull --wait --wait-timeout 180 + elif [ "${{ matrix.config }}" = "pgbouncer" ]; then + docker compose -f docker-compose.yml -f docker-compose.pgbouncer.yml up --quiet-pull --wait --wait-timeout 180 else docker compose up --quiet-pull --wait --wait-timeout 180 fi diff --git a/docker/.env.example b/docker/.env.example index fe428f5091de5..997b20e648256 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -119,19 +119,24 @@ POSTGRES_PORT=5432 ############ -# Supavisor - Database pooler +# Database pooler ############ +# Self-hosted Supabase uses Supavisor as the default database pooler. +# If you use the PgBouncer docker-compose override, Supavisor is disabled +# and the pooler settings below are used to configure PgBouncer instead. +# # Supavisor exposes POSTGRES_PORT and POOLER_PROXY_PORT_TRANSACTION, # POSTGRES_PORT is used for session mode pooling +# PgBouncer only exposes POOLER_PROXY_PORT_TRANSACTION. # # Port to use for transaction mode pooling connections POOLER_PROXY_PORT_TRANSACTION=6543 -# Maximum number of PostgreSQL connections Supavisor opens per pool +# Maximum number of PostgreSQL connections Supavisor or PgBouncer opens per pool POOLER_DEFAULT_POOL_SIZE=20 -# Maximum number of client connections Supavisor accepts per pool +# Maximum number of client connections Supavisor or PgBouncer accepts per pool POOLER_MAX_CLIENT_CONN=100 # Unique Supavisor tenant identifier diff --git a/docker/docker-compose.pgbouncer.yml b/docker/docker-compose.pgbouncer.yml new file mode 100644 index 0000000000000..d745ed484d506 --- /dev/null +++ b/docker/docker-compose.pgbouncer.yml @@ -0,0 +1,56 @@ +# PgBouncer override for self-hosted Supabase +# +# Replaces the default Supavisor pooler with PgBouncer in transaction mode. +# +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.pgbouncer.yml up -d +# +# Or use run.sh to add PgBouncer to the stack: +# sh run.sh config add pgbouncer +# sh run.sh start +# + +services: + supavisor: !reset null + + # To expose Postgres directly on POSTGRES_PORT (5432), uncomment the "db" + # section below. + # WARNING: this opens Postgres to the external traffic. Restrict access at the + # network level if necessary. + #db: + # ports: + # - ${POSTGRES_PORT}:${POSTGRES_PORT} + + pgbouncer: + container_name: supabase-pgbouncer + image: edoburu/pgbouncer:v1.25.2-p0 + restart: unless-stopped + ports: + - ${POOLER_PROXY_PORT_TRANSACTION}:6432 + healthcheck: + test: ['CMD', 'pg_isready', '-h', 'localhost', '-p', '6432'] + interval: 10s + timeout: 5s + retries: 3 + start_period: 5s + depends_on: + db: + condition: service_healthy + environment: + # PgBouncer connects to Postgres as the dedicated `pgbouncer` role and + # looks up every other role's password on demand via the auth_query below. + DB_USER: pgbouncer + DB_PASSWORD: ${POSTGRES_PASSWORD} + DB_HOST: ${POSTGRES_HOST} + DB_PORT: ${POSTGRES_PORT} + DB_NAME: ${POSTGRES_DB} + LISTEN_PORT: 6432 + AUTH_TYPE: scram-sha-256 + AUTH_QUERY: SELECT * FROM pgbouncer.get_auth($$1) + # POOL_MODE can be: session, transaction, or statement + POOL_MODE: transaction + DEFAULT_POOL_SIZE: ${POOLER_DEFAULT_POOL_SIZE} + MAX_CLIENT_CONN: ${POOLER_MAX_CLIENT_CONN} + # Only the `pgbouncer` role may run get_auth / the admin console. + ADMIN_USERS: pgbouncer + STATS_USERS: pgbouncer diff --git a/docker/tests/test-container-logs.sh b/docker/tests/test-container-logs.sh index 7780735937c73..85a64b6d78e15 100644 --- a/docker/tests/test-container-logs.sh +++ b/docker/tests/test-container-logs.sh @@ -148,9 +148,19 @@ check_logs_if_running analytics \ 'Executing startup tasks' \ 'Ensuring single tenant user is seeded' -check_logs supavisor \ - 'Connected to Postgres database' \ - 'HEAD /api/health$' +# Database pooler: Supavisor by default, or PgBouncer when the pgbouncer +# override is enabled (which disables Supavisor). Check whichever is running. +if is_service_running supavisor; then + check_logs supavisor \ + 'Connected to Postgres database' \ + 'HEAD /api/health$' +elif is_service_running pgbouncer; then + check_logs pgbouncer \ + 'process up: PgBouncer' \ + 'listening on .*6432' +else + fail_msg "pooler (neither supavisor nor pgbouncer is running)" +fi check_logs_if_running vector \ 'Vector has started' diff --git a/docker/tests/test-self-hosted.sh b/docker/tests/test-self-hosted.sh index 216a5cdcbf218..d842cc9dcfc11 100644 --- a/docker/tests/test-self-hosted.sh +++ b/docker/tests/test-self-hosted.sh @@ -77,6 +77,16 @@ http_body() { curl -s "$@" "$url" } +# Is a compose service running? Falls back to a label lookup so it works +# regardless of which override files are loaded in this shell. +service_running() { + svc="$1" + docker compose ps --services --status running 2>/dev/null | grep -qx "$svc" && return 0 + docker ps --filter "label=com.docker.compose.project=${COMPOSE_PROJECT_NAME:-supabase}" \ + --filter "label=com.docker.compose.service=$svc" \ + --filter "status=running" --quiet | grep -q '.' +} + echo "" echo "=== Self-hosted smoke test against $BASE_URL ===" echo "" @@ -487,6 +497,39 @@ check "Realtime /api/openapi blocked" "403" \ "$(http_status "$BASE_URL/realtime/v1/api/openapi" \ -H "apikey: $ANON_KEY")" +# --------------------------------------------- +# 10. Database pooler (transaction mode) +# --------------------------------------------- + +echo "" +echo "--- Database pooler (transaction mode) ---" +if command -v docker >/dev/null 2>&1; then + pg_password=$(grep '^POSTGRES_PASSWORD=' .env | cut -d= -f2-) + pooler_tenant_id=$(grep '^POOLER_TENANT_ID=' .env | cut -d= -f2-) + # Connect as the postgres role through whichever transaction pooler is running: + # Supavisor (default) -> service 'supavisor', port 6543, user 'postgres.' + # PgBouncer (override) -> service 'pgbouncer', port 6432 (published as 6543), user 'postgres' + # psql runs inside the db container (always has a client) and reaches the + # pooler over the compose network. + if service_running supavisor; then + pooler_user="postgres.$pooler_tenant_id"; pooler_host="supavisor"; pooler_port=6543 + elif service_running pgbouncer; then + pooler_user="postgres"; pooler_host="pgbouncer"; pooler_port=6432 + else + pooler_user="" + fi + if [ -n "$pooler_user" ]; then + pooler_result=$(docker exec -e PGPASSWORD="$pg_password" supabase-db \ + psql "host=$pooler_host port=$pooler_port user=$pooler_user dbname=postgres sslmode=disable" \ + -tAc "select 'pooler_ok';" 2>/dev/null | tr -d '[:space:]') + check "Pooler transaction-mode query (as postgres)" "pooler_ok" "$pooler_result" + else + check "Pooler running (supavisor or pgbouncer)" "true" "false" + fi +else + echo " SKIP: docker not available" +fi + # --------------------------------------------- # Summary # --------------------------------------------- From 81bccd68622c6f0761fc7880e9633fc44a690f67 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Thu, 20 Aug 2026 19:51:20 +1000 Subject: [PATCH 8/9] notebook preview refine (#49288) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit image image Updates how we display Notebooks in Assistant to be more in line with our AssistantQueryCell. ## To test: - Open Assistant and ask it to create a test notebook and note the new styling ## Summary by CodeRabbit * **New Features** * Added unified, read-only notebook previews for create and update proposals. * Preview cells now support expandable content, clearer type icons, metadata, and “Show more” controls. * Added before-and-after metadata comparisons for replaced cells. * Integrated previews into confirmation cards with approval, skip, and refresh actions. * Added skip-only confirmation flows when approval is unavailable. * **Bug Fixes** * Improved handling of parse failures, stale notebooks, invalid changes, and loading errors. * **Style** * Refined confirmation card layouts, borders, spacing, and footer presentation. --- .../NotebookPreview/NotebookPreview.tsx | 108 -------- .../AssistantNotebookPreview.test.tsx} | 22 +- .../AssistantNotebookPreview.tsx | 101 +++++++ .../AssistantNotebookPreview.utils.test.ts} | 43 ++- .../AssistantNotebookPreview.utils.ts} | 21 +- .../AssistantNotebookPreviewCell.tsx} | 89 +++---- .../ui/AIAssistantPanel/Confirm.tsx | 44 ++-- .../ui/AIAssistantPanel/Message.Parts.tsx | 17 +- .../NotebookProposalRenderer.test.tsx | 60 ++++- .../NotebookProposalRenderer.tsx | 249 +++++++++--------- 10 files changed, 431 insertions(+), 323 deletions(-) delete mode 100644 apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.tsx rename apps/studio/components/{interfaces/Explorer/NotebookPreview/NotebookPreview.test.tsx => ui/AIAssistantPanel/AssistantNotebookPreview.test.tsx} (74%) create mode 100644 apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.tsx rename apps/studio/components/{interfaces/Explorer/NotebookPreview/NotebookPreview.utils.test.ts => ui/AIAssistantPanel/AssistantNotebookPreview.utils.test.ts} (83%) rename apps/studio/components/{interfaces/Explorer/NotebookPreview/NotebookPreview.utils.ts => ui/AIAssistantPanel/AssistantNotebookPreview.utils.ts} (89%) rename apps/studio/components/{interfaces/Explorer/NotebookPreview/NotebookPreviewCell.tsx => ui/AIAssistantPanel/AssistantNotebookPreviewCell.tsx} (67%) diff --git a/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.tsx b/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.tsx deleted file mode 100644 index 5d79e016bef5a..0000000000000 --- a/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { NotebookPen } from 'lucide-react' -import { useState } from 'react' -import { Button, cn } from 'ui' - -import { - formatNotebookDiffSummary, - getEntryKey, - isEntryExpandedByDefault, - summarizeNotebookDiff, -} from './NotebookPreview.utils' -import { NotebookPreviewCell } from './NotebookPreviewCell' -import type { NotebookCellDiffEntry } from '@/data/content/notebooks/notebook-operations' - -export interface NotebookPreviewProps { - entries: NotebookCellDiffEntry[] - mode: 'create' | 'update' - /** The notebook's name, shown in the card header. Falls back to a generic label. */ - title?: string - className?: string -} - -const VISIBLE_ENTRY_LIMIT = 5 - -const FALLBACK_TITLE = { - create: 'New notebook', - update: 'Notebook changes', -} as const - -/** - * Read-only preview of a proposed notebook create/update, rendered from a pre-computed diff. - * Pure presentational component: no data fetching, no approval or notebook-editor state — see - * `deriveNotebookDiff` for how `entries` is produced. - */ -export const NotebookPreview = ({ entries, mode, title, className }: NotebookPreviewProps) => { - const [isShowingAllEntries, setIsShowingAllEntries] = useState(false) - const [expandedOverrides, setExpandedOverrides] = useState>({}) - - const summary = summarizeNotebookDiff(entries, mode) - const visibleEntries = isShowingAllEntries ? entries : entries.slice(0, VISIBLE_ENTRY_LIMIT) - const hiddenCount = entries.length - visibleEntries.length - - const isExpanded = (entry: NotebookCellDiffEntry) => - expandedOverrides[getEntryKey(entry)] ?? isEntryExpandedByDefault(entry) - - const hasEntries = entries.length > 0 - const areAllExpanded = visibleEntries.every(isExpanded) - - const toggleAll = () => - setExpandedOverrides( - Object.fromEntries(entries.map((entry) => [getEntryKey(entry), !areAllExpanded])) - ) - - return ( -
-
- - - {title ?? FALLBACK_TITLE[mode]} - - - {formatNotebookDiffSummary(summary)} - - {hasEntries && ( - - )} -
-
- {visibleEntries.map((entry) => { - const key = getEntryKey(entry) - return ( - - setExpandedOverrides((prev) => ({ ...prev, [key]: open })) - } - /> - ) - })} -
- {hiddenCount > 0 && ( - - )} -
- ) -} diff --git a/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.test.tsx b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.test.tsx similarity index 74% rename from apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.test.tsx rename to apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.test.tsx index a1f9e8622f86c..b796ea2e5e0c9 100644 --- a/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.test.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.test.tsx @@ -1,7 +1,8 @@ import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { describe, expect, it } from 'vitest' -import { NotebookPreview } from './NotebookPreview' +import { AssistantNotebookPreview } from './AssistantNotebookPreview' import type { NotebookCellDiffEntry } from '@/data/content/notebooks/notebook-operations' import type { AgentCell, CellWire } from '@/data/content/notebooks/notebook-schema' import { customRender as render } from '@/tests/lib/custom-render' @@ -29,18 +30,20 @@ const agentDatabaseCell = (database_identifier?: string): AgentCell => ({ database_identifier, }) -describe('NotebookPreview', () => { +describe('AssistantNotebookPreview', () => { // The whole safety argument for this feature reduces to this: agent-authored markdown text // is rendered as literal source (via CodeBlock), never interpreted into real DOM nodes. A // future refactor that swaps in would break this silently. - it('never renders agent-authored cell content as real img/link/src DOM nodes', () => { + it('never renders agent-authored cell content as real img/link/src DOM nodes', async () => { + const user = userEvent.setup() const adversarialText = '![x](https://evil.example/) [y](https://evil.example/) ' const entries: NotebookCellDiffEntry[] = [ { _tag: 'added', cell: agentMarkdownCell(adversarialText), operationIndex: 0 }, ] - const { container } = render() + const { container } = render() + await user.click(screen.getByRole('button', { name: 'Added Markdown cell' })) expect(container.querySelectorAll('img')).toHaveLength(0) expect(container.querySelectorAll('[href]')).toHaveLength(0) @@ -53,9 +56,11 @@ describe('NotebookPreview', () => { { _tag: 'unchanged', cell: wireMarkdownCell('b', 'two') }, ] - render() + render() + expect(screen.getByRole('toolbar', { name: 'Notebook toolbar' })).toBeInTheDocument() expect(screen.getByText('2 cells')).toBeInTheDocument() + expect(screen.getByText('New notebook')).toBeInTheDocument() }) it('surfaces a metadata-only change on a replaced cell even when the sql is unchanged', () => { @@ -68,9 +73,12 @@ describe('NotebookPreview', () => { }, ] - render() + render() expect(screen.getByText('Database: primary → Database: replica-3')).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Replaced Query: Untitled query' }) + ).toHaveTextContent('Database: primary → Database: replica-3') }) it('hides entries past the limit behind a "Show N more" button', async () => { @@ -79,7 +87,7 @@ describe('NotebookPreview', () => { cell: wireMarkdownCell(`cell-${index}`, `text-${index}`), })) - render() + render() expect(screen.getByText('Show 2 more cells')).toBeInTheDocument() }) diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.tsx b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.tsx new file mode 100644 index 0000000000000..959378d65e559 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.tsx @@ -0,0 +1,101 @@ +import { NotebookText } from 'lucide-react' +import { useState } from 'react' +import { Button, cn } from 'ui' + +import { + formatNotebookDiffSummary, + getEntryKey, + summarizeNotebookDiff, +} from './AssistantNotebookPreview.utils' +import { AssistantNotebookPreviewCell } from './AssistantNotebookPreviewCell' +import { + ExplorerToolbar, + ExplorerToolbarActions, + ExplorerToolbarIcon, + ExplorerToolbarTitle, +} from '@/components/interfaces/Explorer/ExplorerToolbar' +import type { NotebookCellDiffEntry } from '@/data/content/notebooks/notebook-operations' + +export interface AssistantNotebookPreviewProps { + entries: NotebookCellDiffEntry[] + mode: 'create' | 'update' + /** The notebook's name, shown in the toolbar. Falls back to a generic label. */ + title?: string + className?: string +} + +const VISIBLE_ENTRY_LIMIT = 5 + +const FALLBACK_TITLE = { + create: 'New notebook', + update: 'Notebook changes', +} as const + +/** + * Read-only minified notebook for assistant create/update previews. Composes the same + * Explorer toolbar as notebook tabs; the surrounding `Confirm` card owns the frame. + * Pure presentational: no data fetching, no approval or notebook-editor state — see + * `deriveNotebookDiff` for how `entries` is produced. + */ +export const AssistantNotebookPreview = ({ + entries, + mode, + title, + className, +}: AssistantNotebookPreviewProps) => { + const [isShowingAllEntries, setIsShowingAllEntries] = useState(false) + const [expandedOverrides, setExpandedOverrides] = useState>({}) + + const summary = summarizeNotebookDiff(entries, mode) + const visibleEntries = isShowingAllEntries ? entries : entries.slice(0, VISIBLE_ENTRY_LIMIT) + const hiddenCount = entries.length - visibleEntries.length + + const isExpanded = (entry: NotebookCellDiffEntry) => + expandedOverrides[getEntryKey(entry)] === true + + return ( +
+ + + + + {title ?? FALLBACK_TITLE[mode]} + + + {formatNotebookDiffSummary(summary)} + + + +
+
+
+ {visibleEntries.map((entry) => { + const key = getEntryKey(entry) + return ( + + setExpandedOverrides((prev) => ({ ...prev, [key]: open })) + } + /> + ) + })} +
+ {hiddenCount > 0 && ( + + )} +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.utils.test.ts b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.test.ts similarity index 83% rename from apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.utils.test.ts rename to apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.test.ts index f9ca459fa5e87..9a7536d73bf9a 100644 --- a/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.utils.test.ts +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.test.ts @@ -7,8 +7,9 @@ import { getCellLabel, getCellMetadataLine, getEntryKey, + getEntryMetadataLine, summarizeNotebookDiff, -} from './NotebookPreview.utils' +} from './AssistantNotebookPreview.utils' import type { NotebookCellDiffEntry } from '@/data/content/notebooks/notebook-operations' import type { AgentCell, CellWire } from '@/data/content/notebooks/notebook-schema' import { isoDateTimeString } from '@/lib/iso-datetime' @@ -37,6 +38,13 @@ const wireLogCell = (id: string): CellWire => ({ time_range: { _tag: 'relative_time_range', unit: 'day', amount: 7 }, }) +const agentDatabaseCell = (database_identifier?: string): AgentCell => ({ + _tag: 'database_cell', + sql: 'select 1', + row_limit: 100, + database_identifier, +}) + describe('getEntryKey', () => { it('keys unchanged, removed, and moved entries off the cell id', () => { expect(getEntryKey({ _tag: 'unchanged', cell: wireMarkdownCell('cell-1') })).toBe('cell-1') @@ -128,6 +136,39 @@ describe('getCellMetadataLine', () => { }) }) +describe('getEntryMetadataLine', () => { + it('uses the cell metadata for non-replaced entries', () => { + expect( + getEntryMetadataLine({ + _tag: 'unchanged', + cell: wireDatabaseCell('cell-1', 'Signups', 'replica-3'), + }) + ).toBe('Database: replica-3') + }) + + it('returns a before → after pair when a replacement changes only metadata', () => { + expect( + getEntryMetadataLine({ + _tag: 'replaced', + before: wireDatabaseCell('cell-1', 'Signups', 'primary'), + after: agentDatabaseCell('replica-3'), + operationIndex: 0, + }) + ).toBe('Database: primary → Database: replica-3') + }) + + it('returns a single line when replacement metadata is unchanged', () => { + expect( + getEntryMetadataLine({ + _tag: 'replaced', + before: wireDatabaseCell('cell-1', 'Signups', 'primary'), + after: agentDatabaseCell('primary'), + operationIndex: 0, + }) + ).toBe('Database: primary') + }) +}) + describe('summarizeNotebookDiff / formatNotebookDiffSummary', () => { it('formats create mode with pluralized cell count', () => { const entries: NotebookCellDiffEntry[] = [ diff --git a/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.utils.ts b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.ts similarity index 89% rename from apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.utils.ts rename to apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.ts index 11959be18c817..b8aefa9bd82f8 100644 --- a/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.utils.ts +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.ts @@ -20,15 +20,6 @@ export function getEntryKey(entry: NotebookCellDiffEntry): string { } } -/** - * Whether a diff entry starts expanded. Only the entries the user has to actually read to - * decide — the ones whose content the assistant is proposing — open on their own; unchanged, - * moved, and removed cells stay as single rows until asked for. - */ -export function isEntryExpandedByDefault(entry: NotebookCellDiffEntry): boolean { - return entry._tag === 'added' || entry._tag === 'replaced' -} - /** Human label for a collapsed/badge row. */ export function getCellLabel(cell: OperationResultCell): string { switch (cell._tag) { @@ -99,6 +90,18 @@ export function getCellMetadataLine(cell: OperationResultCell): string | null { } } +/** Header metadata for a diff row, including a before → after pair on replacements. */ +export function getEntryMetadataLine(entry: NotebookCellDiffEntry): string | null { + if (entry._tag !== 'replaced') { + return getCellMetadataLine(entry.cell) + } + + const beforeMetadata = getCellMetadataLine(entry.before) + const afterMetadata = getCellMetadataLine(entry.after) + if (beforeMetadata === afterMetadata) return afterMetadata + return `${beforeMetadata ?? 'No metadata'} → ${afterMetadata ?? 'No metadata'}` +} + export type NotebookDiffSummary = | { mode: 'create'; cellCount: number } | { mode: 'update'; counts: { added: number; removed: number; replaced: number; moved: number } } diff --git a/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreviewCell.tsx b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreviewCell.tsx similarity index 67% rename from apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreviewCell.tsx rename to apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreviewCell.tsx index 67153618e7b15..908bd0be12456 100644 --- a/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreviewCell.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreviewCell.tsx @@ -1,4 +1,4 @@ -import { ChevronRight } from 'lucide-react' +import { ChevronRight, FileText, SquareCode } from 'lucide-react' import { cn, Collapsible, @@ -13,18 +13,20 @@ import { CodeBlock } from 'ui-patterns/CodeBlock' import { getCellCodeBlockLanguage, getCellLabel, - getCellMetadataLine, getCellMonacoLanguage, getCellSourceText, -} from './NotebookPreview.utils' + getEntryMetadataLine, +} from './AssistantNotebookPreview.utils' import { DiffEditor } from '@/components/ui/DiffEditor' import type { NotebookCellDiffEntry } from '@/data/content/notebooks/notebook-operations' import type { AgentCell, CellWire } from '@/data/content/notebooks/notebook-schema' -export interface NotebookPreviewCellProps { +export interface AssistantNotebookPreviewCellProps { entry: NotebookCellDiffEntry isExpanded: boolean onExpandedChange: (isExpanded: boolean) => void + /** Create previews omit per-row change glyphs — every cell is an add. */ + mode: 'create' | 'update' } /** @@ -47,37 +49,46 @@ function getEntryCell(entry: NotebookCellDiffEntry): CellWire | AgentCell { } /** One row of the diff card: a header line that collapses to a single row, plus its content. */ -export const NotebookPreviewCell = ({ +export const AssistantNotebookPreviewCell = ({ entry, isExpanded, onExpandedChange, -}: NotebookPreviewCellProps) => { + mode, +}: AssistantNotebookPreviewCellProps) => { const marker = CHANGE_MARKERS[entry._tag] const isRemoved = entry._tag === 'removed' - const label = getCellLabel(getEntryCell(entry)) + const cell = getEntryCell(entry) + const label = getCellLabel(cell) + const metadata = getEntryMetadataLine(entry) return ( - + {mode === 'update' && } + {label} + {metadata && ( + + {metadata} + + )} -
+
@@ -108,44 +119,27 @@ const ChangeGlyph = ({ marker }: { marker: ChangeMarker }) => { ) } +/** Same icons the notebook tab uses for query vs markdown cells. */ +const CellTypeIcon = ({ cell }: { cell: CellWire | AgentCell }) => { + const Icon = cell._tag === 'markdown_cell' ? FileText : SquareCode + return +} + const CellBody = ({ entry }: { entry: NotebookCellDiffEntry }) => entry._tag === 'replaced' ? ( ) : ( - <> - - - + ) -/** - * A `replace_cell` can change only the source parameters (`database_identifier`, - * `time_range`) and leave `sql`/`text` identical — the `DiffEditor` above would then show no - * change at all, so the metadata is compared independently and rendered as its own - * before → after line whenever it differs. - */ -const ReplacedCellBody = ({ before, after }: { before: CellWire; after: AgentCell }) => { - const beforeMetadata = getCellMetadataLine(before) - const afterMetadata = getCellMetadataLine(after) - - return ( - <> - - {beforeMetadata !== afterMetadata ? ( - - ) : ( - - )} - - ) -} +const ReplacedCellBody = ({ before, after }: { before: CellWire; after: AgentCell }) => ( + +) /** * The cell's source, always rendered as literal text via `CodeBlock` — agent-authored markdown @@ -154,14 +148,11 @@ const ReplacedCellBody = ({ before, after }: { before: CellWire; after: AgentCel const CellSource = ({ cell }: { cell: CellWire | AgentCell }) => ( ) - -/** A plain-text metadata line for a query cell — never rendered as a link or attribute. */ -const MetadataLine = ({ text }: { text: string | null }) => - text ?

{text}

: null diff --git a/apps/studio/components/ui/AIAssistantPanel/Confirm.tsx b/apps/studio/components/ui/AIAssistantPanel/Confirm.tsx index 8282bdce2ae3c..a3a7d26c975ec 100644 --- a/apps/studio/components/ui/AIAssistantPanel/Confirm.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/Confirm.tsx @@ -10,6 +10,8 @@ interface ConfirmFooterProps { confirmLabelLoading?: string isLoading?: boolean isDisabled?: boolean + /** Omit the confirm button so only Skip remains (unparseable / unapplyable previews). */ + denyOnly?: boolean /** Escape hatch for consumers that attach the bar directly under their own frame. */ className?: string onCancel?: () => void | Promise @@ -24,6 +26,7 @@ export const ConfirmFooter = ({ confirmLabelLoading = 'Working...', isLoading = false, isDisabled = false, + denyOnly = false, className, onCancel, onConfirm, @@ -33,20 +36,21 @@ export const ConfirmFooter = ({ return (
-
{message}
-
+
{message}
+
- + {!denyOnly && ( + + )}
) @@ -66,18 +70,21 @@ interface ConfirmProps { isLoading?: boolean /** * Children fill the remaining height of the card (e.g. `QueryEditor` in viewport - * mode). Omit for content-sized bodies like notebook previews. + * mode). Omit for content-sized bodies like notebook previews and Edge Function blocks. */ fill?: boolean className?: string + /** Omit the confirm button so only Skip remains. */ + denyOnly?: boolean onCancel?: () => void | Promise onConfirm?: () => void | Promise } /** * Card that wraps an assistant tool preview and optionally attaches a confirm footer - * below it. The card owns the frame; nested surfaces (QueryEditor viewport, unframed - * edge-function blocks) fill the body. + * below it. The card owns the frame; the footer hangs inset from the sides so it reads + * as tucked under the card. Nested surfaces (QueryEditor viewport, unframed notebook + * previews, Edge Function blocks) fill the body. */ export const Confirm = ({ children, @@ -90,6 +97,7 @@ export const Confirm = ({ isLoading = false, fill = false, className, + denyOnly = false, onCancel, onConfirm, }: PropsWithChildren) => { @@ -100,13 +108,14 @@ export const Confirm = ({ return (
-
+
{children}
{bar.show && ( @@ -117,6 +126,7 @@ export const Confirm = ({ confirmLabelLoading={confirmLabelLoading} isLoading={showLoading} isDisabled={!isApprovalRequested} + denyOnly={denyOnly} onCancel={onCancel} onConfirm={onConfirm} /> diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx index 62cd84b4ba2b5..e6ea99e7623a4 100644 --- a/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx @@ -249,7 +249,11 @@ function MessagePartNotebookProposal({ return

{NOTEBOOK_FAILED_LABEL[mode]}

} - const approvalId = state === 'approval-requested' ? toolPart.approval?.id : undefined + const { confirmState, onApprove, onDeny } = getManualToolApprovalHandlers({ + state, + approval: toolPart.approval, + addToolApprovalResponse, + }) return ( addToolApprovalResponse?.({ id: approvalId, approved: true }) : undefined - } - onDeny={ - approvalId - ? () => addToolApprovalResponse?.({ id: approvalId, approved: false }) - : undefined - } + confirmState={confirmState} + onApprove={onApprove} + onDeny={onDeny} /> ) } diff --git a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx index 61f5de48c25d3..5d591f90644da 100644 --- a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.test.tsx @@ -51,6 +51,7 @@ describe('NotebookProposalRenderer', () => { { /> ) + expect(screen.getByRole('toolbar', { name: 'Notebook toolbar' })).toBeInTheDocument() expect(screen.getByText('1 cell')).toBeInTheDocument() + expect(screen.getByText('Assistant wants to create this notebook')).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'Create' })) expect(onApprove).toHaveBeenCalledTimes(1) }) @@ -78,6 +81,7 @@ describe('NotebookProposalRenderer', () => { { { it('falls back to a raw-input admonition without dropping the confirm footer on a parse failure', async () => { const user = userEvent.setup() + const onApprove = vi.fn() const onDeny = vi.fn() render( ) expect(screen.getByText("Couldn't render a preview for this notebook")).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Create' })).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Skip' })) + expect(onDeny).toHaveBeenCalledTimes(1) + expect(onApprove).not.toHaveBeenCalled() + }) + + it('withholds Apply changes when the update cannot be applied as written', async () => { + const user = userEvent.setup() + const onApprove = vi.fn() + const onDeny = vi.fn() + mockContentItem(mockNotebookRow()) + + render( + + ) + + expect(await screen.findByText("This update can't be applied as written")).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Apply changes' })).not.toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'Skip' })) expect(onDeny).toHaveBeenCalledTimes(1) + expect(onApprove).not.toHaveBeenCalled() }) it('renders an Open notebook link once output is available', () => { @@ -154,11 +192,25 @@ describe('NotebookProposalRenderer', () => { expect(link).toHaveAttribute('href', `/project/default/explorer/notebook/${NOTEBOOK_ID}`) }) - it('renders a muted skipped summary when output-denied', () => { + it('keeps the preview in the message after skip', () => { render( - + ) - expect(screen.getByText('Skipped notebook update')).toBeInTheDocument() + expect(screen.getByRole('toolbar', { name: 'Notebook toolbar' })).toBeInTheDocument() + expect(screen.getByText('New notebook')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Create' })).not.toBeInTheDocument() + expect(screen.queryByText('Skipped notebook creation')).not.toBeInTheDocument() }) }) diff --git a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx index 9a9ea60455171..0f2c665de5106 100644 --- a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx @@ -1,17 +1,19 @@ import { useParams } from 'common' import { Loader2 } from 'lucide-react' import Link from 'next/link' -import { Button, cn } from 'ui' +import { type PropsWithChildren } from 'react' +import { Button } from 'ui' import { Admonition } from 'ui-patterns/Admonition' import { CodeBlock } from 'ui-patterns/CodeBlock' -import { ConfirmFooter } from './Confirm' +import { AssistantNotebookPreview } from './AssistantNotebookPreview' +import { Confirm } from './Confirm' +import { type ConfirmFooterApprovalState } from './Confirm.utils' import { createNotebookInputSchema, notebookToolOutputSchema, updateNotebookInputSchema, } from './Message.utils' -import { NotebookPreview } from '@/components/interfaces/Explorer/NotebookPreview/NotebookPreview' import { AlertError } from '@/components/ui/AlertError' import { deriveNotebookDiff, @@ -24,7 +26,7 @@ import { toWireNotebook } from '@/data/content/notebooks/notebook-schema' export type NotebookProposalMode = 'create' | 'update' // input-streaming and output-error are handled by the caller before this component is -// rendered — see MessagePartCreateNotebook / MessagePartUpdateNotebook in Message.Parts.tsx. +// rendered — see MessagePartNotebookProposal in Message.Parts.tsx. export type NotebookProposalState = | 'input-available' | 'approval-requested' @@ -37,25 +39,25 @@ export interface NotebookProposalRendererProps { state: NotebookProposalState input: unknown output: unknown + /** Result of `getManualToolApprovalConfirmState`. Drives the Confirm footer. */ + confirmState?: ConfirmFooterApprovalState onApprove?: () => void onDeny?: () => void } -type NotebookProposalStepProps = Omit +type NotebookProposalStepProps = Omit const MODE_COPY = { create: { confirmMessage: 'Assistant wants to create this notebook', confirmLabel: 'Create', confirmLabelLoading: 'Creating...', - skippedLabel: 'Skipped notebook creation', outputLabel: 'Notebook created', }, update: { confirmMessage: 'Assistant wants to update this notebook', confirmLabel: 'Apply changes', confirmLabelLoading: 'Applying changes...', - skippedLabel: 'Skipped notebook update', outputLabel: 'Notebook updated', }, } as const @@ -63,26 +65,31 @@ const MODE_COPY = { /** * Renders the create/update notebook tool across all approval states. Owns input parsing * (via the shared `agentNotebookSchema` / `notebookOperationsSchema`), the update-mode diff - * fetch, and the `expected_updated_at` version check. + * fetch, and the `expected_updated_at` version check. Wraps the preview in `Confirm` the + * same way `AssistantQueryCell` wraps `QueryEditor`. */ export const NotebookProposalRenderer = (props: NotebookProposalRendererProps) => { const { mode, state, output } = props - if (state === 'output-denied') { - return ( -

{MODE_COPY[mode].skippedLabel}

- ) - } - if (state === 'output-available') { return } - const { input, onApprove, onDeny } = props + const { input, confirmState, onApprove, onDeny } = props return mode === 'create' ? ( - + ) : ( - + ) } @@ -107,95 +114,91 @@ function NotebookOutputSummary({ mode, output }: { mode: NotebookProposalMode; o ) } -interface NotebookConfirmFooterProps { +interface NotebookConfirmProps { mode: NotebookProposalMode - state: NotebookProposalState + confirmState?: ConfirmFooterApprovalState message?: string confirmLabel?: string confirmLabelLoading?: string extraLoading?: boolean + denyOnly?: boolean onApprove?: () => void onDeny?: () => void } -/** - * `ConfirmFooter` now ships bare so the `Confirm` card can own the frame, so this renderer - * supplies its own flush-under-block border. The block above still has to square off its - * bottom corners and the two must not be gapped apart. - */ -const GLUED_TO_FOOTER = 'rounded-b-none' -const FLUSH_UNDER_BLOCK = 'border border-t-0 rounded-b-lg' - -function hasConfirmFooter(state: NotebookProposalState) { - return state === 'approval-requested' || state === 'approval-responded' -} - -/** The footer morphs (label + disabled) across approval-requested/approval-responded and is absent otherwise. */ -function NotebookConfirmFooter({ +/** Confirm card around a notebook preview, matching AssistantQueryCell / EdgeFunctionRenderer. */ +function NotebookConfirm({ mode, - state, + confirmState, message, confirmLabel, confirmLabelLoading, - extraLoading = false, + extraLoading, + denyOnly, onApprove, onDeny, -}: NotebookConfirmFooterProps) { - if (!hasConfirmFooter(state)) return null - + children, +}: PropsWithChildren) { const copy = MODE_COPY[mode] - const isApprovalRequested = state === 'approval-requested' return ( - + extraLoading={extraLoading} + denyOnly={denyOnly} + onCancel={onDeny} + onConfirm={denyOnly ? undefined : onApprove} + > + {children} + ) } function NotebookParseFailure({ mode, - state, + confirmState, input, - onApprove, onDeny, -}: Pick) { +}: Pick) { return ( -
- - - -
+ +
+ + +
+
) } -function CreateNotebookProposal({ state, input, onApprove, onDeny }: NotebookProposalStepProps) { +function CreateNotebookProposal({ + input, + confirmState, + onApprove, + onDeny, +}: NotebookProposalStepProps) { const parsedInput = createNotebookInputSchema.safeParse(input) if (!parsedInput.success) { return ( ) @@ -210,19 +213,23 @@ function CreateNotebookProposal({ state, input, onApprove, onDeny }: NotebookPro ) return ( -
- - -
+ + + ) } -function UpdateNotebookProposal({ state, input, onApprove, onDeny }: NotebookProposalStepProps) { +function UpdateNotebookProposal({ + input, + confirmState, + onApprove, + onDeny, +}: NotebookProposalStepProps) { const { ref } = useParams() const parsedInput = updateNotebookInputSchema.safeParse(input) @@ -242,9 +249,8 @@ function UpdateNotebookProposal({ state, input, onApprove, onDeny }: NotebookPro return ( ) @@ -263,12 +269,12 @@ function UpdateNotebookProposal({ state, input, onApprove, onDeny }: NotebookPro return (
- {(state === 'approval-requested' || state === 'approval-responded') && ( + {confirmState !== undefined && (
+
+
+ {oauthBlocks.title} +
+ {oauthBlocks.items.map((item, i) => ( + + ))} +
{platformBlocks.title} diff --git a/apps/ui-library/config/docs.ts b/apps/ui-library/config/docs.ts index e0585e27e78da..3e496318a66fb 100644 --- a/apps/ui-library/config/docs.ts +++ b/apps/ui-library/config/docs.ts @@ -36,6 +36,20 @@ export const platformBlocks: SidebarNavGroup = { ], } +export const oauthBlocks: SidebarNavGroup = { + title: 'OAuth', + items: [ + { + title: 'OAuth Consent', + supportedFrameworks: ['nextjs', 'react-router', 'tanstack', 'react'], + href: '/docs/nextjs/oauth-consent', + items: [], + new: true, + commandItemLabel: 'OAuth Consent', + }, + ], +} + // Component definitions with supported frameworks export const componentPages: SidebarNavGroup = { title: 'Components', @@ -133,6 +147,10 @@ export const COMMAND_ITEMS = [ label: item.commandItemLabel, href: item.href, })), + ...oauthBlocks.items.map((item) => ({ + label: item.commandItemLabel, + href: item.href, + })), ] // Framework titles for display diff --git a/apps/ui-library/content/docs/nextjs/oauth-consent.mdx b/apps/ui-library/content/docs/nextjs/oauth-consent.mdx new file mode 100644 index 0000000000000..ae473e57a1fd1 --- /dev/null +++ b/apps/ui-library/content/docs/nextjs/oauth-consent.mdx @@ -0,0 +1,77 @@ +--- +title: OAuth Consent +description: OAuth 2.1 consent block for Next.js +--- + + + +## Installation + + + +## Folder structure + +This block includes the [Supabase client](/library/docs/nextjs/client). If you already have one installed, you can skip overwriting it. + + + +## Usage + +This block installs an OAuth 2.1 consent route at `/oauth/consent`. It is designed for an app that already has authentication: it does not install sign-in, sign-up, or callback routes. + +When the visitor has no session, the consent screen redirects to `/auth/login` and preserves the original consent URL in the `next` query parameter. Update the `signInPath` prop in `app/oauth/consent/page.tsx` if your sign-in route is different. + +After sign-in, your login page must send the visitor back to the path in `next`; otherwise the OAuth flow stops at your login screen. The password-based auth and social auth blocks follow `next` automatically. For a custom login page, validate that `next` is a relative path before redirecting to it: + +```ts +// After sign-in succeeds: +const next = new URLSearchParams(window.location.search).get('next') +try { + const nextUrl = new URL(next ?? '/protected', window.location.origin) + router.push( + nextUrl.origin === window.location.origin + ? `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}` + : '/protected' + ) +} catch { + router.push('/protected') +} +``` + +Set the `productName` prop to replace the `Your product` placeholder in the consent header. + +Your middleware must let unauthenticated requests reach `/oauth/consent`. The middleware in `lib/supabase/middleware.ts` exempts only `/oauth/consent`. Middleware that redirects to a login page before the consent route runs drops the authorization and ends the OAuth flow at your login screen. + +### Getting started + +After installing the block, you'll have the following environment variables in your `.env.local` file: + +```env +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY= +``` + +- If you're using supabase.com, you can find these values in the [Connect modal](https://supabase.com/dashboard/project/_?showConnect=true&connectTab=frameworks&framework=nextjs) under App Frameworks or in your project's [API settings](https://supabase.com/dashboard/project/_/settings/api). +- If you're using a local instance of Supabase, you can find these values by running `supabase start` or `supabase status` (if you already have it running). + +### Configure the OAuth server + +Enable the OAuth server in the Supabase Dashboard under **Authentication** > **OAuth Server**, then set its authorization URL path to `/oauth/consent`. For local development, set the following in `supabase/config.toml`: + +```toml +[auth.oauth_server] +enabled = true +authorization_url_path = "/oauth/consent" +``` + +Recent CLI versions already write an `[auth.oauth_server]` section with `enabled = false`. Edit that section rather than adding a second one, which fails with `table oauth_server already exists`. + +The route expects the `authorization_id` query parameter that Supabase Auth supplies during the authorization flow. + +## Further reading + +- [OAuth Server](https://supabase.com/docs/guides/auth/oauth-server) +- [Supabase Auth](https://supabase.com/docs/guides/auth) diff --git a/apps/ui-library/content/docs/react-router/oauth-consent.mdx b/apps/ui-library/content/docs/react-router/oauth-consent.mdx new file mode 100644 index 0000000000000..755a79b25b175 --- /dev/null +++ b/apps/ui-library/content/docs/react-router/oauth-consent.mdx @@ -0,0 +1,77 @@ +--- +title: OAuth Consent +description: OAuth 2.1 consent block for React Router +--- + + + +## Installation + + + +## Folder structure + +This block includes the [Supabase client](/library/docs/react-router/client). If you already have one installed, you can skip overwriting it. + + + +## Usage + +This block installs an OAuth 2.1 consent route at `/oauth/consent`. It is designed for an app that already has authentication: it does not install sign-in, sign-up, or callback routes. + +When the visitor has no session, the consent screen redirects to `/auth/login` and preserves the original consent URL in the `next` query parameter. Update the `signInPath` prop in `app/routes/oauth.consent.tsx` if your sign-in route is different. + +After sign-in, your login route must send the visitor back to the path in `next`; otherwise the OAuth flow stops at your login screen. The password-based auth and social auth blocks follow `next` automatically. For a custom login route, pass `next` through the form (for example with a hidden input), validate that it is a relative path, and redirect to it from the action: + +```ts +// In the login action, after sign-in succeeds: +const origin = new URL(request.url).origin +const next = formData.get('next') +try { + const nextUrl = new URL(typeof next === 'string' ? next : '/protected', origin) + return redirect( + nextUrl.origin === origin + ? `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}` + : '/protected', + { headers } + ) +} catch { + return redirect('/protected', { headers }) +} +``` + +Set the `productName` prop to replace the `Your product` placeholder in the consent header. + +### Getting started + +After installing the block, you'll have the following environment variables in your `.env.local` file: + +```env +VITE_SUPABASE_URL= +VITE_SUPABASE_PUBLISHABLE_KEY= +``` + +- If you're using supabase.com, you can find these values in the [Connect modal](https://supabase.com/dashboard/project/_?showConnect=true&connectTab=frameworks&framework=react&using=vite&with=supabasejs) under App Frameworks or in your project's [API settings](https://supabase.com/dashboard/project/_/settings/api). +- If you're using a local instance of Supabase, you can find these values by running `supabase start` or `supabase status` (if you already have it running). + +### Configure the OAuth server + +Enable the OAuth server in the Supabase Dashboard under **Authentication** > **OAuth Server**, then set its authorization URL path to `/oauth/consent`. For local development, set the following in `supabase/config.toml`: + +```toml +[auth.oauth_server] +enabled = true +authorization_url_path = "/oauth/consent" +``` + +Recent CLI versions already write an `[auth.oauth_server]` section with `enabled = false`. Edit that section rather than adding a second one, which fails with `table oauth_server already exists`. + +The route expects the `authorization_id` query parameter that Supabase Auth supplies during the authorization flow. + +## Further reading + +- [OAuth Server](https://supabase.com/docs/guides/auth/oauth-server) +- [Supabase Auth](https://supabase.com/docs/guides/auth) diff --git a/apps/ui-library/content/docs/react/oauth-consent.mdx b/apps/ui-library/content/docs/react/oauth-consent.mdx new file mode 100644 index 0000000000000..b3caac21784f7 --- /dev/null +++ b/apps/ui-library/content/docs/react/oauth-consent.mdx @@ -0,0 +1,93 @@ +--- +title: OAuth Consent +description: OAuth 2.1 consent block for React single-page applications +--- + + + +## Installation + + + +## Folder structure + +This block includes the [Supabase client](/library/docs/react/client). If you already have one installed, you can skip overwriting it. + + + +## Usage + +This block provides an OAuth 2.1 consent component for an app that already has authentication. It does not install sign-in, sign-up, callback, or router-specific routes. + +Render the component from your existing `/oauth/consent` route and pass the `authorization_id` query parameter. Set `signInPath` to the sign-in route in your app: + +```tsx +import { OAuthConsent } from '@/components/oauth-consent' + +export function ConsentPage() { + const authorizationId = new URLSearchParams(window.location.search).get('authorization_id') + + return ( +
+ +
+ ) +} +``` + +When the visitor has no session, the consent screen redirects to `signInPath` and preserves the original consent URL in the `next` query parameter. After sign-in, your login page must send the visitor back to the path in `next`; otherwise the OAuth flow stops at your login screen. The password-based auth block follows `next` automatically. For a custom login page, validate that `next` is a relative path before redirecting to it: + +```ts +// After sign-in succeeds: +const next = new URLSearchParams(window.location.search).get('next') +try { + const nextUrl = new URL(next ?? '/protected', window.location.origin) + location.href = + nextUrl.origin === window.location.origin + ? `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}` + : '/protected' +} catch { + location.href = '/protected' +} +``` + +Set the `productName` prop to replace the `Your product` placeholder in the consent header. + +### Getting started + +After installing the block, you'll have the following environment variables in your `.env.local` file: + +```env +VITE_SUPABASE_URL= +VITE_SUPABASE_PUBLISHABLE_KEY= +``` + +- If you're using supabase.com, you can find these values in the [Connect modal](https://supabase.com/dashboard/project/_?showConnect=true&connectTab=frameworks&framework=react&using=vite&with=supabasejs) under App Frameworks or in your project's [API settings](https://supabase.com/dashboard/project/_/settings/api). +- If you're using a local instance of Supabase, you can find these values by running `supabase start` or `supabase status` (if you already have it running). + +### Configure the OAuth server + +Enable the OAuth server in the Supabase Dashboard under **Authentication** > **OAuth Server**, then set its authorization URL path to `/oauth/consent`. For local development, set the following in `supabase/config.toml`: + +```toml +[auth.oauth_server] +enabled = true +authorization_url_path = "/oauth/consent" +``` + +Recent CLI versions already write an `[auth.oauth_server]` section with `enabled = false`. Edit that section rather than adding a second one, which fails with `table oauth_server already exists`. + +The route expects the `authorization_id` query parameter that Supabase Auth supplies during the authorization flow. + +## Further reading + +- [OAuth Server](https://supabase.com/docs/guides/auth/oauth-server) +- [Supabase Auth](https://supabase.com/docs/guides/auth) diff --git a/apps/ui-library/content/docs/tanstack/oauth-consent.mdx b/apps/ui-library/content/docs/tanstack/oauth-consent.mdx new file mode 100644 index 0000000000000..01e57232cac3d --- /dev/null +++ b/apps/ui-library/content/docs/tanstack/oauth-consent.mdx @@ -0,0 +1,69 @@ +--- +title: OAuth Consent +description: OAuth 2.1 consent block for TanStack Start +--- + + + +## Installation + + + +## Folder structure + +This block includes the [Supabase client](/library/docs/tanstack/client). If you already have one installed, you can skip overwriting it. + + + +## Usage + +This block installs an OAuth 2.1 consent route at `/oauth/consent`. It is designed for an app that already has authentication: it does not install sign-in, sign-up, or callback routes. + +When the visitor has no session, the consent screen redirects to `/auth/login` and preserves the original consent URL in the `next` query parameter. Update the `signInPath` prop in `routes/oauth/consent.tsx` if your sign-in route is different. + +After sign-in, your login page must send the visitor back to the path in `next`; otherwise the OAuth flow stops at your login screen. The password-based auth and social auth blocks follow `next` automatically. For a custom login page, validate that `next` is a relative path before redirecting to it: + +```ts +// After sign-in succeeds: +const next = new URLSearchParams(window.location.search).get('next') +if (next?.startsWith('/') && !next.startsWith('//')) { + // `next` may point outside the typed route tree, so use a full navigation. + window.location.assign(next) +} +``` + +Set the `productName` prop to replace the `Your product` placeholder in the consent header. + +### Getting started + +After installing the block, you'll have the following environment variables in your `.env.local` file: + +```env +VITE_SUPABASE_URL= +VITE_SUPABASE_PUBLISHABLE_KEY= +``` + +- If you're using supabase.com, you can find these values in the [Connect modal](https://supabase.com/dashboard/project/_?showConnect=true&connectTab=frameworks&framework=react&using=vite&with=supabasejs) under App Frameworks or in your project's [API settings](https://supabase.com/dashboard/project/_/settings/api). +- If you're using a local instance of Supabase, you can find these values by running `supabase start` or `supabase status` (if you already have it running). + +### Configure the OAuth server + +Enable the OAuth server in the Supabase Dashboard under **Authentication** > **OAuth Server**, then set its authorization URL path to `/oauth/consent`. For local development, set the following in `supabase/config.toml`: + +```toml +[auth.oauth_server] +enabled = true +authorization_url_path = "/oauth/consent" +``` + +Recent CLI versions already write an `[auth.oauth_server]` section with `enabled = false`. Edit that section rather than adding a second one, which fails with `table oauth_server already exists`. + +The route expects the `authorization_id` query parameter that Supabase Auth supplies during the authorization flow. + +## Further reading + +- [OAuth Server](https://supabase.com/docs/guides/auth/oauth-server) +- [Supabase Auth](https://supabase.com/docs/guides/auth) diff --git a/apps/ui-library/public/r/current-user-avatar-nextjs.json b/apps/ui-library/public/r/current-user-avatar-nextjs.json index 44046fecd08c4..df4744d15128d 100644 --- a/apps/ui-library/public/r/current-user-avatar-nextjs.json +++ b/apps/ui-library/public/r/current-user-avatar-nextjs.json @@ -34,7 +34,7 @@ }, { "path": "registry/default/clients/nextjs/lib/supabase/middleware.ts", - "content": "import { createServerClient } from '@supabase/ssr'\nimport { NextResponse, type NextRequest } from 'next/server'\n\nexport async function updateSession(request: NextRequest) {\n let supabaseResponse = NextResponse.next({\n request,\n })\n\n // With Fluid compute, don't put this client in a global environment\n // variable. Always create a new one on each request.\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,\n {\n cookies: {\n getAll() {\n return request.cookies.getAll()\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))\n supabaseResponse = NextResponse.next({\n request,\n })\n cookiesToSet.forEach(({ name, value, options }) =>\n supabaseResponse.cookies.set(name, value, options)\n )\n },\n },\n }\n )\n\n // Do not run code between createServerClient and\n // supabase.auth.getClaims(). A simple mistake could make it very hard to debug\n // issues with users being randomly logged out.\n\n // IMPORTANT: If you remove getClaims() and you use server-side rendering\n // with the Supabase client, your users may be randomly logged out.\n const { data } = await supabase.auth.getClaims()\n const user = data?.claims\n\n if (\n !user &&\n !request.nextUrl.pathname.startsWith('/login') &&\n !request.nextUrl.pathname.startsWith('/auth')\n ) {\n // no user, potentially respond by redirecting the user to the login page\n const url = request.nextUrl.clone()\n url.pathname = '/auth/login'\n return NextResponse.redirect(url)\n }\n\n // IMPORTANT: You *must* return the supabaseResponse object as it is.\n // If you're creating a new response object with NextResponse.next() make sure to:\n // 1. Pass the request in it, like so:\n // const myNewResponse = NextResponse.next({ request })\n // 2. Copy over the cookies, like so:\n // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())\n // 3. Change the myNewResponse object to fit your needs, but avoid changing\n // the cookies!\n // 4. Finally:\n // return myNewResponse\n // If this is not done, you may be causing the browser and server to go out\n // of sync and terminate the user's session prematurely!\n\n return supabaseResponse\n}\n", + "content": "import { createServerClient } from '@supabase/ssr'\nimport { NextResponse, type NextRequest } from 'next/server'\n\nexport async function updateSession(request: NextRequest) {\n let supabaseResponse = NextResponse.next({\n request,\n })\n\n // With Fluid compute, don't put this client in a global environment\n // variable. Always create a new one on each request.\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,\n {\n cookies: {\n getAll() {\n return request.cookies.getAll()\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))\n supabaseResponse = NextResponse.next({\n request,\n })\n cookiesToSet.forEach(({ name, value, options }) =>\n supabaseResponse.cookies.set(name, value, options)\n )\n },\n },\n }\n )\n\n // Do not run code between createServerClient and\n // supabase.auth.getClaims(). A simple mistake could make it very hard to debug\n // issues with users being randomly logged out.\n\n // IMPORTANT: If you remove getClaims() and you use server-side rendering\n // with the Supabase client, your users may be randomly logged out.\n const { data } = await supabase.auth.getClaims()\n const user = data?.claims\n\n if (\n !user &&\n !request.nextUrl.pathname.startsWith('/login') &&\n !request.nextUrl.pathname.startsWith('/auth') &&\n // the OAuth consent route sends unauthenticated visitors to the login page\n // itself, so that it can preserve the authorization in the `next` parameter\n request.nextUrl.pathname !== '/oauth/consent'\n ) {\n // no user, potentially respond by redirecting the user to the login page\n const url = request.nextUrl.clone()\n url.pathname = '/auth/login'\n return NextResponse.redirect(url)\n }\n\n // IMPORTANT: You *must* return the supabaseResponse object as it is.\n // If you're creating a new response object with NextResponse.next() make sure to:\n // 1. Pass the request in it, like so:\n // const myNewResponse = NextResponse.next({ request })\n // 2. Copy over the cookies, like so:\n // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())\n // 3. Change the myNewResponse object to fit your needs, but avoid changing\n // the cookies!\n // 4. Finally:\n // return myNewResponse\n // If this is not done, you may be causing the browser and server to go out\n // of sync and terminate the user's session prematurely!\n\n return supabaseResponse\n}\n", "type": "registry:lib" }, { diff --git a/apps/ui-library/public/r/dropzone-nextjs.json b/apps/ui-library/public/r/dropzone-nextjs.json index a7db28470b1a1..a3bdfc16603ad 100644 --- a/apps/ui-library/public/r/dropzone-nextjs.json +++ b/apps/ui-library/public/r/dropzone-nextjs.json @@ -31,7 +31,7 @@ }, { "path": "registry/default/clients/nextjs/lib/supabase/middleware.ts", - "content": "import { createServerClient } from '@supabase/ssr'\nimport { NextResponse, type NextRequest } from 'next/server'\n\nexport async function updateSession(request: NextRequest) {\n let supabaseResponse = NextResponse.next({\n request,\n })\n\n // With Fluid compute, don't put this client in a global environment\n // variable. Always create a new one on each request.\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,\n {\n cookies: {\n getAll() {\n return request.cookies.getAll()\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))\n supabaseResponse = NextResponse.next({\n request,\n })\n cookiesToSet.forEach(({ name, value, options }) =>\n supabaseResponse.cookies.set(name, value, options)\n )\n },\n },\n }\n )\n\n // Do not run code between createServerClient and\n // supabase.auth.getClaims(). A simple mistake could make it very hard to debug\n // issues with users being randomly logged out.\n\n // IMPORTANT: If you remove getClaims() and you use server-side rendering\n // with the Supabase client, your users may be randomly logged out.\n const { data } = await supabase.auth.getClaims()\n const user = data?.claims\n\n if (\n !user &&\n !request.nextUrl.pathname.startsWith('/login') &&\n !request.nextUrl.pathname.startsWith('/auth')\n ) {\n // no user, potentially respond by redirecting the user to the login page\n const url = request.nextUrl.clone()\n url.pathname = '/auth/login'\n return NextResponse.redirect(url)\n }\n\n // IMPORTANT: You *must* return the supabaseResponse object as it is.\n // If you're creating a new response object with NextResponse.next() make sure to:\n // 1. Pass the request in it, like so:\n // const myNewResponse = NextResponse.next({ request })\n // 2. Copy over the cookies, like so:\n // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())\n // 3. Change the myNewResponse object to fit your needs, but avoid changing\n // the cookies!\n // 4. Finally:\n // return myNewResponse\n // If this is not done, you may be causing the browser and server to go out\n // of sync and terminate the user's session prematurely!\n\n return supabaseResponse\n}\n", + "content": "import { createServerClient } from '@supabase/ssr'\nimport { NextResponse, type NextRequest } from 'next/server'\n\nexport async function updateSession(request: NextRequest) {\n let supabaseResponse = NextResponse.next({\n request,\n })\n\n // With Fluid compute, don't put this client in a global environment\n // variable. Always create a new one on each request.\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,\n {\n cookies: {\n getAll() {\n return request.cookies.getAll()\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))\n supabaseResponse = NextResponse.next({\n request,\n })\n cookiesToSet.forEach(({ name, value, options }) =>\n supabaseResponse.cookies.set(name, value, options)\n )\n },\n },\n }\n )\n\n // Do not run code between createServerClient and\n // supabase.auth.getClaims(). A simple mistake could make it very hard to debug\n // issues with users being randomly logged out.\n\n // IMPORTANT: If you remove getClaims() and you use server-side rendering\n // with the Supabase client, your users may be randomly logged out.\n const { data } = await supabase.auth.getClaims()\n const user = data?.claims\n\n if (\n !user &&\n !request.nextUrl.pathname.startsWith('/login') &&\n !request.nextUrl.pathname.startsWith('/auth') &&\n // the OAuth consent route sends unauthenticated visitors to the login page\n // itself, so that it can preserve the authorization in the `next` parameter\n request.nextUrl.pathname !== '/oauth/consent'\n ) {\n // no user, potentially respond by redirecting the user to the login page\n const url = request.nextUrl.clone()\n url.pathname = '/auth/login'\n return NextResponse.redirect(url)\n }\n\n // IMPORTANT: You *must* return the supabaseResponse object as it is.\n // If you're creating a new response object with NextResponse.next() make sure to:\n // 1. Pass the request in it, like so:\n // const myNewResponse = NextResponse.next({ request })\n // 2. Copy over the cookies, like so:\n // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())\n // 3. Change the myNewResponse object to fit your needs, but avoid changing\n // the cookies!\n // 4. Finally:\n // return myNewResponse\n // If this is not done, you may be causing the browser and server to go out\n // of sync and terminate the user's session prematurely!\n\n return supabaseResponse\n}\n", "type": "registry:lib" }, { diff --git a/apps/ui-library/public/r/oauth-consent-nextjs.json b/apps/ui-library/public/r/oauth-consent-nextjs.json new file mode 100644 index 0000000000000..a7c139492f062 --- /dev/null +++ b/apps/ui-library/public/r/oauth-consent-nextjs.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "oauth-consent-nextjs", + "type": "registry:block", + "title": "OAuth Consent for Next.js", + "description": "OAuth 2.1 consent screen and route for an existing authenticated application.", + "dependencies": [ + "@supabase/ssr@latest", + "@supabase/supabase-js@latest" + ], + "registryDependencies": [ + "button", + "card", + "https://supabase.com/library/r/safe-next-path.json" + ], + "files": [ + { + "path": "registry/default/blocks/oauth-consent/components/oauth-consent.tsx", + "content": "'use client'\n\nimport { cn } from '@/lib/utils'\nimport {\n useOAuthConsent,\n type OAuthConsentDecision,\n} from '@/registry/default/blocks/oauth-consent/hooks/use-oauth-consent'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\n\nconst getInitial = (value: string) => value.trim().charAt(0).toUpperCase() || '?'\n\ninterface ConsentCardShellProps extends React.ComponentPropsWithoutRef<'div'> {\n clientName: string\n productName: string\n}\n\nfunction ConsentCardShell({\n clientName,\n productName,\n className,\n children,\n ...props\n}: ConsentCardShellProps) {\n return (\n
\n \n \n \n
\n {getInitial(clientName)}\n
\n
\n
\n {getInitial(productName)}\n
\n
\n
\n Authorize {clientName}\n Review what this client gets access to.\n
\n
\n {children}\n
\n
\n )\n}\n\nexport interface OAuthConsentCardProps extends React.ComponentPropsWithoutRef<'div'> {\n clientName: string\n productName?: string\n redirectUri: string\n email: string\n scopes?: string[]\n error?: string | null\n decision?: OAuthConsentDecision | null\n onApprove?: () => void\n onDeny?: () => void\n}\n\nexport function OAuthConsentCard({\n clientName,\n productName = 'Your product',\n redirectUri,\n email,\n scopes = [],\n error = null,\n decision = null,\n onApprove,\n onDeny,\n ...props\n}: OAuthConsentCardProps) {\n return (\n \n
\n
\n
Client
\n
{clientName}
\n
\n
\n
Redirects to
\n
{redirectUri}
\n
\n
\n
Signed in as
\n
{email}
\n
\n {scopes.length > 0 && (\n
\n
Scopes
\n
{scopes.join(', ')}
\n
\n )}\n
\n

\n Allow access only if you recognize this application. It can act on your behalf only within\n the requested permissions and the access you already have.\n

\n {error && (\n

\n {error}\n

\n )}\n
\n \n \n
\n
\n )\n}\n\ninterface OAuthConsentProps extends React.ComponentPropsWithoutRef<'div'> {\n authorizationId?: string | null\n signInPath?: string\n productName?: string\n}\n\nexport function OAuthConsent({\n authorizationId,\n signInPath = '/auth/login',\n productName = 'Your product',\n ...props\n}: OAuthConsentProps) {\n const { details, email, error, isLoading, decision, approve, deny } = useOAuthConsent({\n authorizationId,\n signInPath,\n })\n\n if (isLoading || !details || !email) {\n return (\n \n {isLoading ? (\n

\n Loading authorization request...\n

\n ) : (\n

\n {error ??\n 'Unable to load the authorization request. Start again from your OAuth client.'}\n

\n )}\n
\n )\n }\n\n return (\n void approve()}\n onDeny={() => void deny()}\n {...props}\n />\n )\n}\n", + "type": "registry:component" + }, + { + "path": "registry/default/blocks/oauth-consent/hooks/use-oauth-consent.ts", + "content": "import { isAuthSessionMissingError } from '@supabase/supabase-js'\nimport type { OAuthAuthorizationDetails } from '@supabase/supabase-js'\nimport { useCallback, useEffect, useRef, useState } from 'react'\n\nimport { safeNextPath } from '@/registry/default/blocks/safe-next-path/lib/safe-next-path'\nimport { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\n\nexport type OAuthConsentDecision = 'approve' | 'deny'\n\nexport interface UseOAuthConsentOptions {\n authorizationId?: string | null\n signInPath?: string\n}\n\nconst withNextParam = (path: string, next: string) => {\n const url = new URL(path, window.location.origin)\n const searchParams = new URLSearchParams(url.search)\n searchParams.set('next', next)\n url.search = searchParams.toString()\n return `${url.pathname}${url.search}${url.hash}`\n}\n\nconst useOAuthConsent = ({\n authorizationId,\n signInPath = '/auth/login',\n}: UseOAuthConsentOptions) => {\n const [details, setDetails] = useState(null)\n const [error, setError] = useState(null)\n const [isLoading, setIsLoading] = useState(true)\n const [decision, setDecision] = useState(null)\n const isDeciding = useRef(false)\n\n useEffect(() => {\n let active = true\n\n const loadAuthorization = async () => {\n setIsLoading(true)\n setError(null)\n setDetails(null)\n setDecision(null)\n\n if (!authorizationId) {\n setError('This page needs an authorization_id. Start again from your OAuth client.')\n setIsLoading(false)\n return\n }\n\n const supabase = createClient()\n const {\n data: { user },\n error: userError,\n } = await supabase.auth.getUser()\n\n if (userError && !isAuthSessionMissingError(userError)) {\n if (active) {\n setError(userError.message)\n setIsLoading(false)\n }\n return\n }\n\n if (!user) {\n const next = `${window.location.pathname}${window.location.search}`\n if (active) {\n window.location.replace(withNextParam(safeNextPath(signInPath, '/auth/login'), next))\n }\n return\n }\n\n const { data, error } = await supabase.auth.oauth.getAuthorizationDetails(authorizationId)\n if (error) {\n if (active) {\n setError(error.message)\n setIsLoading(false)\n }\n return\n }\n\n if (!('authorization_id' in data)) {\n if (active) {\n window.location.replace(data.redirect_url)\n }\n return\n }\n\n if (active) {\n setDetails(data)\n setIsLoading(false)\n }\n }\n\n void loadAuthorization()\n return () => {\n active = false\n }\n }, [authorizationId, signInPath])\n\n const decide = useCallback(\n async (action: OAuthConsentDecision) => {\n if (!authorizationId || isDeciding.current) return\n\n isDeciding.current = true\n setDecision(action)\n setError(null)\n const supabase = createClient()\n const result =\n action === 'approve'\n ? await supabase.auth.oauth.approveAuthorization(authorizationId, {\n skipBrowserRedirect: true,\n })\n : await supabase.auth.oauth.denyAuthorization(authorizationId, {\n skipBrowserRedirect: true,\n })\n\n if (result.error) {\n setError(result.error.message)\n setDecision(null)\n isDeciding.current = false\n return\n }\n\n if (!result.data?.redirect_url) {\n setError('The server did not return a redirect. Start again from your OAuth client.')\n setDecision(null)\n isDeciding.current = false\n return\n }\n\n window.location.assign(result.data.redirect_url)\n },\n [authorizationId]\n )\n\n return {\n details,\n email: details?.user.email ?? null,\n error,\n isLoading,\n decision,\n approve: () => decide('approve'),\n deny: () => decide('deny'),\n }\n}\n\ntype UseOAuthConsentReturn = ReturnType\n\nexport { useOAuthConsent, type OAuthAuthorizationDetails, type UseOAuthConsentReturn }\n", + "type": "registry:hook" + }, + { + "path": "registry/default/blocks/oauth-consent-nextjs/app/oauth/consent/page.tsx", + "content": "import { OAuthConsent } from '@/registry/default/blocks/oauth-consent/components/oauth-consent'\n\nexport default async function ConsentPage({\n searchParams,\n}: {\n searchParams: Promise<{ authorization_id?: string }>\n}) {\n const { authorization_id } = await searchParams\n\n return (\n
\n \n
\n )\n}\n", + "type": "registry:page", + "target": "app/oauth/consent/page.tsx" + }, + { + "path": "registry/default/clients/nextjs/lib/supabase/client.ts", + "content": "import { createBrowserClient } from '@supabase/ssr'\n\nexport function createClient() {\n return createBrowserClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!\n )\n}\n", + "type": "registry:lib" + }, + { + "path": "registry/default/clients/nextjs/lib/supabase/middleware.ts", + "content": "import { createServerClient } from '@supabase/ssr'\nimport { NextResponse, type NextRequest } from 'next/server'\n\nexport async function updateSession(request: NextRequest) {\n let supabaseResponse = NextResponse.next({\n request,\n })\n\n // With Fluid compute, don't put this client in a global environment\n // variable. Always create a new one on each request.\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,\n {\n cookies: {\n getAll() {\n return request.cookies.getAll()\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))\n supabaseResponse = NextResponse.next({\n request,\n })\n cookiesToSet.forEach(({ name, value, options }) =>\n supabaseResponse.cookies.set(name, value, options)\n )\n },\n },\n }\n )\n\n // Do not run code between createServerClient and\n // supabase.auth.getClaims(). A simple mistake could make it very hard to debug\n // issues with users being randomly logged out.\n\n // IMPORTANT: If you remove getClaims() and you use server-side rendering\n // with the Supabase client, your users may be randomly logged out.\n const { data } = await supabase.auth.getClaims()\n const user = data?.claims\n\n if (\n !user &&\n !request.nextUrl.pathname.startsWith('/login') &&\n !request.nextUrl.pathname.startsWith('/auth') &&\n // the OAuth consent route sends unauthenticated visitors to the login page\n // itself, so that it can preserve the authorization in the `next` parameter\n request.nextUrl.pathname !== '/oauth/consent'\n ) {\n // no user, potentially respond by redirecting the user to the login page\n const url = request.nextUrl.clone()\n url.pathname = '/auth/login'\n return NextResponse.redirect(url)\n }\n\n // IMPORTANT: You *must* return the supabaseResponse object as it is.\n // If you're creating a new response object with NextResponse.next() make sure to:\n // 1. Pass the request in it, like so:\n // const myNewResponse = NextResponse.next({ request })\n // 2. Copy over the cookies, like so:\n // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())\n // 3. Change the myNewResponse object to fit your needs, but avoid changing\n // the cookies!\n // 4. Finally:\n // return myNewResponse\n // If this is not done, you may be causing the browser and server to go out\n // of sync and terminate the user's session prematurely!\n\n return supabaseResponse\n}\n", + "type": "registry:lib" + }, + { + "path": "registry/default/clients/nextjs/lib/supabase/server.ts", + "content": "import { createServerClient } from '@supabase/ssr'\nimport { cookies } from 'next/headers'\n\n/**\n * If using Fluid compute: Don't put this client in a global variable. Always create a new client within each\n * function when using it.\n */\nexport async function createClient() {\n const cookieStore = await cookies()\n\n return createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,\n {\n cookies: {\n getAll() {\n return cookieStore.getAll()\n },\n setAll(cookiesToSet) {\n try {\n cookiesToSet.forEach(({ name, value, options }) =>\n cookieStore.set(name, value, options)\n )\n } catch {\n // The `setAll` method was called from a Server Component.\n // This can be ignored if you have middleware refreshing\n // user sessions.\n }\n },\n },\n }\n )\n}\n", + "type": "registry:lib" + } + ], + "envVars": { + "NEXT_PUBLIC_SUPABASE_URL": "", + "NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY": "" + }, + "docs": "After installation, set the following in `supabase/config.toml`. Recent CLI versions already write an `[auth.oauth_server]` section with `enabled = false` — edit that section rather than adding a second one:\n\n```toml\n[auth.oauth_server]\nenabled = true\nauthorization_url_path = \"/oauth/consent\"\n```\n\nYou'll need to set the following environment variables in your project: `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`." +} \ No newline at end of file diff --git a/apps/ui-library/public/r/oauth-consent-react-router.json b/apps/ui-library/public/r/oauth-consent-react-router.json new file mode 100644 index 0000000000000..aa1bef8314b19 --- /dev/null +++ b/apps/ui-library/public/r/oauth-consent-react-router.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "oauth-consent-react-router", + "type": "registry:block", + "title": "OAuth Consent for React Router", + "description": "OAuth 2.1 consent screen and route for an existing authenticated application.", + "dependencies": [ + "@supabase/ssr@latest", + "@supabase/supabase-js@latest" + ], + "registryDependencies": [ + "button", + "card", + "https://supabase.com/library/r/safe-next-path.json" + ], + "files": [ + { + "path": "registry/default/blocks/oauth-consent/components/oauth-consent.tsx", + "content": "'use client'\n\nimport { cn } from '@/lib/utils'\nimport {\n useOAuthConsent,\n type OAuthConsentDecision,\n} from '@/registry/default/blocks/oauth-consent/hooks/use-oauth-consent'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\n\nconst getInitial = (value: string) => value.trim().charAt(0).toUpperCase() || '?'\n\ninterface ConsentCardShellProps extends React.ComponentPropsWithoutRef<'div'> {\n clientName: string\n productName: string\n}\n\nfunction ConsentCardShell({\n clientName,\n productName,\n className,\n children,\n ...props\n}: ConsentCardShellProps) {\n return (\n
\n \n \n \n
\n {getInitial(clientName)}\n
\n
\n
\n {getInitial(productName)}\n
\n
\n
\n Authorize {clientName}\n Review what this client gets access to.\n
\n
\n {children}\n
\n
\n )\n}\n\nexport interface OAuthConsentCardProps extends React.ComponentPropsWithoutRef<'div'> {\n clientName: string\n productName?: string\n redirectUri: string\n email: string\n scopes?: string[]\n error?: string | null\n decision?: OAuthConsentDecision | null\n onApprove?: () => void\n onDeny?: () => void\n}\n\nexport function OAuthConsentCard({\n clientName,\n productName = 'Your product',\n redirectUri,\n email,\n scopes = [],\n error = null,\n decision = null,\n onApprove,\n onDeny,\n ...props\n}: OAuthConsentCardProps) {\n return (\n \n
\n
\n
Client
\n
{clientName}
\n
\n
\n
Redirects to
\n
{redirectUri}
\n
\n
\n
Signed in as
\n
{email}
\n
\n {scopes.length > 0 && (\n
\n
Scopes
\n
{scopes.join(', ')}
\n
\n )}\n
\n

\n Allow access only if you recognize this application. It can act on your behalf only within\n the requested permissions and the access you already have.\n

\n {error && (\n

\n {error}\n

\n )}\n
\n \n \n
\n
\n )\n}\n\ninterface OAuthConsentProps extends React.ComponentPropsWithoutRef<'div'> {\n authorizationId?: string | null\n signInPath?: string\n productName?: string\n}\n\nexport function OAuthConsent({\n authorizationId,\n signInPath = '/auth/login',\n productName = 'Your product',\n ...props\n}: OAuthConsentProps) {\n const { details, email, error, isLoading, decision, approve, deny } = useOAuthConsent({\n authorizationId,\n signInPath,\n })\n\n if (isLoading || !details || !email) {\n return (\n \n {isLoading ? (\n

\n Loading authorization request...\n

\n ) : (\n

\n {error ??\n 'Unable to load the authorization request. Start again from your OAuth client.'}\n

\n )}\n
\n )\n }\n\n return (\n void approve()}\n onDeny={() => void deny()}\n {...props}\n />\n )\n}\n", + "type": "registry:component" + }, + { + "path": "registry/default/blocks/oauth-consent/hooks/use-oauth-consent.ts", + "content": "import { isAuthSessionMissingError } from '@supabase/supabase-js'\nimport type { OAuthAuthorizationDetails } from '@supabase/supabase-js'\nimport { useCallback, useEffect, useRef, useState } from 'react'\n\nimport { safeNextPath } from '@/registry/default/blocks/safe-next-path/lib/safe-next-path'\nimport { createClient } from '@/registry/default/clients/react-router/lib/supabase/client'\n\nexport type OAuthConsentDecision = 'approve' | 'deny'\n\nexport interface UseOAuthConsentOptions {\n authorizationId?: string | null\n signInPath?: string\n}\n\nconst withNextParam = (path: string, next: string) => {\n const url = new URL(path, window.location.origin)\n const searchParams = new URLSearchParams(url.search)\n searchParams.set('next', next)\n url.search = searchParams.toString()\n return `${url.pathname}${url.search}${url.hash}`\n}\n\nconst useOAuthConsent = ({\n authorizationId,\n signInPath = '/auth/login',\n}: UseOAuthConsentOptions) => {\n const [details, setDetails] = useState(null)\n const [error, setError] = useState(null)\n const [isLoading, setIsLoading] = useState(true)\n const [decision, setDecision] = useState(null)\n const isDeciding = useRef(false)\n\n useEffect(() => {\n let active = true\n\n const loadAuthorization = async () => {\n setIsLoading(true)\n setError(null)\n setDetails(null)\n setDecision(null)\n\n if (!authorizationId) {\n setError('This page needs an authorization_id. Start again from your OAuth client.')\n setIsLoading(false)\n return\n }\n\n const supabase = createClient()\n const {\n data: { user },\n error: userError,\n } = await supabase.auth.getUser()\n\n if (userError && !isAuthSessionMissingError(userError)) {\n if (active) {\n setError(userError.message)\n setIsLoading(false)\n }\n return\n }\n\n if (!user) {\n const next = `${window.location.pathname}${window.location.search}`\n if (active) {\n window.location.replace(withNextParam(safeNextPath(signInPath, '/auth/login'), next))\n }\n return\n }\n\n const { data, error } = await supabase.auth.oauth.getAuthorizationDetails(authorizationId)\n if (error) {\n if (active) {\n setError(error.message)\n setIsLoading(false)\n }\n return\n }\n\n if (!('authorization_id' in data)) {\n if (active) {\n window.location.replace(data.redirect_url)\n }\n return\n }\n\n if (active) {\n setDetails(data)\n setIsLoading(false)\n }\n }\n\n void loadAuthorization()\n return () => {\n active = false\n }\n }, [authorizationId, signInPath])\n\n const decide = useCallback(\n async (action: OAuthConsentDecision) => {\n if (!authorizationId || isDeciding.current) return\n\n isDeciding.current = true\n setDecision(action)\n setError(null)\n const supabase = createClient()\n const result =\n action === 'approve'\n ? await supabase.auth.oauth.approveAuthorization(authorizationId, {\n skipBrowserRedirect: true,\n })\n : await supabase.auth.oauth.denyAuthorization(authorizationId, {\n skipBrowserRedirect: true,\n })\n\n if (result.error) {\n setError(result.error.message)\n setDecision(null)\n isDeciding.current = false\n return\n }\n\n if (!result.data?.redirect_url) {\n setError('The server did not return a redirect. Start again from your OAuth client.')\n setDecision(null)\n isDeciding.current = false\n return\n }\n\n window.location.assign(result.data.redirect_url)\n },\n [authorizationId]\n )\n\n return {\n details,\n email: details?.user.email ?? null,\n error,\n isLoading,\n decision,\n approve: () => decide('approve'),\n deny: () => decide('deny'),\n }\n}\n\ntype UseOAuthConsentReturn = ReturnType\n\nexport { useOAuthConsent, type OAuthAuthorizationDetails, type UseOAuthConsentReturn }\n", + "type": "registry:hook" + }, + { + "path": "registry/default/blocks/oauth-consent-react-router/app/routes/oauth.consent.tsx", + "content": "import { useSearchParams } from 'react-router'\n\nimport { OAuthConsent } from '@/registry/default/blocks/oauth-consent/components/oauth-consent'\n\nexport default function ConsentRoute() {\n const [searchParams] = useSearchParams()\n\n return (\n
\n \n
\n )\n}\n", + "type": "registry:file", + "target": "app/routes/oauth.consent.tsx" + }, + { + "path": "registry/default/clients/react-router/lib/supabase/client.ts", + "content": "/// \nimport { createBrowserClient } from '@supabase/ssr'\n\nexport function createClient() {\n return createBrowserClient(\n import.meta.env.VITE_SUPABASE_URL!,\n import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY!\n )\n}\n", + "type": "registry:lib" + }, + { + "path": "registry/default/clients/react-router/lib/supabase/server.ts", + "content": "import { createServerClient, parseCookieHeader, serializeCookieHeader } from '@supabase/ssr'\n\nexport function createClient(request: Request) {\n const headers = new Headers()\n\n const supabase = createServerClient(\n process.env.VITE_SUPABASE_URL!,\n process.env.VITE_SUPABASE_PUBLISHABLE_KEY!,\n {\n cookies: {\n getAll() {\n return parseCookieHeader(request.headers.get('Cookie') ?? '') as {\n name: string\n value: string\n }[]\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value, options }) =>\n headers.append('Set-Cookie', serializeCookieHeader(name, value, options))\n )\n },\n },\n }\n )\n\n return { supabase, headers }\n}\n", + "type": "registry:lib" + } + ], + "envVars": { + "VITE_SUPABASE_URL": "", + "VITE_SUPABASE_PUBLISHABLE_KEY": "" + }, + "docs": "After installation, set the following in `supabase/config.toml`. Recent CLI versions already write an `[auth.oauth_server]` section with `enabled = false` — edit that section rather than adding a second one:\n\n```toml\n[auth.oauth_server]\nenabled = true\nauthorization_url_path = \"/oauth/consent\"\n```\n\nYou'll need to set the following environment variables in your project: `VITE_SUPABASE_URL` and `VITE_SUPABASE_PUBLISHABLE_KEY`." +} \ No newline at end of file diff --git a/apps/ui-library/public/r/oauth-consent-react.json b/apps/ui-library/public/r/oauth-consent-react.json new file mode 100644 index 0000000000000..6326997caf73c --- /dev/null +++ b/apps/ui-library/public/r/oauth-consent-react.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "oauth-consent-react", + "type": "registry:block", + "title": "OAuth Consent for React", + "description": "OAuth 2.1 consent component for an existing authenticated application.", + "dependencies": [ + "@supabase/supabase-js@latest" + ], + "registryDependencies": [ + "button", + "card", + "https://supabase.com/library/r/safe-next-path.json" + ], + "files": [ + { + "path": "registry/default/blocks/oauth-consent/components/oauth-consent.tsx", + "content": "'use client'\n\nimport { cn } from '@/lib/utils'\nimport {\n useOAuthConsent,\n type OAuthConsentDecision,\n} from '@/registry/default/blocks/oauth-consent/hooks/use-oauth-consent'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\n\nconst getInitial = (value: string) => value.trim().charAt(0).toUpperCase() || '?'\n\ninterface ConsentCardShellProps extends React.ComponentPropsWithoutRef<'div'> {\n clientName: string\n productName: string\n}\n\nfunction ConsentCardShell({\n clientName,\n productName,\n className,\n children,\n ...props\n}: ConsentCardShellProps) {\n return (\n
\n \n \n \n
\n {getInitial(clientName)}\n
\n
\n
\n {getInitial(productName)}\n
\n
\n
\n Authorize {clientName}\n Review what this client gets access to.\n
\n
\n {children}\n
\n
\n )\n}\n\nexport interface OAuthConsentCardProps extends React.ComponentPropsWithoutRef<'div'> {\n clientName: string\n productName?: string\n redirectUri: string\n email: string\n scopes?: string[]\n error?: string | null\n decision?: OAuthConsentDecision | null\n onApprove?: () => void\n onDeny?: () => void\n}\n\nexport function OAuthConsentCard({\n clientName,\n productName = 'Your product',\n redirectUri,\n email,\n scopes = [],\n error = null,\n decision = null,\n onApprove,\n onDeny,\n ...props\n}: OAuthConsentCardProps) {\n return (\n \n
\n
\n
Client
\n
{clientName}
\n
\n
\n
Redirects to
\n
{redirectUri}
\n
\n
\n
Signed in as
\n
{email}
\n
\n {scopes.length > 0 && (\n
\n
Scopes
\n
{scopes.join(', ')}
\n
\n )}\n
\n

\n Allow access only if you recognize this application. It can act on your behalf only within\n the requested permissions and the access you already have.\n

\n {error && (\n

\n {error}\n

\n )}\n
\n \n \n
\n
\n )\n}\n\ninterface OAuthConsentProps extends React.ComponentPropsWithoutRef<'div'> {\n authorizationId?: string | null\n signInPath?: string\n productName?: string\n}\n\nexport function OAuthConsent({\n authorizationId,\n signInPath = '/auth/login',\n productName = 'Your product',\n ...props\n}: OAuthConsentProps) {\n const { details, email, error, isLoading, decision, approve, deny } = useOAuthConsent({\n authorizationId,\n signInPath,\n })\n\n if (isLoading || !details || !email) {\n return (\n \n {isLoading ? (\n

\n Loading authorization request...\n

\n ) : (\n

\n {error ??\n 'Unable to load the authorization request. Start again from your OAuth client.'}\n

\n )}\n
\n )\n }\n\n return (\n void approve()}\n onDeny={() => void deny()}\n {...props}\n />\n )\n}\n", + "type": "registry:component" + }, + { + "path": "registry/default/blocks/oauth-consent/hooks/use-oauth-consent.ts", + "content": "import { isAuthSessionMissingError } from '@supabase/supabase-js'\nimport type { OAuthAuthorizationDetails } from '@supabase/supabase-js'\nimport { useCallback, useEffect, useRef, useState } from 'react'\n\nimport { safeNextPath } from '@/registry/default/blocks/safe-next-path/lib/safe-next-path'\nimport { createClient } from '@/registry/default/clients/react/lib/supabase/client'\n\nexport type OAuthConsentDecision = 'approve' | 'deny'\n\nexport interface UseOAuthConsentOptions {\n authorizationId?: string | null\n signInPath?: string\n}\n\nconst withNextParam = (path: string, next: string) => {\n const url = new URL(path, window.location.origin)\n const searchParams = new URLSearchParams(url.search)\n searchParams.set('next', next)\n url.search = searchParams.toString()\n return `${url.pathname}${url.search}${url.hash}`\n}\n\nconst useOAuthConsent = ({\n authorizationId,\n signInPath = '/auth/login',\n}: UseOAuthConsentOptions) => {\n const [details, setDetails] = useState(null)\n const [error, setError] = useState(null)\n const [isLoading, setIsLoading] = useState(true)\n const [decision, setDecision] = useState(null)\n const isDeciding = useRef(false)\n\n useEffect(() => {\n let active = true\n\n const loadAuthorization = async () => {\n setIsLoading(true)\n setError(null)\n setDetails(null)\n setDecision(null)\n\n if (!authorizationId) {\n setError('This page needs an authorization_id. Start again from your OAuth client.')\n setIsLoading(false)\n return\n }\n\n const supabase = createClient()\n const {\n data: { user },\n error: userError,\n } = await supabase.auth.getUser()\n\n if (userError && !isAuthSessionMissingError(userError)) {\n if (active) {\n setError(userError.message)\n setIsLoading(false)\n }\n return\n }\n\n if (!user) {\n const next = `${window.location.pathname}${window.location.search}`\n if (active) {\n window.location.replace(withNextParam(safeNextPath(signInPath, '/auth/login'), next))\n }\n return\n }\n\n const { data, error } = await supabase.auth.oauth.getAuthorizationDetails(authorizationId)\n if (error) {\n if (active) {\n setError(error.message)\n setIsLoading(false)\n }\n return\n }\n\n if (!('authorization_id' in data)) {\n if (active) {\n window.location.replace(data.redirect_url)\n }\n return\n }\n\n if (active) {\n setDetails(data)\n setIsLoading(false)\n }\n }\n\n void loadAuthorization()\n return () => {\n active = false\n }\n }, [authorizationId, signInPath])\n\n const decide = useCallback(\n async (action: OAuthConsentDecision) => {\n if (!authorizationId || isDeciding.current) return\n\n isDeciding.current = true\n setDecision(action)\n setError(null)\n const supabase = createClient()\n const result =\n action === 'approve'\n ? await supabase.auth.oauth.approveAuthorization(authorizationId, {\n skipBrowserRedirect: true,\n })\n : await supabase.auth.oauth.denyAuthorization(authorizationId, {\n skipBrowserRedirect: true,\n })\n\n if (result.error) {\n setError(result.error.message)\n setDecision(null)\n isDeciding.current = false\n return\n }\n\n if (!result.data?.redirect_url) {\n setError('The server did not return a redirect. Start again from your OAuth client.')\n setDecision(null)\n isDeciding.current = false\n return\n }\n\n window.location.assign(result.data.redirect_url)\n },\n [authorizationId]\n )\n\n return {\n details,\n email: details?.user.email ?? null,\n error,\n isLoading,\n decision,\n approve: () => decide('approve'),\n deny: () => decide('deny'),\n }\n}\n\ntype UseOAuthConsentReturn = ReturnType\n\nexport { useOAuthConsent, type OAuthAuthorizationDetails, type UseOAuthConsentReturn }\n", + "type": "registry:hook" + }, + { + "path": "registry/default/clients/react/lib/supabase/client.ts", + "content": "import { createClient as createSupabaseClient } from '@supabase/supabase-js'\n\nexport function createClient() {\n return createSupabaseClient(\n import.meta.env.VITE_SUPABASE_URL!,\n import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY!\n )\n}\n", + "type": "registry:lib" + } + ], + "envVars": { + "VITE_SUPABASE_URL": "", + "VITE_SUPABASE_PUBLISHABLE_KEY": "" + }, + "docs": "After installation, set the following in `supabase/config.toml`. Recent CLI versions already write an `[auth.oauth_server]` section with `enabled = false` — edit that section rather than adding a second one:\n\n```toml\n[auth.oauth_server]\nenabled = true\nauthorization_url_path = \"/oauth/consent\"\n```\n\nYou'll need to set the following environment variables in your project: `VITE_SUPABASE_URL` and `VITE_SUPABASE_PUBLISHABLE_KEY`." +} \ No newline at end of file diff --git a/apps/ui-library/public/r/oauth-consent-tanstack.json b/apps/ui-library/public/r/oauth-consent-tanstack.json new file mode 100644 index 0000000000000..34cc335915f73 --- /dev/null +++ b/apps/ui-library/public/r/oauth-consent-tanstack.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "oauth-consent-tanstack", + "type": "registry:block", + "title": "OAuth Consent for TanStack Start", + "description": "OAuth 2.1 consent screen and route for an existing authenticated application.", + "dependencies": [ + "@supabase/ssr@latest", + "@supabase/supabase-js@latest" + ], + "registryDependencies": [ + "button", + "card", + "https://supabase.com/library/r/safe-next-path.json" + ], + "files": [ + { + "path": "registry/default/blocks/oauth-consent/components/oauth-consent.tsx", + "content": "'use client'\n\nimport { cn } from '@/lib/utils'\nimport {\n useOAuthConsent,\n type OAuthConsentDecision,\n} from '@/registry/default/blocks/oauth-consent/hooks/use-oauth-consent'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\n\nconst getInitial = (value: string) => value.trim().charAt(0).toUpperCase() || '?'\n\ninterface ConsentCardShellProps extends React.ComponentPropsWithoutRef<'div'> {\n clientName: string\n productName: string\n}\n\nfunction ConsentCardShell({\n clientName,\n productName,\n className,\n children,\n ...props\n}: ConsentCardShellProps) {\n return (\n
\n \n \n \n
\n {getInitial(clientName)}\n
\n
\n
\n {getInitial(productName)}\n
\n
\n
\n Authorize {clientName}\n Review what this client gets access to.\n
\n
\n {children}\n
\n
\n )\n}\n\nexport interface OAuthConsentCardProps extends React.ComponentPropsWithoutRef<'div'> {\n clientName: string\n productName?: string\n redirectUri: string\n email: string\n scopes?: string[]\n error?: string | null\n decision?: OAuthConsentDecision | null\n onApprove?: () => void\n onDeny?: () => void\n}\n\nexport function OAuthConsentCard({\n clientName,\n productName = 'Your product',\n redirectUri,\n email,\n scopes = [],\n error = null,\n decision = null,\n onApprove,\n onDeny,\n ...props\n}: OAuthConsentCardProps) {\n return (\n \n
\n
\n
Client
\n
{clientName}
\n
\n
\n
Redirects to
\n
{redirectUri}
\n
\n
\n
Signed in as
\n
{email}
\n
\n {scopes.length > 0 && (\n
\n
Scopes
\n
{scopes.join(', ')}
\n
\n )}\n
\n

\n Allow access only if you recognize this application. It can act on your behalf only within\n the requested permissions and the access you already have.\n

\n {error && (\n

\n {error}\n

\n )}\n
\n \n \n
\n
\n )\n}\n\ninterface OAuthConsentProps extends React.ComponentPropsWithoutRef<'div'> {\n authorizationId?: string | null\n signInPath?: string\n productName?: string\n}\n\nexport function OAuthConsent({\n authorizationId,\n signInPath = '/auth/login',\n productName = 'Your product',\n ...props\n}: OAuthConsentProps) {\n const { details, email, error, isLoading, decision, approve, deny } = useOAuthConsent({\n authorizationId,\n signInPath,\n })\n\n if (isLoading || !details || !email) {\n return (\n \n {isLoading ? (\n

\n Loading authorization request...\n

\n ) : (\n

\n {error ??\n 'Unable to load the authorization request. Start again from your OAuth client.'}\n

\n )}\n
\n )\n }\n\n return (\n void approve()}\n onDeny={() => void deny()}\n {...props}\n />\n )\n}\n", + "type": "registry:component" + }, + { + "path": "registry/default/blocks/oauth-consent/hooks/use-oauth-consent.ts", + "content": "import { isAuthSessionMissingError } from '@supabase/supabase-js'\nimport type { OAuthAuthorizationDetails } from '@supabase/supabase-js'\nimport { useCallback, useEffect, useRef, useState } from 'react'\n\nimport { safeNextPath } from '@/registry/default/blocks/safe-next-path/lib/safe-next-path'\nimport { createClient } from '@/registry/default/clients/tanstack/lib/supabase/client'\n\nexport type OAuthConsentDecision = 'approve' | 'deny'\n\nexport interface UseOAuthConsentOptions {\n authorizationId?: string | null\n signInPath?: string\n}\n\nconst withNextParam = (path: string, next: string) => {\n const url = new URL(path, window.location.origin)\n const searchParams = new URLSearchParams(url.search)\n searchParams.set('next', next)\n url.search = searchParams.toString()\n return `${url.pathname}${url.search}${url.hash}`\n}\n\nconst useOAuthConsent = ({\n authorizationId,\n signInPath = '/auth/login',\n}: UseOAuthConsentOptions) => {\n const [details, setDetails] = useState(null)\n const [error, setError] = useState(null)\n const [isLoading, setIsLoading] = useState(true)\n const [decision, setDecision] = useState(null)\n const isDeciding = useRef(false)\n\n useEffect(() => {\n let active = true\n\n const loadAuthorization = async () => {\n setIsLoading(true)\n setError(null)\n setDetails(null)\n setDecision(null)\n\n if (!authorizationId) {\n setError('This page needs an authorization_id. Start again from your OAuth client.')\n setIsLoading(false)\n return\n }\n\n const supabase = createClient()\n const {\n data: { user },\n error: userError,\n } = await supabase.auth.getUser()\n\n if (userError && !isAuthSessionMissingError(userError)) {\n if (active) {\n setError(userError.message)\n setIsLoading(false)\n }\n return\n }\n\n if (!user) {\n const next = `${window.location.pathname}${window.location.search}`\n if (active) {\n window.location.replace(withNextParam(safeNextPath(signInPath, '/auth/login'), next))\n }\n return\n }\n\n const { data, error } = await supabase.auth.oauth.getAuthorizationDetails(authorizationId)\n if (error) {\n if (active) {\n setError(error.message)\n setIsLoading(false)\n }\n return\n }\n\n if (!('authorization_id' in data)) {\n if (active) {\n window.location.replace(data.redirect_url)\n }\n return\n }\n\n if (active) {\n setDetails(data)\n setIsLoading(false)\n }\n }\n\n void loadAuthorization()\n return () => {\n active = false\n }\n }, [authorizationId, signInPath])\n\n const decide = useCallback(\n async (action: OAuthConsentDecision) => {\n if (!authorizationId || isDeciding.current) return\n\n isDeciding.current = true\n setDecision(action)\n setError(null)\n const supabase = createClient()\n const result =\n action === 'approve'\n ? await supabase.auth.oauth.approveAuthorization(authorizationId, {\n skipBrowserRedirect: true,\n })\n : await supabase.auth.oauth.denyAuthorization(authorizationId, {\n skipBrowserRedirect: true,\n })\n\n if (result.error) {\n setError(result.error.message)\n setDecision(null)\n isDeciding.current = false\n return\n }\n\n if (!result.data?.redirect_url) {\n setError('The server did not return a redirect. Start again from your OAuth client.')\n setDecision(null)\n isDeciding.current = false\n return\n }\n\n window.location.assign(result.data.redirect_url)\n },\n [authorizationId]\n )\n\n return {\n details,\n email: details?.user.email ?? null,\n error,\n isLoading,\n decision,\n approve: () => decide('approve'),\n deny: () => decide('deny'),\n }\n}\n\ntype UseOAuthConsentReturn = ReturnType\n\nexport { useOAuthConsent, type OAuthAuthorizationDetails, type UseOAuthConsentReturn }\n", + "type": "registry:hook" + }, + { + "path": "registry/default/blocks/oauth-consent-tanstack/routes/oauth/consent.tsx", + "content": "import { createFileRoute } from '@tanstack/react-router'\n\nimport { OAuthConsent } from '@/registry/default/blocks/oauth-consent/components/oauth-consent'\n\n// This route is installed into the consumer's routes directory. Their TanStack\n// route generator adds it to FileRoutesByPath after installation.\n// @ts-expect-error The local generated route tree does not include this block route.\nexport const Route = createFileRoute('/oauth/consent')({\n component: ConsentPage,\n validateSearch: (search) => ({\n authorization_id: typeof search.authorization_id === 'string' ? search.authorization_id : null,\n }),\n})\n\nfunction ConsentPage() {\n const { authorization_id } = Route.useSearch()\n\n return (\n
\n \n
\n )\n}\n", + "type": "registry:file", + "target": "routes/oauth/consent.tsx" + }, + { + "path": "registry/default/clients/tanstack/lib/supabase/client.ts", + "content": "/// \nimport { createBrowserClient } from '@supabase/ssr'\n\nexport function createClient() {\n return createBrowserClient(\n import.meta.env.VITE_SUPABASE_URL!,\n import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY!\n )\n}\n", + "type": "registry:lib" + }, + { + "path": "registry/default/clients/tanstack/lib/supabase/server.ts", + "content": "import { createServerClient } from '@supabase/ssr'\nimport { getCookies, setCookie } from '@tanstack/react-start/server'\n\nexport function createClient() {\n return createServerClient(\n process.env.VITE_SUPABASE_URL!,\n process.env.VITE_SUPABASE_PUBLISHABLE_KEY!,\n {\n cookies: {\n getAll() {\n return Object.entries(getCookies()).map(\n ([name, value]) =>\n ({\n name,\n value,\n }) as { name: string; value: string }\n )\n },\n setAll(cookies) {\n cookies.forEach((cookie) => {\n setCookie(cookie.name, cookie.value)\n })\n },\n },\n }\n )\n}\n", + "type": "registry:lib" + } + ], + "envVars": { + "VITE_SUPABASE_URL": "", + "VITE_SUPABASE_PUBLISHABLE_KEY": "" + }, + "docs": "After installation, set the following in `supabase/config.toml`. Recent CLI versions already write an `[auth.oauth_server]` section with `enabled = false` — edit that section rather than adding a second one:\n\n```toml\n[auth.oauth_server]\nenabled = true\nauthorization_url_path = \"/oauth/consent\"\n```\n\nYou'll need to set the following environment variables in your project: `VITE_SUPABASE_URL` and `VITE_SUPABASE_PUBLISHABLE_KEY`." +} \ No newline at end of file diff --git a/apps/ui-library/public/r/password-based-auth-nextjs.json b/apps/ui-library/public/r/password-based-auth-nextjs.json index 787fc6ed2f04a..e4cfaeb897f77 100644 --- a/apps/ui-library/public/r/password-based-auth-nextjs.json +++ b/apps/ui-library/public/r/password-based-auth-nextjs.json @@ -12,7 +12,8 @@ "button", "card", "input", - "label" + "label", + "https://supabase.com/library/r/safe-next-path.json" ], "files": [ { @@ -41,7 +42,7 @@ }, { "path": "registry/default/blocks/password-based-auth-nextjs/components/login-form.tsx", - "content": "'use client'\n\nimport { useRouter } from 'next/navigation'\nimport { useState } from 'react'\n\nimport { cn } from '@/lib/utils'\nimport { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\nimport Link from 'next/link'\n\nexport function LoginForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {\n const [email, setEmail] = useState('')\n const [password, setPassword] = useState('')\n const [error, setError] = useState(null)\n const [isLoading, setIsLoading] = useState(false)\n const router = useRouter()\n\n const handleLogin = async (e: React.FormEvent) => {\n e.preventDefault()\n const supabase = createClient()\n setIsLoading(true)\n setError(null)\n\n try {\n const { error } = await supabase.auth.signInWithPassword({\n email,\n password,\n })\n if (error) throw error\n // Update this route to redirect to an authenticated route. The user already has an active session.\n router.push('/protected')\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n } finally {\n setIsLoading(false)\n }\n }\n\n return (\n
\n \n \n Login\n Enter your email below to login to your account\n \n \n
\n
\n
\n \n setEmail(e.target.value)}\n />\n
\n
\n
\n \n \n Forgot your password?\n \n
\n setPassword(e.target.value)}\n />\n
\n {error &&

{error}

}\n \n
\n
\n Don't have an account?{' '}\n \n Sign up\n \n
\n
\n
\n
\n
\n )\n}\n", + "content": "'use client'\n\nimport { useRouter } from 'next/navigation'\nimport { useState } from 'react'\n\nimport { cn } from '@/lib/utils'\nimport { safeNextPath } from '@/registry/default/blocks/safe-next-path/lib/safe-next-path'\nimport { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\nimport Link from 'next/link'\n\nexport function LoginForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {\n const [email, setEmail] = useState('')\n const [password, setPassword] = useState('')\n const [error, setError] = useState(null)\n const [isLoading, setIsLoading] = useState(false)\n const router = useRouter()\n\n const handleLogin = async (e: React.FormEvent) => {\n e.preventDefault()\n const supabase = createClient()\n setIsLoading(true)\n setError(null)\n\n try {\n const { error } = await supabase.auth.signInWithPassword({\n email,\n password,\n })\n if (error) throw error\n // Update this route to redirect to an authenticated route. The user already has an active session.\n const next = new URLSearchParams(window.location.search).get('next')\n router.push(safeNextPath(next, '/protected'))\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n } finally {\n setIsLoading(false)\n }\n }\n\n return (\n
\n \n \n Login\n Enter your email below to login to your account\n \n \n
\n
\n
\n \n setEmail(e.target.value)}\n />\n
\n
\n
\n \n \n Forgot your password?\n \n
\n setPassword(e.target.value)}\n />\n
\n {error &&

{error}

}\n \n
\n
\n Don't have an account?{' '}\n \n Sign up\n \n
\n
\n
\n
\n
\n )\n}\n", "type": "registry:component" }, { @@ -101,7 +102,7 @@ }, { "path": "registry/default/clients/nextjs/lib/supabase/middleware.ts", - "content": "import { createServerClient } from '@supabase/ssr'\nimport { NextResponse, type NextRequest } from 'next/server'\n\nexport async function updateSession(request: NextRequest) {\n let supabaseResponse = NextResponse.next({\n request,\n })\n\n // With Fluid compute, don't put this client in a global environment\n // variable. Always create a new one on each request.\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,\n {\n cookies: {\n getAll() {\n return request.cookies.getAll()\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))\n supabaseResponse = NextResponse.next({\n request,\n })\n cookiesToSet.forEach(({ name, value, options }) =>\n supabaseResponse.cookies.set(name, value, options)\n )\n },\n },\n }\n )\n\n // Do not run code between createServerClient and\n // supabase.auth.getClaims(). A simple mistake could make it very hard to debug\n // issues with users being randomly logged out.\n\n // IMPORTANT: If you remove getClaims() and you use server-side rendering\n // with the Supabase client, your users may be randomly logged out.\n const { data } = await supabase.auth.getClaims()\n const user = data?.claims\n\n if (\n !user &&\n !request.nextUrl.pathname.startsWith('/login') &&\n !request.nextUrl.pathname.startsWith('/auth')\n ) {\n // no user, potentially respond by redirecting the user to the login page\n const url = request.nextUrl.clone()\n url.pathname = '/auth/login'\n return NextResponse.redirect(url)\n }\n\n // IMPORTANT: You *must* return the supabaseResponse object as it is.\n // If you're creating a new response object with NextResponse.next() make sure to:\n // 1. Pass the request in it, like so:\n // const myNewResponse = NextResponse.next({ request })\n // 2. Copy over the cookies, like so:\n // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())\n // 3. Change the myNewResponse object to fit your needs, but avoid changing\n // the cookies!\n // 4. Finally:\n // return myNewResponse\n // If this is not done, you may be causing the browser and server to go out\n // of sync and terminate the user's session prematurely!\n\n return supabaseResponse\n}\n", + "content": "import { createServerClient } from '@supabase/ssr'\nimport { NextResponse, type NextRequest } from 'next/server'\n\nexport async function updateSession(request: NextRequest) {\n let supabaseResponse = NextResponse.next({\n request,\n })\n\n // With Fluid compute, don't put this client in a global environment\n // variable. Always create a new one on each request.\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,\n {\n cookies: {\n getAll() {\n return request.cookies.getAll()\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))\n supabaseResponse = NextResponse.next({\n request,\n })\n cookiesToSet.forEach(({ name, value, options }) =>\n supabaseResponse.cookies.set(name, value, options)\n )\n },\n },\n }\n )\n\n // Do not run code between createServerClient and\n // supabase.auth.getClaims(). A simple mistake could make it very hard to debug\n // issues with users being randomly logged out.\n\n // IMPORTANT: If you remove getClaims() and you use server-side rendering\n // with the Supabase client, your users may be randomly logged out.\n const { data } = await supabase.auth.getClaims()\n const user = data?.claims\n\n if (\n !user &&\n !request.nextUrl.pathname.startsWith('/login') &&\n !request.nextUrl.pathname.startsWith('/auth') &&\n // the OAuth consent route sends unauthenticated visitors to the login page\n // itself, so that it can preserve the authorization in the `next` parameter\n request.nextUrl.pathname !== '/oauth/consent'\n ) {\n // no user, potentially respond by redirecting the user to the login page\n const url = request.nextUrl.clone()\n url.pathname = '/auth/login'\n return NextResponse.redirect(url)\n }\n\n // IMPORTANT: You *must* return the supabaseResponse object as it is.\n // If you're creating a new response object with NextResponse.next() make sure to:\n // 1. Pass the request in it, like so:\n // const myNewResponse = NextResponse.next({ request })\n // 2. Copy over the cookies, like so:\n // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())\n // 3. Change the myNewResponse object to fit your needs, but avoid changing\n // the cookies!\n // 4. Finally:\n // return myNewResponse\n // If this is not done, you may be causing the browser and server to go out\n // of sync and terminate the user's session prematurely!\n\n return supabaseResponse\n}\n", "type": "registry:lib" }, { diff --git a/apps/ui-library/public/r/password-based-auth-react-router.json b/apps/ui-library/public/r/password-based-auth-react-router.json index 0b3e87063b1cf..1474873cd46bb 100644 --- a/apps/ui-library/public/r/password-based-auth-react-router.json +++ b/apps/ui-library/public/r/password-based-auth-react-router.json @@ -14,7 +14,8 @@ "button", "card", "input", - "label" + "label", + "https://supabase.com/library/r/safe-next-path.json" ], "files": [ { @@ -37,7 +38,7 @@ }, { "path": "registry/default/blocks/password-based-auth-react-router/app/routes/login.tsx", - "content": "import { Link, redirect, useFetcher, type ActionFunctionArgs } from 'react-router'\n\nimport { createClient } from '@/registry/default/clients/react-router/lib/supabase/server'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\n\nexport const action = async ({ request }: ActionFunctionArgs) => {\n const { supabase, headers } = createClient(request)\n\n const formData = await request.formData()\n\n const email = formData.get('email') as string\n const password = formData.get('password') as string\n\n const { error } = await supabase.auth.signInWithPassword({\n email,\n password,\n })\n\n if (error) {\n return {\n error: error instanceof Error ? error.message : 'An error occurred',\n }\n }\n\n // Update this route to redirect to an authenticated route. The user already has an active session.\n return redirect('/protected', { headers })\n}\n\nexport default function Login() {\n const fetcher = useFetcher()\n\n const error = fetcher.data?.error\n const loading = fetcher.state === 'submitting'\n\n return (\n
\n
\n
\n \n \n Login\n Enter your email below to login to your account\n \n \n \n
\n
\n \n \n
\n
\n
\n \n \n Forgot your password?\n \n
\n \n
\n {error &&

{error}

}\n \n
\n
\n Don't have an account?{' '}\n \n Sign up\n \n
\n
\n
\n
\n
\n
\n
\n )\n}\n", + "content": "import { Link, redirect, useFetcher, useSearchParams, type ActionFunctionArgs } from 'react-router'\n\nimport { safeNextPath } from '@/registry/default/blocks/safe-next-path/lib/safe-next-path'\nimport { createClient } from '@/registry/default/clients/react-router/lib/supabase/server'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\n\nexport const action = async ({ request }: ActionFunctionArgs) => {\n const { supabase, headers } = createClient(request)\n const origin = new URL(request.url).origin\n\n const formData = await request.formData()\n\n const email = formData.get('email') as string\n const password = formData.get('password') as string\n\n const { error } = await supabase.auth.signInWithPassword({\n email,\n password,\n })\n\n if (error) {\n return {\n error: error instanceof Error ? error.message : 'An error occurred',\n }\n }\n\n // Update this route to redirect to an authenticated route. The user already has an active session.\n return redirect(safeNextPath(formData.get('next'), '/protected', origin), { headers })\n}\n\nexport default function Login() {\n const fetcher = useFetcher()\n const [searchParams] = useSearchParams()\n\n const error = fetcher.data?.error\n const loading = fetcher.state === 'submitting'\n\n return (\n
\n
\n
\n \n \n Login\n Enter your email below to login to your account\n \n \n \n \n
\n
\n \n \n
\n
\n
\n \n \n Forgot your password?\n \n
\n \n
\n {error &&

{error}

}\n \n
\n
\n Don't have an account?{' '}\n \n Sign up\n \n
\n
\n
\n
\n
\n
\n
\n )\n}\n", "type": "registry:file", "target": "app/routes/login.tsx" }, diff --git a/apps/ui-library/public/r/password-based-auth-react.json b/apps/ui-library/public/r/password-based-auth-react.json index e9cfb105d24a4..afe60913e0228 100644 --- a/apps/ui-library/public/r/password-based-auth-react.json +++ b/apps/ui-library/public/r/password-based-auth-react.json @@ -11,12 +11,13 @@ "button", "card", "input", - "label" + "label", + "https://supabase.com/library/r/safe-next-path.json" ], "files": [ { "path": "registry/default/blocks/password-based-auth-react/components/login-form.tsx", - "content": "import { useState } from 'react'\n\nimport { cn } from '@/lib/utils'\nimport { createClient } from '@/registry/default/clients/react/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\n\nexport function LoginForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {\n const [email, setEmail] = useState('')\n const [password, setPassword] = useState('')\n const [error, setError] = useState(null)\n const [isLoading, setIsLoading] = useState(false)\n const supabase = createClient()\n\n const handleLogin = async (e: React.FormEvent) => {\n e.preventDefault()\n setIsLoading(true)\n setError(null)\n\n try {\n const { error } = await supabase.auth.signInWithPassword({\n email,\n password,\n })\n if (error) throw error\n // Update this route to redirect to an authenticated route. The user already has an active session.\n location.href = '/protected'\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n } finally {\n setIsLoading(false)\n }\n }\n\n return (\n
\n \n \n Login\n Enter your email below to login to your account\n \n \n
\n
\n
\n \n setEmail(e.target.value)}\n />\n
\n
\n
\n \n \n Forgot your password?\n \n
\n setPassword(e.target.value)}\n />\n
\n {error &&

{error}

}\n \n
\n
\n Don't have an account?{' '}\n \n Sign up\n \n
\n
\n
\n
\n
\n )\n}\n", + "content": "import { useState } from 'react'\n\nimport { cn } from '@/lib/utils'\nimport { safeNextPath } from '@/registry/default/blocks/safe-next-path/lib/safe-next-path'\nimport { createClient } from '@/registry/default/clients/react/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\n\nexport function LoginForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {\n const [email, setEmail] = useState('')\n const [password, setPassword] = useState('')\n const [error, setError] = useState(null)\n const [isLoading, setIsLoading] = useState(false)\n const supabase = createClient()\n\n const handleLogin = async (e: React.FormEvent) => {\n e.preventDefault()\n setIsLoading(true)\n setError(null)\n\n try {\n const { error } = await supabase.auth.signInWithPassword({\n email,\n password,\n })\n if (error) throw error\n // Update this route to redirect to an authenticated route. The user already has an active session.\n const next = new URLSearchParams(window.location.search).get('next')\n location.href = safeNextPath(next, '/protected')\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n } finally {\n setIsLoading(false)\n }\n }\n\n return (\n
\n \n \n Login\n Enter your email below to login to your account\n \n \n
\n
\n
\n \n setEmail(e.target.value)}\n />\n
\n
\n
\n \n \n Forgot your password?\n \n
\n setPassword(e.target.value)}\n />\n
\n {error &&

{error}

}\n \n
\n
\n Don't have an account?{' '}\n \n Sign up\n \n
\n
\n
\n
\n
\n )\n}\n", "type": "registry:component" }, { diff --git a/apps/ui-library/public/r/password-based-auth-tanstack.json b/apps/ui-library/public/r/password-based-auth-tanstack.json index 3866d11932877..209f5534c1295 100644 --- a/apps/ui-library/public/r/password-based-auth-tanstack.json +++ b/apps/ui-library/public/r/password-based-auth-tanstack.json @@ -12,7 +12,8 @@ "button", "card", "input", - "label" + "label", + "https://supabase.com/library/r/safe-next-path.json" ], "files": [ { @@ -47,7 +48,7 @@ }, { "path": "registry/default/blocks/password-based-auth-tanstack/components/login-form.tsx", - "content": "import { Link, useNavigate } from '@tanstack/react-router'\nimport { useState } from 'react'\n\nimport { cn } from '@/lib/utils'\nimport { createClient } from '@/registry/default/clients/tanstack/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\n\nexport function LoginForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {\n const [email, setEmail] = useState('')\n const [password, setPassword] = useState('')\n const [error, setError] = useState(null)\n const [isLoading, setIsLoading] = useState(false)\n const navigate = useNavigate()\n\n const handleLogin = async (e: React.FormEvent) => {\n e.preventDefault()\n const supabase = createClient()\n setIsLoading(true)\n setError(null)\n\n try {\n const { error } = await supabase.auth.signInWithPassword({\n email,\n password,\n })\n if (error) throw error\n // Update this route to redirect to an authenticated route. The user already has an active session.\n await navigate({ to: '/protected' })\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n } finally {\n setIsLoading(false)\n }\n }\n\n return (\n
\n \n \n Login\n Enter your email below to login to your account\n \n \n
\n
\n
\n \n setEmail(e.target.value)}\n />\n
\n
\n
\n \n \n Forgot your password?\n \n
\n setPassword(e.target.value)}\n />\n
\n {error &&

{error}

}\n \n
\n
\n Don't have an account?{' '}\n \n Sign up\n \n
\n
\n
\n
\n
\n )\n}\n", + "content": "import { Link } from '@tanstack/react-router'\nimport { useState } from 'react'\n\nimport { cn } from '@/lib/utils'\nimport { safeNextPath } from '@/registry/default/blocks/safe-next-path/lib/safe-next-path'\nimport { createClient } from '@/registry/default/clients/tanstack/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\n\nexport function LoginForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {\n const [email, setEmail] = useState('')\n const [password, setPassword] = useState('')\n const [error, setError] = useState(null)\n const [isLoading, setIsLoading] = useState(false)\n\n const handleLogin = async (e: React.FormEvent) => {\n e.preventDefault()\n const supabase = createClient()\n setIsLoading(true)\n setError(null)\n\n try {\n const { error } = await supabase.auth.signInWithPassword({\n email,\n password,\n })\n if (error) throw error\n // Follow the `next` query parameter if it is a same-origin relative path, e.g. when\n // the OAuth consent screen sent the user here to sign in first. It may point outside\n // the typed route tree, so it needs a full navigation.\n const next = new URLSearchParams(window.location.search).get('next')\n window.location.assign(safeNextPath(next, '/protected'))\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n } finally {\n setIsLoading(false)\n }\n }\n\n return (\n
\n \n \n Login\n Enter your email below to login to your account\n \n \n
\n
\n
\n \n setEmail(e.target.value)}\n />\n
\n
\n
\n \n \n Forgot your password?\n \n
\n setPassword(e.target.value)}\n />\n
\n {error &&

{error}

}\n \n
\n
\n Don't have an account?{' '}\n \n Sign up\n \n
\n
\n
\n
\n
\n )\n}\n", "type": "registry:component" }, { diff --git a/apps/ui-library/public/r/platform-kit-nextjs.json b/apps/ui-library/public/r/platform-kit-nextjs.json index 3cb976b10c3d9..32389fd3e053b 100644 --- a/apps/ui-library/public/r/platform-kit-nextjs.json +++ b/apps/ui-library/public/r/platform-kit-nextjs.json @@ -86,7 +86,7 @@ }, { "path": "registry/default/platform/platform-kit-nextjs/components/supabase-manager/index.tsx", - "content": "'use client'\n\nimport { QueryClient, QueryClientProvider } from '@tanstack/react-query'\nimport {\n ChevronLeft,\n ChevronRight,\n Database,\n ExternalLink,\n HardDrive,\n KeyRound,\n Lightbulb,\n ScrollText,\n Shield,\n Users,\n} from 'lucide-react'\nimport Link from 'next/link'\nimport { ReactNode, useMemo, useState } from 'react'\n\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Dialog,\n DialogContent,\n DialogTitle,\n DialogTrigger,\n} from '@/registry/default/components/ui/dialog'\nimport {\n Drawer,\n DrawerContent,\n DrawerHeader,\n DrawerTitle,\n} from '@/registry/default/components/ui/drawer'\nimport {\n HoverCard,\n HoverCardContent,\n HoverCardTrigger,\n} from '@/registry/default/components/ui/hover-card'\nimport { LogoSupabase } from '@/registry/default/platform/platform-kit-nextjs/components/logo-supabase'\nimport { AuthManager } from '@/registry/default/platform/platform-kit-nextjs/components/supabase-manager/auth'\nimport { DatabaseManager } from '@/registry/default/platform/platform-kit-nextjs/components/supabase-manager/database'\nimport { LogsManager } from '@/registry/default/platform/platform-kit-nextjs/components/supabase-manager/logs'\nimport { SecretsManager } from '@/registry/default/platform/platform-kit-nextjs/components/supabase-manager/secrets'\nimport { StorageManager } from '@/registry/default/platform/platform-kit-nextjs/components/supabase-manager/storage'\nimport { SuggestionsManager } from '@/registry/default/platform/platform-kit-nextjs/components/supabase-manager/suggestions'\nimport { UsersManager } from '@/registry/default/platform/platform-kit-nextjs/components/supabase-manager/users'\nimport {\n SheetNavigationProvider,\n useSheetNavigation,\n} from '@/registry/default/platform/platform-kit-nextjs/contexts/SheetNavigationContext'\n\nconst queryClient = new QueryClient()\n\nfunction DialogView({ projectRef, isMobile }: { projectRef: string; isMobile?: boolean }) {\n const { stack, push, popTo, reset } = useSheetNavigation()\n\n const handleTopLevelNavigation = (title: string, component: ReactNode) => {\n if (stack.length === 1 && stack[0].title === title) {\n return\n }\n reset()\n push({ title, component })\n }\n\n const currentView = stack[stack.length - 1]\n const activeManager = stack.length > 0 ? stack[0].title : null\n\n const navigationItems = useMemo(\n () => [\n {\n title: 'Database',\n icon: Database,\n component: ,\n },\n {\n title: 'Storage',\n icon: HardDrive,\n component: ,\n },\n {\n title: 'Auth',\n icon: Shield,\n component: ,\n },\n {\n title: 'Users',\n icon: Users,\n component: ,\n },\n {\n title: 'Secrets',\n icon: KeyRound,\n component: ,\n },\n {\n title: 'Logs',\n icon: ScrollText,\n component: ,\n },\n {\n title: 'Suggestions',\n icon: Lightbulb,\n component: ,\n },\n ],\n [projectRef]\n )\n\n if (isMobile) {\n return (\n
\n {/* Content Area */}\n
\n
\n {currentView ? (\n currentView.component\n ) : (\n
\n

\n Select a manager from the bottom navigation to get started.\n

\n
\n )}\n
\n
\n\n {/* Bottom Navigation Bar */}\n
\n
\n
\n {navigationItems.map((item) => {\n const Icon = item.icon\n return (\n handleTopLevelNavigation(item.title, item.component)}\n >\n \n \n {item.title === 'Auth' ? 'Auth' : item.title}\n \n \n )\n })}\n
\n
\n
\n
\n )\n }\n\n return (\n
\n {/* Sidebar */}\n
\n
\n

Manage your back-end

\n
\n
\n {navigationItems.map((item) => {\n const Icon = item.icon\n return (\n handleTopLevelNavigation(item.title, item.component)}\n >\n \n {item.title === 'Auth' ? 'Authentication' : item.title}\n \n )\n })}\n
\n
\n \n \n \n \n Open in Supabase\n \n \n \n \n

About Supabase

\n

\n Access powerful back-end tools for database, auth, storage, and logs directly in\n Supabase.\n

\n \n
\n
\n
\n\n {/* Content Area */}\n
\n {/* Header with breadcrumbs */}\n
\n {stack.length > 1 && (\n popTo(stack.length - 2)}\n >\n \n Back\n \n )}\n {/* Breadcrumbs */}\n
\n {stack.map((item: { title: string }, index: number) => (\n
\n {index > 0 && }\n {index === stack.length - 1 ? (\n {item.title}\n ) : (\n \n )}\n
\n ))}\n
\n
\n\n
\n {currentView ? (\n currentView.component\n ) : (\n
\n

\n Select a manager from the sidebar to get started.\n

\n
\n )}\n
\n
\n
\n )\n}\n\nexport default function SupabaseManagerDialog({\n projectRef,\n open,\n onOpenChange,\n isMobile,\n}: {\n projectRef: string\n open: boolean\n onOpenChange: (open: boolean) => void\n isMobile: boolean\n}) {\n const content = (\n {}}\n initialStack={[\n {\n title: 'Database',\n component: ,\n },\n ]}\n >\n \n \n )\n\n if (!isMobile) {\n return (\n \n \n \n Manage your back-end\n {content}\n \n \n \n )\n }\n\n return (\n \n \n \n \n Manage your back-end\n \n {content}\n \n \n \n )\n}\n", + "content": "'use client'\n\nimport { QueryClient, QueryClientProvider } from '@tanstack/react-query'\nimport {\n ChevronLeft,\n ChevronRight,\n Database,\n ExternalLink,\n HardDrive,\n KeyRound,\n Lightbulb,\n ScrollText,\n Shield,\n Users,\n} from 'lucide-react'\nimport Link from 'next/link'\nimport { ReactNode, useMemo, useState } from 'react'\n\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Dialog,\n DialogContent,\n DialogTitle,\n DialogTrigger,\n} from '@/registry/default/components/ui/dialog'\nimport {\n Drawer,\n DrawerContent,\n DrawerHeader,\n DrawerTitle,\n} from '@/registry/default/components/ui/drawer'\nimport {\n HoverCard,\n HoverCardContent,\n HoverCardTrigger,\n} from '@/registry/default/components/ui/hover-card'\nimport { LogoSupabase } from '@/registry/default/platform/platform-kit-nextjs/components/logo-supabase'\nimport { AuthManager } from '@/registry/default/platform/platform-kit-nextjs/components/supabase-manager/auth'\nimport { DatabaseManager } from '@/registry/default/platform/platform-kit-nextjs/components/supabase-manager/database'\nimport { LogsManager } from '@/registry/default/platform/platform-kit-nextjs/components/supabase-manager/logs'\nimport { SecretsManager } from '@/registry/default/platform/platform-kit-nextjs/components/supabase-manager/secrets'\nimport { StorageManager } from '@/registry/default/platform/platform-kit-nextjs/components/supabase-manager/storage'\nimport { SuggestionsManager } from '@/registry/default/platform/platform-kit-nextjs/components/supabase-manager/suggestions'\nimport { UsersManager } from '@/registry/default/platform/platform-kit-nextjs/components/supabase-manager/users'\nimport {\n SheetNavigationProvider,\n useSheetNavigation,\n} from '@/registry/default/platform/platform-kit-nextjs/contexts/SheetNavigationContext'\n\nconst queryClient = new QueryClient()\n\nfunction DialogView({ projectRef, isMobile }: { projectRef: string; isMobile?: boolean }) {\n const { stack, push, popTo, reset } = useSheetNavigation()\n\n const handleTopLevelNavigation = (title: string, component: ReactNode) => {\n if (stack.length === 1 && stack[0].title === title) {\n return\n }\n reset()\n push({ title, component })\n }\n\n const currentView = stack[stack.length - 1]\n const activeManager = stack.length > 0 ? stack[0].title : null\n\n const navigationItems = useMemo(\n () => [\n {\n title: 'Database',\n icon: Database,\n component: ,\n },\n {\n title: 'Storage',\n icon: HardDrive,\n component: ,\n },\n {\n title: 'Auth',\n icon: Shield,\n component: ,\n },\n {\n title: 'Users',\n icon: Users,\n component: ,\n },\n {\n title: 'Secrets',\n icon: KeyRound,\n component: ,\n },\n {\n title: 'Logs',\n icon: ScrollText,\n component: ,\n },\n {\n title: 'Suggestions',\n icon: Lightbulb,\n component: ,\n },\n ],\n [projectRef]\n )\n\n if (isMobile) {\n return (\n
\n {/* Content Area */}\n
\n
\n {currentView ? (\n currentView.component\n ) : (\n
\n

\n Select a manager from the bottom navigation to get started.\n

\n
\n )}\n
\n
\n\n {/* Bottom Navigation Bar */}\n
\n
\n
\n {navigationItems.map((item) => {\n const Icon = item.icon\n return (\n handleTopLevelNavigation(item.title, item.component)}\n >\n \n \n {item.title === 'Auth' ? 'Auth' : item.title}\n \n \n )\n })}\n
\n
\n
\n
\n )\n }\n\n return (\n
\n {/* Sidebar */}\n
\n
\n

Manage your back-end

\n
\n
\n {navigationItems.map((item) => {\n const Icon = item.icon\n return (\n handleTopLevelNavigation(item.title, item.component)}\n >\n \n {item.title === 'Auth' ? 'Authentication' : item.title}\n \n )\n })}\n
\n
\n \n \n \n \n Open in Supabase\n \n \n \n \n

About Supabase

\n

\n Access powerful back-end tools for database, auth, storage, and logs directly in\n Supabase.\n

\n \n
\n
\n
\n\n {/* Content Area */}\n
\n {/* Header with breadcrumbs */}\n
\n {stack.length > 1 && (\n popTo(stack.length - 2)}\n >\n \n Back\n \n )}\n {/* Breadcrumbs */}\n
\n {stack.map((item: { title: string }, index: number) => (\n
\n {index > 0 && }\n {index === stack.length - 1 ? (\n {item.title}\n ) : (\n popTo(index)}\n className=\"hover:underline\"\n >\n {item.title}\n \n )}\n
\n ))}\n
\n
\n\n
\n {currentView ? (\n currentView.component\n ) : (\n
\n

\n Select a manager from the sidebar to get started.\n

\n
\n )}\n
\n
\n
\n )\n}\n\nexport default function SupabaseManagerDialog({\n projectRef,\n open,\n onOpenChange,\n isMobile,\n}: {\n projectRef: string\n open: boolean\n onOpenChange: (open: boolean) => void\n isMobile: boolean\n}) {\n const content = (\n {}}\n initialStack={[\n {\n title: 'Database',\n component: ,\n },\n ]}\n >\n \n \n )\n\n if (!isMobile) {\n return (\n \n \n \n Manage your back-end\n {content}\n \n \n \n )\n }\n\n return (\n \n \n \n \n Manage your back-end\n \n {content}\n \n \n \n )\n}\n", "type": "registry:component" }, { diff --git a/apps/ui-library/public/r/realtime-avatar-stack-nextjs.json b/apps/ui-library/public/r/realtime-avatar-stack-nextjs.json index dfbca00e99927..2338c698d3d62 100644 --- a/apps/ui-library/public/r/realtime-avatar-stack-nextjs.json +++ b/apps/ui-library/public/r/realtime-avatar-stack-nextjs.json @@ -45,7 +45,7 @@ }, { "path": "registry/default/clients/nextjs/lib/supabase/middleware.ts", - "content": "import { createServerClient } from '@supabase/ssr'\nimport { NextResponse, type NextRequest } from 'next/server'\n\nexport async function updateSession(request: NextRequest) {\n let supabaseResponse = NextResponse.next({\n request,\n })\n\n // With Fluid compute, don't put this client in a global environment\n // variable. Always create a new one on each request.\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,\n {\n cookies: {\n getAll() {\n return request.cookies.getAll()\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))\n supabaseResponse = NextResponse.next({\n request,\n })\n cookiesToSet.forEach(({ name, value, options }) =>\n supabaseResponse.cookies.set(name, value, options)\n )\n },\n },\n }\n )\n\n // Do not run code between createServerClient and\n // supabase.auth.getClaims(). A simple mistake could make it very hard to debug\n // issues with users being randomly logged out.\n\n // IMPORTANT: If you remove getClaims() and you use server-side rendering\n // with the Supabase client, your users may be randomly logged out.\n const { data } = await supabase.auth.getClaims()\n const user = data?.claims\n\n if (\n !user &&\n !request.nextUrl.pathname.startsWith('/login') &&\n !request.nextUrl.pathname.startsWith('/auth')\n ) {\n // no user, potentially respond by redirecting the user to the login page\n const url = request.nextUrl.clone()\n url.pathname = '/auth/login'\n return NextResponse.redirect(url)\n }\n\n // IMPORTANT: You *must* return the supabaseResponse object as it is.\n // If you're creating a new response object with NextResponse.next() make sure to:\n // 1. Pass the request in it, like so:\n // const myNewResponse = NextResponse.next({ request })\n // 2. Copy over the cookies, like so:\n // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())\n // 3. Change the myNewResponse object to fit your needs, but avoid changing\n // the cookies!\n // 4. Finally:\n // return myNewResponse\n // If this is not done, you may be causing the browser and server to go out\n // of sync and terminate the user's session prematurely!\n\n return supabaseResponse\n}\n", + "content": "import { createServerClient } from '@supabase/ssr'\nimport { NextResponse, type NextRequest } from 'next/server'\n\nexport async function updateSession(request: NextRequest) {\n let supabaseResponse = NextResponse.next({\n request,\n })\n\n // With Fluid compute, don't put this client in a global environment\n // variable. Always create a new one on each request.\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,\n {\n cookies: {\n getAll() {\n return request.cookies.getAll()\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))\n supabaseResponse = NextResponse.next({\n request,\n })\n cookiesToSet.forEach(({ name, value, options }) =>\n supabaseResponse.cookies.set(name, value, options)\n )\n },\n },\n }\n )\n\n // Do not run code between createServerClient and\n // supabase.auth.getClaims(). A simple mistake could make it very hard to debug\n // issues with users being randomly logged out.\n\n // IMPORTANT: If you remove getClaims() and you use server-side rendering\n // with the Supabase client, your users may be randomly logged out.\n const { data } = await supabase.auth.getClaims()\n const user = data?.claims\n\n if (\n !user &&\n !request.nextUrl.pathname.startsWith('/login') &&\n !request.nextUrl.pathname.startsWith('/auth') &&\n // the OAuth consent route sends unauthenticated visitors to the login page\n // itself, so that it can preserve the authorization in the `next` parameter\n request.nextUrl.pathname !== '/oauth/consent'\n ) {\n // no user, potentially respond by redirecting the user to the login page\n const url = request.nextUrl.clone()\n url.pathname = '/auth/login'\n return NextResponse.redirect(url)\n }\n\n // IMPORTANT: You *must* return the supabaseResponse object as it is.\n // If you're creating a new response object with NextResponse.next() make sure to:\n // 1. Pass the request in it, like so:\n // const myNewResponse = NextResponse.next({ request })\n // 2. Copy over the cookies, like so:\n // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())\n // 3. Change the myNewResponse object to fit your needs, but avoid changing\n // the cookies!\n // 4. Finally:\n // return myNewResponse\n // If this is not done, you may be causing the browser and server to go out\n // of sync and terminate the user's session prematurely!\n\n return supabaseResponse\n}\n", "type": "registry:lib" }, { diff --git a/apps/ui-library/public/r/realtime-chat-nextjs.json b/apps/ui-library/public/r/realtime-chat-nextjs.json index 72385d8c17fb8..43b0298b6e420 100644 --- a/apps/ui-library/public/r/realtime-chat-nextjs.json +++ b/apps/ui-library/public/r/realtime-chat-nextjs.json @@ -41,7 +41,7 @@ }, { "path": "registry/default/clients/nextjs/lib/supabase/middleware.ts", - "content": "import { createServerClient } from '@supabase/ssr'\nimport { NextResponse, type NextRequest } from 'next/server'\n\nexport async function updateSession(request: NextRequest) {\n let supabaseResponse = NextResponse.next({\n request,\n })\n\n // With Fluid compute, don't put this client in a global environment\n // variable. Always create a new one on each request.\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,\n {\n cookies: {\n getAll() {\n return request.cookies.getAll()\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))\n supabaseResponse = NextResponse.next({\n request,\n })\n cookiesToSet.forEach(({ name, value, options }) =>\n supabaseResponse.cookies.set(name, value, options)\n )\n },\n },\n }\n )\n\n // Do not run code between createServerClient and\n // supabase.auth.getClaims(). A simple mistake could make it very hard to debug\n // issues with users being randomly logged out.\n\n // IMPORTANT: If you remove getClaims() and you use server-side rendering\n // with the Supabase client, your users may be randomly logged out.\n const { data } = await supabase.auth.getClaims()\n const user = data?.claims\n\n if (\n !user &&\n !request.nextUrl.pathname.startsWith('/login') &&\n !request.nextUrl.pathname.startsWith('/auth')\n ) {\n // no user, potentially respond by redirecting the user to the login page\n const url = request.nextUrl.clone()\n url.pathname = '/auth/login'\n return NextResponse.redirect(url)\n }\n\n // IMPORTANT: You *must* return the supabaseResponse object as it is.\n // If you're creating a new response object with NextResponse.next() make sure to:\n // 1. Pass the request in it, like so:\n // const myNewResponse = NextResponse.next({ request })\n // 2. Copy over the cookies, like so:\n // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())\n // 3. Change the myNewResponse object to fit your needs, but avoid changing\n // the cookies!\n // 4. Finally:\n // return myNewResponse\n // If this is not done, you may be causing the browser and server to go out\n // of sync and terminate the user's session prematurely!\n\n return supabaseResponse\n}\n", + "content": "import { createServerClient } from '@supabase/ssr'\nimport { NextResponse, type NextRequest } from 'next/server'\n\nexport async function updateSession(request: NextRequest) {\n let supabaseResponse = NextResponse.next({\n request,\n })\n\n // With Fluid compute, don't put this client in a global environment\n // variable. Always create a new one on each request.\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,\n {\n cookies: {\n getAll() {\n return request.cookies.getAll()\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))\n supabaseResponse = NextResponse.next({\n request,\n })\n cookiesToSet.forEach(({ name, value, options }) =>\n supabaseResponse.cookies.set(name, value, options)\n )\n },\n },\n }\n )\n\n // Do not run code between createServerClient and\n // supabase.auth.getClaims(). A simple mistake could make it very hard to debug\n // issues with users being randomly logged out.\n\n // IMPORTANT: If you remove getClaims() and you use server-side rendering\n // with the Supabase client, your users may be randomly logged out.\n const { data } = await supabase.auth.getClaims()\n const user = data?.claims\n\n if (\n !user &&\n !request.nextUrl.pathname.startsWith('/login') &&\n !request.nextUrl.pathname.startsWith('/auth') &&\n // the OAuth consent route sends unauthenticated visitors to the login page\n // itself, so that it can preserve the authorization in the `next` parameter\n request.nextUrl.pathname !== '/oauth/consent'\n ) {\n // no user, potentially respond by redirecting the user to the login page\n const url = request.nextUrl.clone()\n url.pathname = '/auth/login'\n return NextResponse.redirect(url)\n }\n\n // IMPORTANT: You *must* return the supabaseResponse object as it is.\n // If you're creating a new response object with NextResponse.next() make sure to:\n // 1. Pass the request in it, like so:\n // const myNewResponse = NextResponse.next({ request })\n // 2. Copy over the cookies, like so:\n // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())\n // 3. Change the myNewResponse object to fit your needs, but avoid changing\n // the cookies!\n // 4. Finally:\n // return myNewResponse\n // If this is not done, you may be causing the browser and server to go out\n // of sync and terminate the user's session prematurely!\n\n return supabaseResponse\n}\n", "type": "registry:lib" }, { diff --git a/apps/ui-library/public/r/realtime-cursor-nextjs.json b/apps/ui-library/public/r/realtime-cursor-nextjs.json index 10d19899a200c..06ee4bd35f827 100644 --- a/apps/ui-library/public/r/realtime-cursor-nextjs.json +++ b/apps/ui-library/public/r/realtime-cursor-nextjs.json @@ -33,7 +33,7 @@ }, { "path": "registry/default/clients/nextjs/lib/supabase/middleware.ts", - "content": "import { createServerClient } from '@supabase/ssr'\nimport { NextResponse, type NextRequest } from 'next/server'\n\nexport async function updateSession(request: NextRequest) {\n let supabaseResponse = NextResponse.next({\n request,\n })\n\n // With Fluid compute, don't put this client in a global environment\n // variable. Always create a new one on each request.\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,\n {\n cookies: {\n getAll() {\n return request.cookies.getAll()\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))\n supabaseResponse = NextResponse.next({\n request,\n })\n cookiesToSet.forEach(({ name, value, options }) =>\n supabaseResponse.cookies.set(name, value, options)\n )\n },\n },\n }\n )\n\n // Do not run code between createServerClient and\n // supabase.auth.getClaims(). A simple mistake could make it very hard to debug\n // issues with users being randomly logged out.\n\n // IMPORTANT: If you remove getClaims() and you use server-side rendering\n // with the Supabase client, your users may be randomly logged out.\n const { data } = await supabase.auth.getClaims()\n const user = data?.claims\n\n if (\n !user &&\n !request.nextUrl.pathname.startsWith('/login') &&\n !request.nextUrl.pathname.startsWith('/auth')\n ) {\n // no user, potentially respond by redirecting the user to the login page\n const url = request.nextUrl.clone()\n url.pathname = '/auth/login'\n return NextResponse.redirect(url)\n }\n\n // IMPORTANT: You *must* return the supabaseResponse object as it is.\n // If you're creating a new response object with NextResponse.next() make sure to:\n // 1. Pass the request in it, like so:\n // const myNewResponse = NextResponse.next({ request })\n // 2. Copy over the cookies, like so:\n // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())\n // 3. Change the myNewResponse object to fit your needs, but avoid changing\n // the cookies!\n // 4. Finally:\n // return myNewResponse\n // If this is not done, you may be causing the browser and server to go out\n // of sync and terminate the user's session prematurely!\n\n return supabaseResponse\n}\n", + "content": "import { createServerClient } from '@supabase/ssr'\nimport { NextResponse, type NextRequest } from 'next/server'\n\nexport async function updateSession(request: NextRequest) {\n let supabaseResponse = NextResponse.next({\n request,\n })\n\n // With Fluid compute, don't put this client in a global environment\n // variable. Always create a new one on each request.\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,\n {\n cookies: {\n getAll() {\n return request.cookies.getAll()\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))\n supabaseResponse = NextResponse.next({\n request,\n })\n cookiesToSet.forEach(({ name, value, options }) =>\n supabaseResponse.cookies.set(name, value, options)\n )\n },\n },\n }\n )\n\n // Do not run code between createServerClient and\n // supabase.auth.getClaims(). A simple mistake could make it very hard to debug\n // issues with users being randomly logged out.\n\n // IMPORTANT: If you remove getClaims() and you use server-side rendering\n // with the Supabase client, your users may be randomly logged out.\n const { data } = await supabase.auth.getClaims()\n const user = data?.claims\n\n if (\n !user &&\n !request.nextUrl.pathname.startsWith('/login') &&\n !request.nextUrl.pathname.startsWith('/auth') &&\n // the OAuth consent route sends unauthenticated visitors to the login page\n // itself, so that it can preserve the authorization in the `next` parameter\n request.nextUrl.pathname !== '/oauth/consent'\n ) {\n // no user, potentially respond by redirecting the user to the login page\n const url = request.nextUrl.clone()\n url.pathname = '/auth/login'\n return NextResponse.redirect(url)\n }\n\n // IMPORTANT: You *must* return the supabaseResponse object as it is.\n // If you're creating a new response object with NextResponse.next() make sure to:\n // 1. Pass the request in it, like so:\n // const myNewResponse = NextResponse.next({ request })\n // 2. Copy over the cookies, like so:\n // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())\n // 3. Change the myNewResponse object to fit your needs, but avoid changing\n // the cookies!\n // 4. Finally:\n // return myNewResponse\n // If this is not done, you may be causing the browser and server to go out\n // of sync and terminate the user's session prematurely!\n\n return supabaseResponse\n}\n", "type": "registry:lib" }, { diff --git a/apps/ui-library/public/r/realtime-flow-nextjs.json b/apps/ui-library/public/r/realtime-flow-nextjs.json index 7249b12d55a79..58a4f65ccc91b 100644 --- a/apps/ui-library/public/r/realtime-flow-nextjs.json +++ b/apps/ui-library/public/r/realtime-flow-nextjs.json @@ -31,7 +31,7 @@ }, { "path": "registry/default/clients/nextjs/lib/supabase/middleware.ts", - "content": "import { createServerClient } from '@supabase/ssr'\nimport { NextResponse, type NextRequest } from 'next/server'\n\nexport async function updateSession(request: NextRequest) {\n let supabaseResponse = NextResponse.next({\n request,\n })\n\n // With Fluid compute, don't put this client in a global environment\n // variable. Always create a new one on each request.\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,\n {\n cookies: {\n getAll() {\n return request.cookies.getAll()\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))\n supabaseResponse = NextResponse.next({\n request,\n })\n cookiesToSet.forEach(({ name, value, options }) =>\n supabaseResponse.cookies.set(name, value, options)\n )\n },\n },\n }\n )\n\n // Do not run code between createServerClient and\n // supabase.auth.getClaims(). A simple mistake could make it very hard to debug\n // issues with users being randomly logged out.\n\n // IMPORTANT: If you remove getClaims() and you use server-side rendering\n // with the Supabase client, your users may be randomly logged out.\n const { data } = await supabase.auth.getClaims()\n const user = data?.claims\n\n if (\n !user &&\n !request.nextUrl.pathname.startsWith('/login') &&\n !request.nextUrl.pathname.startsWith('/auth')\n ) {\n // no user, potentially respond by redirecting the user to the login page\n const url = request.nextUrl.clone()\n url.pathname = '/auth/login'\n return NextResponse.redirect(url)\n }\n\n // IMPORTANT: You *must* return the supabaseResponse object as it is.\n // If you're creating a new response object with NextResponse.next() make sure to:\n // 1. Pass the request in it, like so:\n // const myNewResponse = NextResponse.next({ request })\n // 2. Copy over the cookies, like so:\n // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())\n // 3. Change the myNewResponse object to fit your needs, but avoid changing\n // the cookies!\n // 4. Finally:\n // return myNewResponse\n // If this is not done, you may be causing the browser and server to go out\n // of sync and terminate the user's session prematurely!\n\n return supabaseResponse\n}\n", + "content": "import { createServerClient } from '@supabase/ssr'\nimport { NextResponse, type NextRequest } from 'next/server'\n\nexport async function updateSession(request: NextRequest) {\n let supabaseResponse = NextResponse.next({\n request,\n })\n\n // With Fluid compute, don't put this client in a global environment\n // variable. Always create a new one on each request.\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,\n {\n cookies: {\n getAll() {\n return request.cookies.getAll()\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))\n supabaseResponse = NextResponse.next({\n request,\n })\n cookiesToSet.forEach(({ name, value, options }) =>\n supabaseResponse.cookies.set(name, value, options)\n )\n },\n },\n }\n )\n\n // Do not run code between createServerClient and\n // supabase.auth.getClaims(). A simple mistake could make it very hard to debug\n // issues with users being randomly logged out.\n\n // IMPORTANT: If you remove getClaims() and you use server-side rendering\n // with the Supabase client, your users may be randomly logged out.\n const { data } = await supabase.auth.getClaims()\n const user = data?.claims\n\n if (\n !user &&\n !request.nextUrl.pathname.startsWith('/login') &&\n !request.nextUrl.pathname.startsWith('/auth') &&\n // the OAuth consent route sends unauthenticated visitors to the login page\n // itself, so that it can preserve the authorization in the `next` parameter\n request.nextUrl.pathname !== '/oauth/consent'\n ) {\n // no user, potentially respond by redirecting the user to the login page\n const url = request.nextUrl.clone()\n url.pathname = '/auth/login'\n return NextResponse.redirect(url)\n }\n\n // IMPORTANT: You *must* return the supabaseResponse object as it is.\n // If you're creating a new response object with NextResponse.next() make sure to:\n // 1. Pass the request in it, like so:\n // const myNewResponse = NextResponse.next({ request })\n // 2. Copy over the cookies, like so:\n // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())\n // 3. Change the myNewResponse object to fit your needs, but avoid changing\n // the cookies!\n // 4. Finally:\n // return myNewResponse\n // If this is not done, you may be causing the browser and server to go out\n // of sync and terminate the user's session prematurely!\n\n return supabaseResponse\n}\n", "type": "registry:lib" }, { diff --git a/apps/ui-library/public/r/realtime-monaco-nextjs.json b/apps/ui-library/public/r/realtime-monaco-nextjs.json index cf141d4690bc2..11d0760c1ee74 100644 --- a/apps/ui-library/public/r/realtime-monaco-nextjs.json +++ b/apps/ui-library/public/r/realtime-monaco-nextjs.json @@ -30,7 +30,7 @@ }, { "path": "registry/default/clients/nextjs/lib/supabase/middleware.ts", - "content": "import { createServerClient } from '@supabase/ssr'\nimport { NextResponse, type NextRequest } from 'next/server'\n\nexport async function updateSession(request: NextRequest) {\n let supabaseResponse = NextResponse.next({\n request,\n })\n\n // With Fluid compute, don't put this client in a global environment\n // variable. Always create a new one on each request.\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,\n {\n cookies: {\n getAll() {\n return request.cookies.getAll()\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))\n supabaseResponse = NextResponse.next({\n request,\n })\n cookiesToSet.forEach(({ name, value, options }) =>\n supabaseResponse.cookies.set(name, value, options)\n )\n },\n },\n }\n )\n\n // Do not run code between createServerClient and\n // supabase.auth.getClaims(). A simple mistake could make it very hard to debug\n // issues with users being randomly logged out.\n\n // IMPORTANT: If you remove getClaims() and you use server-side rendering\n // with the Supabase client, your users may be randomly logged out.\n const { data } = await supabase.auth.getClaims()\n const user = data?.claims\n\n if (\n !user &&\n !request.nextUrl.pathname.startsWith('/login') &&\n !request.nextUrl.pathname.startsWith('/auth')\n ) {\n // no user, potentially respond by redirecting the user to the login page\n const url = request.nextUrl.clone()\n url.pathname = '/auth/login'\n return NextResponse.redirect(url)\n }\n\n // IMPORTANT: You *must* return the supabaseResponse object as it is.\n // If you're creating a new response object with NextResponse.next() make sure to:\n // 1. Pass the request in it, like so:\n // const myNewResponse = NextResponse.next({ request })\n // 2. Copy over the cookies, like so:\n // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())\n // 3. Change the myNewResponse object to fit your needs, but avoid changing\n // the cookies!\n // 4. Finally:\n // return myNewResponse\n // If this is not done, you may be causing the browser and server to go out\n // of sync and terminate the user's session prematurely!\n\n return supabaseResponse\n}\n", + "content": "import { createServerClient } from '@supabase/ssr'\nimport { NextResponse, type NextRequest } from 'next/server'\n\nexport async function updateSession(request: NextRequest) {\n let supabaseResponse = NextResponse.next({\n request,\n })\n\n // With Fluid compute, don't put this client in a global environment\n // variable. Always create a new one on each request.\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,\n {\n cookies: {\n getAll() {\n return request.cookies.getAll()\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))\n supabaseResponse = NextResponse.next({\n request,\n })\n cookiesToSet.forEach(({ name, value, options }) =>\n supabaseResponse.cookies.set(name, value, options)\n )\n },\n },\n }\n )\n\n // Do not run code between createServerClient and\n // supabase.auth.getClaims(). A simple mistake could make it very hard to debug\n // issues with users being randomly logged out.\n\n // IMPORTANT: If you remove getClaims() and you use server-side rendering\n // with the Supabase client, your users may be randomly logged out.\n const { data } = await supabase.auth.getClaims()\n const user = data?.claims\n\n if (\n !user &&\n !request.nextUrl.pathname.startsWith('/login') &&\n !request.nextUrl.pathname.startsWith('/auth') &&\n // the OAuth consent route sends unauthenticated visitors to the login page\n // itself, so that it can preserve the authorization in the `next` parameter\n request.nextUrl.pathname !== '/oauth/consent'\n ) {\n // no user, potentially respond by redirecting the user to the login page\n const url = request.nextUrl.clone()\n url.pathname = '/auth/login'\n return NextResponse.redirect(url)\n }\n\n // IMPORTANT: You *must* return the supabaseResponse object as it is.\n // If you're creating a new response object with NextResponse.next() make sure to:\n // 1. Pass the request in it, like so:\n // const myNewResponse = NextResponse.next({ request })\n // 2. Copy over the cookies, like so:\n // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())\n // 3. Change the myNewResponse object to fit your needs, but avoid changing\n // the cookies!\n // 4. Finally:\n // return myNewResponse\n // If this is not done, you may be causing the browser and server to go out\n // of sync and terminate the user's session prematurely!\n\n return supabaseResponse\n}\n", "type": "registry:lib" }, { diff --git a/apps/ui-library/public/r/registry.json b/apps/ui-library/public/r/registry.json index 6968fddc64a05..4afb6f79ca526 100644 --- a/apps/ui-library/public/r/registry.json +++ b/apps/ui-library/public/r/registry.json @@ -3,6 +3,22 @@ "name": "Supabase Library", "homepage": "https://supabase.com/library", "items": [ + { + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "safe-next-path", + "type": "registry:lib", + "title": "Safe next path", + "description": "Validates same-origin paths before redirecting.", + "registryDependencies": [], + "dependencies": [], + "files": [ + { + "path": "registry/default/blocks/safe-next-path/lib/safe-next-path.ts", + "type": "registry:lib", + "target": "lib/safe-next-path.ts" + } + ] + }, { "name": "password-based-auth-nextjs", "type": "registry:block", @@ -12,7 +28,8 @@ "button", "card", "input", - "label" + "label", + "https://supabase.com/library/r/safe-next-path.json" ], "dependencies": [ "@supabase/ssr@latest", @@ -112,7 +129,8 @@ "button", "card", "input", - "label" + "label", + "https://supabase.com/library/r/safe-next-path.json" ], "files": [ { @@ -154,7 +172,8 @@ "button", "card", "input", - "label" + "label", + "https://supabase.com/library/r/safe-next-path.json" ], "dependencies": [ "@supabase/ssr@latest", @@ -232,7 +251,8 @@ "button", "card", "input", - "label" + "label", + "https://supabase.com/library/r/safe-next-path.json" ], "dependencies": [ "@supabase/ssr@latest", @@ -326,7 +346,8 @@ "description": "Social Auth flow for Nextjs and Supabase", "registryDependencies": [ "button", - "card" + "card", + "https://supabase.com/library/r/safe-next-path.json" ], "dependencies": [ "@supabase/ssr@latest", @@ -420,7 +441,8 @@ "description": "Social Auth flow for React Router and Supabase", "registryDependencies": [ "button", - "card" + "card", + "https://supabase.com/library/r/safe-next-path.json" ], "dependencies": [ "@supabase/ssr@latest", @@ -481,7 +503,8 @@ "description": "Social Auth flow for TanStack and Supabase", "registryDependencies": [ "button", - "card" + "card", + "https://supabase.com/library/r/safe-next-path.json" ], "dependencies": [ "@supabase/ssr@latest", @@ -1723,6 +1746,172 @@ } ] }, + { + "name": "oauth-consent-nextjs", + "type": "registry:block", + "title": "OAuth Consent for Next.js", + "description": "OAuth 2.1 consent screen and route for an existing authenticated application.", + "docs": "After installation, set the following in `supabase/config.toml`. Recent CLI versions already write an `[auth.oauth_server]` section with `enabled = false` — edit that section rather than adding a second one:\n\n```toml\n[auth.oauth_server]\nenabled = true\nauthorization_url_path = \"/oauth/consent\"\n```\n\nYou'll need to set the following environment variables in your project: `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`.", + "registryDependencies": [ + "button", + "card", + "https://supabase.com/library/r/safe-next-path.json" + ], + "files": [ + { + "path": "registry/default/blocks/oauth-consent/components/oauth-consent.tsx", + "type": "registry:component" + }, + { + "path": "registry/default/blocks/oauth-consent/hooks/use-oauth-consent.ts", + "type": "registry:hook" + }, + { + "path": "registry/default/blocks/oauth-consent-nextjs/app/oauth/consent/page.tsx", + "type": "registry:page", + "target": "app/oauth/consent/page.tsx" + }, + { + "path": "registry/default/clients/nextjs/lib/supabase/client.ts", + "type": "registry:lib" + }, + { + "path": "registry/default/clients/nextjs/lib/supabase/middleware.ts", + "type": "registry:lib" + }, + { + "path": "registry/default/clients/nextjs/lib/supabase/server.ts", + "type": "registry:lib" + } + ], + "dependencies": [ + "@supabase/ssr@latest", + "@supabase/supabase-js@latest" + ], + "envVars": { + "NEXT_PUBLIC_SUPABASE_URL": "", + "NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY": "" + } + }, + { + "name": "oauth-consent-react", + "type": "registry:block", + "title": "OAuth Consent for React", + "description": "OAuth 2.1 consent component for an existing authenticated application.", + "docs": "After installation, set the following in `supabase/config.toml`. Recent CLI versions already write an `[auth.oauth_server]` section with `enabled = false` — edit that section rather than adding a second one:\n\n```toml\n[auth.oauth_server]\nenabled = true\nauthorization_url_path = \"/oauth/consent\"\n```\n\nYou'll need to set the following environment variables in your project: `VITE_SUPABASE_URL` and `VITE_SUPABASE_PUBLISHABLE_KEY`.", + "registryDependencies": [ + "button", + "card", + "https://supabase.com/library/r/safe-next-path.json" + ], + "files": [ + { + "path": "registry/default/blocks/oauth-consent/components/oauth-consent.tsx", + "type": "registry:component" + }, + { + "path": "registry/default/blocks/oauth-consent/hooks/use-oauth-consent.ts", + "type": "registry:hook" + }, + { + "path": "registry/default/clients/react/lib/supabase/client.ts", + "type": "registry:lib" + } + ], + "dependencies": [ + "@supabase/supabase-js@latest" + ], + "envVars": { + "VITE_SUPABASE_URL": "", + "VITE_SUPABASE_PUBLISHABLE_KEY": "" + } + }, + { + "name": "oauth-consent-react-router", + "type": "registry:block", + "title": "OAuth Consent for React Router", + "description": "OAuth 2.1 consent screen and route for an existing authenticated application.", + "docs": "After installation, set the following in `supabase/config.toml`. Recent CLI versions already write an `[auth.oauth_server]` section with `enabled = false` — edit that section rather than adding a second one:\n\n```toml\n[auth.oauth_server]\nenabled = true\nauthorization_url_path = \"/oauth/consent\"\n```\n\nYou'll need to set the following environment variables in your project: `VITE_SUPABASE_URL` and `VITE_SUPABASE_PUBLISHABLE_KEY`.", + "registryDependencies": [ + "button", + "card", + "https://supabase.com/library/r/safe-next-path.json" + ], + "files": [ + { + "path": "registry/default/blocks/oauth-consent/components/oauth-consent.tsx", + "type": "registry:component" + }, + { + "path": "registry/default/blocks/oauth-consent/hooks/use-oauth-consent.ts", + "type": "registry:hook" + }, + { + "path": "registry/default/blocks/oauth-consent-react-router/app/routes/oauth.consent.tsx", + "type": "registry:file", + "target": "app/routes/oauth.consent.tsx" + }, + { + "path": "registry/default/clients/react-router/lib/supabase/client.ts", + "type": "registry:lib" + }, + { + "path": "registry/default/clients/react-router/lib/supabase/server.ts", + "type": "registry:lib" + } + ], + "dependencies": [ + "@supabase/ssr@latest", + "@supabase/supabase-js@latest" + ], + "envVars": { + "VITE_SUPABASE_URL": "", + "VITE_SUPABASE_PUBLISHABLE_KEY": "" + } + }, + { + "name": "oauth-consent-tanstack", + "type": "registry:block", + "title": "OAuth Consent for TanStack Start", + "description": "OAuth 2.1 consent screen and route for an existing authenticated application.", + "docs": "After installation, set the following in `supabase/config.toml`. Recent CLI versions already write an `[auth.oauth_server]` section with `enabled = false` — edit that section rather than adding a second one:\n\n```toml\n[auth.oauth_server]\nenabled = true\nauthorization_url_path = \"/oauth/consent\"\n```\n\nYou'll need to set the following environment variables in your project: `VITE_SUPABASE_URL` and `VITE_SUPABASE_PUBLISHABLE_KEY`.", + "registryDependencies": [ + "button", + "card", + "https://supabase.com/library/r/safe-next-path.json" + ], + "files": [ + { + "path": "registry/default/blocks/oauth-consent/components/oauth-consent.tsx", + "type": "registry:component" + }, + { + "path": "registry/default/blocks/oauth-consent/hooks/use-oauth-consent.ts", + "type": "registry:hook" + }, + { + "path": "registry/default/blocks/oauth-consent-tanstack/routes/oauth/consent.tsx", + "type": "registry:file", + "target": "routes/oauth/consent.tsx" + }, + { + "path": "registry/default/clients/tanstack/lib/supabase/client.ts", + "type": "registry:lib" + }, + { + "path": "registry/default/clients/tanstack/lib/supabase/server.ts", + "type": "registry:lib" + } + ], + "dependencies": [ + "@supabase/ssr@latest", + "@supabase/supabase-js@latest" + ], + "envVars": { + "VITE_SUPABASE_URL": "", + "VITE_SUPABASE_PUBLISHABLE_KEY": "" + } + }, { "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "tanstack-db-nextjs", @@ -2651,4 +2840,4 @@ ] } ] -} +} \ No newline at end of file diff --git a/apps/ui-library/public/r/safe-next-path.json b/apps/ui-library/public/r/safe-next-path.json new file mode 100644 index 0000000000000..441364e56d0cc --- /dev/null +++ b/apps/ui-library/public/r/safe-next-path.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "safe-next-path", + "type": "registry:lib", + "title": "Safe next path", + "description": "Validates same-origin paths before redirecting.", + "dependencies": [], + "registryDependencies": [], + "files": [ + { + "path": "registry/default/blocks/safe-next-path/lib/safe-next-path.ts", + "content": "export const safeNextPath = (path: unknown, fallback = '/', origin?: string) => {\n if (typeof path !== 'string' || !path.startsWith('/')) return fallback\n\n const currentOrigin = origin ?? window.location.origin\n\n try {\n const url = new URL(path, currentOrigin)\n return url.origin === currentOrigin ? `${url.pathname}${url.search}${url.hash}` : fallback\n } catch {\n return fallback\n }\n}\n", + "type": "registry:lib", + "target": "lib/safe-next-path.ts" + } + ] +} \ No newline at end of file diff --git a/apps/ui-library/public/r/social-auth-nextjs.json b/apps/ui-library/public/r/social-auth-nextjs.json index 6463dd1b85eab..a49a2280c0e86 100644 --- a/apps/ui-library/public/r/social-auth-nextjs.json +++ b/apps/ui-library/public/r/social-auth-nextjs.json @@ -10,7 +10,8 @@ ], "registryDependencies": [ "button", - "card" + "card", + "https://supabase.com/library/r/safe-next-path.json" ], "files": [ { @@ -39,7 +40,7 @@ }, { "path": "registry/default/blocks/social-auth-nextjs/components/login-form.tsx", - "content": "'use client'\n\nimport { useState } from 'react'\n\nimport { cn } from '@/lib/utils'\nimport { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\n\nexport function LoginForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {\n const [error, setError] = useState(null)\n const [isLoading, setIsLoading] = useState(false)\n\n const handleSocialLogin = async (e: React.FormEvent) => {\n e.preventDefault()\n const supabase = createClient()\n setIsLoading(true)\n setError(null)\n\n try {\n const { error } = await supabase.auth.signInWithOAuth({\n provider: 'github',\n options: {\n redirectTo: `${window.location.origin}/auth/oauth?next=/protected`,\n },\n })\n\n if (error) throw error\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n setIsLoading(false)\n }\n }\n\n return (\n
\n \n \n Welcome!\n Sign in to your account to continue\n \n \n
\n
\n {error &&

{error}

}\n \n
\n
\n
\n
\n
\n )\n}\n", + "content": "'use client'\n\nimport { useState } from 'react'\n\nimport { cn } from '@/lib/utils'\nimport { safeNextPath } from '@/registry/default/blocks/safe-next-path/lib/safe-next-path'\nimport { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\n\nexport function LoginForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {\n const [error, setError] = useState(null)\n const [isLoading, setIsLoading] = useState(false)\n\n const handleSocialLogin = async (e: React.FormEvent) => {\n e.preventDefault()\n const supabase = createClient()\n setIsLoading(true)\n setError(null)\n\n try {\n const next = new URLSearchParams(window.location.search).get('next')\n const { error } = await supabase.auth.signInWithOAuth({\n provider: 'github',\n options: {\n redirectTo: `${window.location.origin}/auth/oauth?next=${encodeURIComponent(safeNextPath(next, '/protected'))}`,\n },\n })\n\n if (error) throw error\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n setIsLoading(false)\n }\n }\n\n return (\n
\n \n \n Welcome!\n Sign in to your account to continue\n \n \n
\n
\n {error &&

{error}

}\n \n
\n
\n
\n
\n
\n )\n}\n", "type": "registry:component" }, { @@ -60,7 +61,7 @@ }, { "path": "registry/default/clients/nextjs/lib/supabase/middleware.ts", - "content": "import { createServerClient } from '@supabase/ssr'\nimport { NextResponse, type NextRequest } from 'next/server'\n\nexport async function updateSession(request: NextRequest) {\n let supabaseResponse = NextResponse.next({\n request,\n })\n\n // With Fluid compute, don't put this client in a global environment\n // variable. Always create a new one on each request.\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,\n {\n cookies: {\n getAll() {\n return request.cookies.getAll()\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))\n supabaseResponse = NextResponse.next({\n request,\n })\n cookiesToSet.forEach(({ name, value, options }) =>\n supabaseResponse.cookies.set(name, value, options)\n )\n },\n },\n }\n )\n\n // Do not run code between createServerClient and\n // supabase.auth.getClaims(). A simple mistake could make it very hard to debug\n // issues with users being randomly logged out.\n\n // IMPORTANT: If you remove getClaims() and you use server-side rendering\n // with the Supabase client, your users may be randomly logged out.\n const { data } = await supabase.auth.getClaims()\n const user = data?.claims\n\n if (\n !user &&\n !request.nextUrl.pathname.startsWith('/login') &&\n !request.nextUrl.pathname.startsWith('/auth')\n ) {\n // no user, potentially respond by redirecting the user to the login page\n const url = request.nextUrl.clone()\n url.pathname = '/auth/login'\n return NextResponse.redirect(url)\n }\n\n // IMPORTANT: You *must* return the supabaseResponse object as it is.\n // If you're creating a new response object with NextResponse.next() make sure to:\n // 1. Pass the request in it, like so:\n // const myNewResponse = NextResponse.next({ request })\n // 2. Copy over the cookies, like so:\n // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())\n // 3. Change the myNewResponse object to fit your needs, but avoid changing\n // the cookies!\n // 4. Finally:\n // return myNewResponse\n // If this is not done, you may be causing the browser and server to go out\n // of sync and terminate the user's session prematurely!\n\n return supabaseResponse\n}\n", + "content": "import { createServerClient } from '@supabase/ssr'\nimport { NextResponse, type NextRequest } from 'next/server'\n\nexport async function updateSession(request: NextRequest) {\n let supabaseResponse = NextResponse.next({\n request,\n })\n\n // With Fluid compute, don't put this client in a global environment\n // variable. Always create a new one on each request.\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,\n {\n cookies: {\n getAll() {\n return request.cookies.getAll()\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))\n supabaseResponse = NextResponse.next({\n request,\n })\n cookiesToSet.forEach(({ name, value, options }) =>\n supabaseResponse.cookies.set(name, value, options)\n )\n },\n },\n }\n )\n\n // Do not run code between createServerClient and\n // supabase.auth.getClaims(). A simple mistake could make it very hard to debug\n // issues with users being randomly logged out.\n\n // IMPORTANT: If you remove getClaims() and you use server-side rendering\n // with the Supabase client, your users may be randomly logged out.\n const { data } = await supabase.auth.getClaims()\n const user = data?.claims\n\n if (\n !user &&\n !request.nextUrl.pathname.startsWith('/login') &&\n !request.nextUrl.pathname.startsWith('/auth') &&\n // the OAuth consent route sends unauthenticated visitors to the login page\n // itself, so that it can preserve the authorization in the `next` parameter\n request.nextUrl.pathname !== '/oauth/consent'\n ) {\n // no user, potentially respond by redirecting the user to the login page\n const url = request.nextUrl.clone()\n url.pathname = '/auth/login'\n return NextResponse.redirect(url)\n }\n\n // IMPORTANT: You *must* return the supabaseResponse object as it is.\n // If you're creating a new response object with NextResponse.next() make sure to:\n // 1. Pass the request in it, like so:\n // const myNewResponse = NextResponse.next({ request })\n // 2. Copy over the cookies, like so:\n // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())\n // 3. Change the myNewResponse object to fit your needs, but avoid changing\n // the cookies!\n // 4. Finally:\n // return myNewResponse\n // If this is not done, you may be causing the browser and server to go out\n // of sync and terminate the user's session prematurely!\n\n return supabaseResponse\n}\n", "type": "registry:lib" }, { diff --git a/apps/ui-library/public/r/social-auth-react-router.json b/apps/ui-library/public/r/social-auth-react-router.json index b6d80cfa45945..3956d8fe86b29 100644 --- a/apps/ui-library/public/r/social-auth-react-router.json +++ b/apps/ui-library/public/r/social-auth-react-router.json @@ -12,7 +12,8 @@ ], "registryDependencies": [ "button", - "card" + "card", + "https://supabase.com/library/r/safe-next-path.json" ], "files": [ { @@ -29,7 +30,7 @@ }, { "path": "registry/default/blocks/social-auth-react-router/app/routes/login.tsx", - "content": "import { redirect, useFetcher, type ActionFunctionArgs } from 'react-router'\n\nimport { createClient } from '@/registry/default/clients/react-router/lib/supabase/server'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\n\nexport const action = async ({ request }: ActionFunctionArgs) => {\n const { supabase } = createClient(request)\n const origin = new URL(request.url).origin\n\n const { data, error } = await supabase.auth.signInWithOAuth({\n provider: 'github',\n options: {\n redirectTo: `${origin}/auth/oauth?next=/protected`,\n },\n })\n\n if (data.url) {\n return redirect(data.url)\n }\n\n if (error) {\n return {\n error: error instanceof Error ? error.message : 'An error occurred',\n }\n }\n}\n\nexport default function Login() {\n const fetcher = useFetcher()\n\n const error = fetcher.data?.error\n const loading = fetcher.state === 'submitting'\n\n return (\n
\n
\n
\n \n \n Welcome!\n Sign in to your account to continue\n \n \n \n
\n {error &&

{error}

}\n \n
\n
\n
\n
\n
\n
\n
\n )\n}\n", + "content": "import { redirect, useFetcher, useSearchParams, type ActionFunctionArgs } from 'react-router'\n\nimport { safeNextPath } from '@/registry/default/blocks/safe-next-path/lib/safe-next-path'\nimport { createClient } from '@/registry/default/clients/react-router/lib/supabase/server'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\n\nexport const action = async ({ request }: ActionFunctionArgs) => {\n const { supabase, headers } = createClient(request)\n const origin = new URL(request.url).origin\n\n const formData = await request.formData()\n const next = safeNextPath(formData.get('next'), '/protected', origin)\n\n const { data, error } = await supabase.auth.signInWithOAuth({\n provider: 'github',\n options: {\n redirectTo: `${origin}/auth/oauth?next=${encodeURIComponent(next)}`,\n },\n })\n\n if (data.url) {\n return redirect(data.url, { headers })\n }\n\n if (error) {\n return {\n error: error instanceof Error ? error.message : 'An error occurred',\n }\n }\n}\n\nexport default function Login() {\n const fetcher = useFetcher()\n const [searchParams] = useSearchParams()\n\n const error = fetcher.data?.error\n const loading = fetcher.state === 'submitting'\n\n return (\n
\n
\n
\n \n \n Welcome!\n Sign in to your account to continue\n \n \n \n \n
\n {error &&

{error}

}\n \n
\n
\n
\n
\n
\n
\n
\n )\n}\n", "type": "registry:file", "target": "app/routes/login.tsx" }, diff --git a/apps/ui-library/public/r/social-auth-tanstack.json b/apps/ui-library/public/r/social-auth-tanstack.json index 4dddd3c74694a..6f5aae7f630dd 100644 --- a/apps/ui-library/public/r/social-auth-tanstack.json +++ b/apps/ui-library/public/r/social-auth-tanstack.json @@ -10,12 +10,13 @@ ], "registryDependencies": [ "button", - "card" + "card", + "https://supabase.com/library/r/safe-next-path.json" ], "files": [ { "path": "registry/default/blocks/social-auth-tanstack/components/login-form.tsx", - "content": "import { useState } from 'react'\n\nimport { cn } from '@/lib/utils'\nimport { createClient } from '@/registry/default/clients/tanstack/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\n\nexport function LoginForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {\n const [error, setError] = useState(null)\n const [isLoading, setIsLoading] = useState(false)\n\n const handleSocialLogin = async (e: React.FormEvent) => {\n e.preventDefault()\n const supabase = createClient()\n setIsLoading(true)\n setError(null)\n\n try {\n const { error } = await supabase.auth.signInWithOAuth({\n provider: 'github',\n options: {\n redirectTo: `${window.location.origin}/auth/oauth?next=/protected`,\n },\n })\n\n if (error) throw error\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n setIsLoading(false)\n }\n }\n\n return (\n
\n \n \n Welcome!\n Sign in to your account to continue\n \n \n
\n
\n {error &&

{error}

}\n \n
\n
\n
\n
\n
\n )\n}\n", + "content": "import { useState } from 'react'\n\nimport { cn } from '@/lib/utils'\nimport { safeNextPath } from '@/registry/default/blocks/safe-next-path/lib/safe-next-path'\nimport { createClient } from '@/registry/default/clients/tanstack/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\n\nexport function LoginForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {\n const [error, setError] = useState(null)\n const [isLoading, setIsLoading] = useState(false)\n\n const handleSocialLogin = async (e: React.FormEvent) => {\n e.preventDefault()\n const supabase = createClient()\n setIsLoading(true)\n setError(null)\n\n try {\n const next = new URLSearchParams(window.location.search).get('next')\n const { error } = await supabase.auth.signInWithOAuth({\n provider: 'github',\n options: {\n redirectTo: `${window.location.origin}/auth/confirm?next=${encodeURIComponent(safeNextPath(next, '/protected'))}`,\n },\n })\n\n if (error) throw error\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n setIsLoading(false)\n }\n }\n\n return (\n
\n \n \n Welcome!\n Sign in to your account to continue\n \n \n
\n
\n {error &&

{error}

}\n \n
\n
\n
\n
\n
\n )\n}\n", "type": "registry:component" }, { diff --git a/apps/ui-library/public/r/supabase-client-nextjs.json b/apps/ui-library/public/r/supabase-client-nextjs.json index c7925d9f8d7ce..5e8fba07ebe33 100644 --- a/apps/ui-library/public/r/supabase-client-nextjs.json +++ b/apps/ui-library/public/r/supabase-client-nextjs.json @@ -17,7 +17,7 @@ }, { "path": "registry/default/clients/nextjs/lib/supabase/middleware.ts", - "content": "import { createServerClient } from '@supabase/ssr'\nimport { NextResponse, type NextRequest } from 'next/server'\n\nexport async function updateSession(request: NextRequest) {\n let supabaseResponse = NextResponse.next({\n request,\n })\n\n // With Fluid compute, don't put this client in a global environment\n // variable. Always create a new one on each request.\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,\n {\n cookies: {\n getAll() {\n return request.cookies.getAll()\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))\n supabaseResponse = NextResponse.next({\n request,\n })\n cookiesToSet.forEach(({ name, value, options }) =>\n supabaseResponse.cookies.set(name, value, options)\n )\n },\n },\n }\n )\n\n // Do not run code between createServerClient and\n // supabase.auth.getClaims(). A simple mistake could make it very hard to debug\n // issues with users being randomly logged out.\n\n // IMPORTANT: If you remove getClaims() and you use server-side rendering\n // with the Supabase client, your users may be randomly logged out.\n const { data } = await supabase.auth.getClaims()\n const user = data?.claims\n\n if (\n !user &&\n !request.nextUrl.pathname.startsWith('/login') &&\n !request.nextUrl.pathname.startsWith('/auth')\n ) {\n // no user, potentially respond by redirecting the user to the login page\n const url = request.nextUrl.clone()\n url.pathname = '/auth/login'\n return NextResponse.redirect(url)\n }\n\n // IMPORTANT: You *must* return the supabaseResponse object as it is.\n // If you're creating a new response object with NextResponse.next() make sure to:\n // 1. Pass the request in it, like so:\n // const myNewResponse = NextResponse.next({ request })\n // 2. Copy over the cookies, like so:\n // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())\n // 3. Change the myNewResponse object to fit your needs, but avoid changing\n // the cookies!\n // 4. Finally:\n // return myNewResponse\n // If this is not done, you may be causing the browser and server to go out\n // of sync and terminate the user's session prematurely!\n\n return supabaseResponse\n}\n", + "content": "import { createServerClient } from '@supabase/ssr'\nimport { NextResponse, type NextRequest } from 'next/server'\n\nexport async function updateSession(request: NextRequest) {\n let supabaseResponse = NextResponse.next({\n request,\n })\n\n // With Fluid compute, don't put this client in a global environment\n // variable. Always create a new one on each request.\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,\n {\n cookies: {\n getAll() {\n return request.cookies.getAll()\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))\n supabaseResponse = NextResponse.next({\n request,\n })\n cookiesToSet.forEach(({ name, value, options }) =>\n supabaseResponse.cookies.set(name, value, options)\n )\n },\n },\n }\n )\n\n // Do not run code between createServerClient and\n // supabase.auth.getClaims(). A simple mistake could make it very hard to debug\n // issues with users being randomly logged out.\n\n // IMPORTANT: If you remove getClaims() and you use server-side rendering\n // with the Supabase client, your users may be randomly logged out.\n const { data } = await supabase.auth.getClaims()\n const user = data?.claims\n\n if (\n !user &&\n !request.nextUrl.pathname.startsWith('/login') &&\n !request.nextUrl.pathname.startsWith('/auth') &&\n // the OAuth consent route sends unauthenticated visitors to the login page\n // itself, so that it can preserve the authorization in the `next` parameter\n request.nextUrl.pathname !== '/oauth/consent'\n ) {\n // no user, potentially respond by redirecting the user to the login page\n const url = request.nextUrl.clone()\n url.pathname = '/auth/login'\n return NextResponse.redirect(url)\n }\n\n // IMPORTANT: You *must* return the supabaseResponse object as it is.\n // If you're creating a new response object with NextResponse.next() make sure to:\n // 1. Pass the request in it, like so:\n // const myNewResponse = NextResponse.next({ request })\n // 2. Copy over the cookies, like so:\n // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())\n // 3. Change the myNewResponse object to fit your needs, but avoid changing\n // the cookies!\n // 4. Finally:\n // return myNewResponse\n // If this is not done, you may be causing the browser and server to go out\n // of sync and terminate the user's session prematurely!\n\n return supabaseResponse\n}\n", "type": "registry:lib" }, { diff --git a/apps/ui-library/public/r/tanstack-db-nextjs.json b/apps/ui-library/public/r/tanstack-db-nextjs.json index aafa10ab5d047..af5db958533ff 100644 --- a/apps/ui-library/public/r/tanstack-db-nextjs.json +++ b/apps/ui-library/public/r/tanstack-db-nextjs.json @@ -26,7 +26,7 @@ }, { "path": "registry/default/clients/nextjs/lib/supabase/middleware.ts", - "content": "import { createServerClient } from '@supabase/ssr'\nimport { NextResponse, type NextRequest } from 'next/server'\n\nexport async function updateSession(request: NextRequest) {\n let supabaseResponse = NextResponse.next({\n request,\n })\n\n // With Fluid compute, don't put this client in a global environment\n // variable. Always create a new one on each request.\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,\n {\n cookies: {\n getAll() {\n return request.cookies.getAll()\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))\n supabaseResponse = NextResponse.next({\n request,\n })\n cookiesToSet.forEach(({ name, value, options }) =>\n supabaseResponse.cookies.set(name, value, options)\n )\n },\n },\n }\n )\n\n // Do not run code between createServerClient and\n // supabase.auth.getClaims(). A simple mistake could make it very hard to debug\n // issues with users being randomly logged out.\n\n // IMPORTANT: If you remove getClaims() and you use server-side rendering\n // with the Supabase client, your users may be randomly logged out.\n const { data } = await supabase.auth.getClaims()\n const user = data?.claims\n\n if (\n !user &&\n !request.nextUrl.pathname.startsWith('/login') &&\n !request.nextUrl.pathname.startsWith('/auth')\n ) {\n // no user, potentially respond by redirecting the user to the login page\n const url = request.nextUrl.clone()\n url.pathname = '/auth/login'\n return NextResponse.redirect(url)\n }\n\n // IMPORTANT: You *must* return the supabaseResponse object as it is.\n // If you're creating a new response object with NextResponse.next() make sure to:\n // 1. Pass the request in it, like so:\n // const myNewResponse = NextResponse.next({ request })\n // 2. Copy over the cookies, like so:\n // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())\n // 3. Change the myNewResponse object to fit your needs, but avoid changing\n // the cookies!\n // 4. Finally:\n // return myNewResponse\n // If this is not done, you may be causing the browser and server to go out\n // of sync and terminate the user's session prematurely!\n\n return supabaseResponse\n}\n", + "content": "import { createServerClient } from '@supabase/ssr'\nimport { NextResponse, type NextRequest } from 'next/server'\n\nexport async function updateSession(request: NextRequest) {\n let supabaseResponse = NextResponse.next({\n request,\n })\n\n // With Fluid compute, don't put this client in a global environment\n // variable. Always create a new one on each request.\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,\n {\n cookies: {\n getAll() {\n return request.cookies.getAll()\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))\n supabaseResponse = NextResponse.next({\n request,\n })\n cookiesToSet.forEach(({ name, value, options }) =>\n supabaseResponse.cookies.set(name, value, options)\n )\n },\n },\n }\n )\n\n // Do not run code between createServerClient and\n // supabase.auth.getClaims(). A simple mistake could make it very hard to debug\n // issues with users being randomly logged out.\n\n // IMPORTANT: If you remove getClaims() and you use server-side rendering\n // with the Supabase client, your users may be randomly logged out.\n const { data } = await supabase.auth.getClaims()\n const user = data?.claims\n\n if (\n !user &&\n !request.nextUrl.pathname.startsWith('/login') &&\n !request.nextUrl.pathname.startsWith('/auth') &&\n // the OAuth consent route sends unauthenticated visitors to the login page\n // itself, so that it can preserve the authorization in the `next` parameter\n request.nextUrl.pathname !== '/oauth/consent'\n ) {\n // no user, potentially respond by redirecting the user to the login page\n const url = request.nextUrl.clone()\n url.pathname = '/auth/login'\n return NextResponse.redirect(url)\n }\n\n // IMPORTANT: You *must* return the supabaseResponse object as it is.\n // If you're creating a new response object with NextResponse.next() make sure to:\n // 1. Pass the request in it, like so:\n // const myNewResponse = NextResponse.next({ request })\n // 2. Copy over the cookies, like so:\n // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())\n // 3. Change the myNewResponse object to fit your needs, but avoid changing\n // the cookies!\n // 4. Finally:\n // return myNewResponse\n // If this is not done, you may be causing the browser and server to go out\n // of sync and terminate the user's session prematurely!\n\n return supabaseResponse\n}\n", "type": "registry:lib" }, { diff --git a/apps/ui-library/registry/blocks.ts b/apps/ui-library/registry/blocks.ts index 4be38f7085a58..f1c045df99922 100644 --- a/apps/ui-library/registry/blocks.ts +++ b/apps/ui-library/registry/blocks.ts @@ -4,6 +4,10 @@ import { clients } from './clients' import currentUserAvatar from './default/blocks/current-user-avatar/registry-item.json' with { type: 'json' } import dropzone from './default/blocks/dropzone/registry-item.json' with { type: 'json' } import infiniteQueryHook from './default/blocks/infinite-query-hook/registry-item.json' with { type: 'json' } +import oauthConsentNextjs from './default/blocks/oauth-consent-nextjs/registry-item.json' with { type: 'json' } +import oauthConsentReactRouter from './default/blocks/oauth-consent-react-router/registry-item.json' with { type: 'json' } +import oauthConsentReact from './default/blocks/oauth-consent-react/registry-item.json' with { type: 'json' } +import oauthConsentTanstack from './default/blocks/oauth-consent-tanstack/registry-item.json' with { type: 'json' } import passwordBasedAuthNextjs from './default/blocks/password-based-auth-nextjs/registry-item.json' with { type: 'json' } import passwordBasedAuthReactRouter from './default/blocks/password-based-auth-react-router/registry-item.json' with { type: 'json' } import passwordBasedAuthReact from './default/blocks/password-based-auth-react/registry-item.json' with { type: 'json' } @@ -13,6 +17,7 @@ import realtimeChat from './default/blocks/realtime-chat/registry-item.json' wit import realtimeCursor from './default/blocks/realtime-cursor/registry-item.json' with { type: 'json' } import realtimeFlow from './default/blocks/realtime-flow/registry-item.json' with { type: 'json' } import realtimeMonaco from './default/blocks/realtime-monaco/registry-item.json' with { type: 'json' } +import safeNextPath from './default/blocks/safe-next-path/registry-item.json' with { type: 'json' } import socialAuthNextjs from './default/blocks/social-auth-nextjs/registry-item.json' with { type: 'json' } import socialAuthReactRouter from './default/blocks/social-auth-react-router/registry-item.json' with { type: 'json' } import socialAuthReact from './default/blocks/social-auth-react/registry-item.json' with { type: 'json' } @@ -32,12 +37,19 @@ const combine = (component: RegistryItem) => { }) } +const withClientAndDocs = (component: RegistryItem, client: RegistryItem) => ({ + ...registryItemAppend(component, [client]), + docs: [component.docs, client.docs].filter(Boolean).join('\n\n'), +}) + const nextjsClient = clients.find((client) => client.name === 'supabase-client-nextjs') const reactClient = clients.find((client) => client.name === 'supabase-client-react') const tanstackClient = clients.find((client) => client.name === 'supabase-client-tanstack') const reactRouterClient = clients.find((client) => client.name === 'supabase-client-react-router') export const blocks = [ + safeNextPath as RegistryItem, + registryItemAppend(passwordBasedAuthNextjs as RegistryItem, [nextjsClient!]), registryItemAppend(passwordBasedAuthReact as RegistryItem, [reactClient!]), registryItemAppend(passwordBasedAuthReactRouter as RegistryItem, [reactRouterClient!]), @@ -58,6 +70,11 @@ export const blocks = [ // infinite query hook is intentionally not combined with the clients since it depends on clients having database types. infiniteQueryHook as RegistryItem, + withClientAndDocs(oauthConsentNextjs as RegistryItem, nextjsClient!), + withClientAndDocs(oauthConsentReact as RegistryItem, reactClient!), + withClientAndDocs(oauthConsentReactRouter as RegistryItem, reactRouterClient!), + withClientAndDocs(oauthConsentTanstack as RegistryItem, tanstackClient!), + // tanstack-db is served dynamically via API route, but we register it here for the static build registryItemAppend(tanstackDbNextjs as RegistryItem, [nextjsClient!]), ] as RegistryItem[] diff --git a/apps/ui-library/registry/default/blocks/oauth-consent-nextjs/app/oauth/consent/page.tsx b/apps/ui-library/registry/default/blocks/oauth-consent-nextjs/app/oauth/consent/page.tsx new file mode 100644 index 0000000000000..444f8b75d800f --- /dev/null +++ b/apps/ui-library/registry/default/blocks/oauth-consent-nextjs/app/oauth/consent/page.tsx @@ -0,0 +1,15 @@ +import { OAuthConsent } from '@/registry/default/blocks/oauth-consent/components/oauth-consent' + +export default async function ConsentPage({ + searchParams, +}: { + searchParams: Promise<{ authorization_id?: string }> +}) { + const { authorization_id } = await searchParams + + return ( +
+ +
+ ) +} diff --git a/apps/ui-library/registry/default/blocks/oauth-consent-nextjs/registry-item.json b/apps/ui-library/registry/default/blocks/oauth-consent-nextjs/registry-item.json new file mode 100644 index 0000000000000..9eb440eed246c --- /dev/null +++ b/apps/ui-library/registry/default/blocks/oauth-consent-nextjs/registry-item.json @@ -0,0 +1,23 @@ +{ + "name": "oauth-consent-nextjs", + "type": "registry:block", + "title": "OAuth Consent for Next.js", + "description": "OAuth 2.1 consent screen and route for an existing authenticated application.", + "docs": "After installation, set the following in `supabase/config.toml`. Recent CLI versions already write an `[auth.oauth_server]` section with `enabled = false` \u2014 edit that section rather than adding a second one:\n\n```toml\n[auth.oauth_server]\nenabled = true\nauthorization_url_path = \"/oauth/consent\"\n```", + "registryDependencies": ["button", "card", "https://supabase.com/library/r/safe-next-path.json"], + "files": [ + { + "path": "registry/default/blocks/oauth-consent/components/oauth-consent.tsx", + "type": "registry:component" + }, + { + "path": "registry/default/blocks/oauth-consent/hooks/use-oauth-consent.ts", + "type": "registry:hook" + }, + { + "path": "registry/default/blocks/oauth-consent-nextjs/app/oauth/consent/page.tsx", + "type": "registry:page", + "target": "app/oauth/consent/page.tsx" + } + ] +} diff --git a/apps/ui-library/registry/default/blocks/oauth-consent-react-router/app/routes/oauth.consent.tsx b/apps/ui-library/registry/default/blocks/oauth-consent-react-router/app/routes/oauth.consent.tsx new file mode 100644 index 0000000000000..444a8f4a177cb --- /dev/null +++ b/apps/ui-library/registry/default/blocks/oauth-consent-react-router/app/routes/oauth.consent.tsx @@ -0,0 +1,16 @@ +import { useSearchParams } from 'react-router' + +import { OAuthConsent } from '@/registry/default/blocks/oauth-consent/components/oauth-consent' + +export default function ConsentRoute() { + const [searchParams] = useSearchParams() + + return ( +
+ +
+ ) +} diff --git a/apps/ui-library/registry/default/blocks/oauth-consent-react-router/registry-item.json b/apps/ui-library/registry/default/blocks/oauth-consent-react-router/registry-item.json new file mode 100644 index 0000000000000..aafde11087346 --- /dev/null +++ b/apps/ui-library/registry/default/blocks/oauth-consent-react-router/registry-item.json @@ -0,0 +1,23 @@ +{ + "name": "oauth-consent-react-router", + "type": "registry:block", + "title": "OAuth Consent for React Router", + "description": "OAuth 2.1 consent screen and route for an existing authenticated application.", + "docs": "After installation, set the following in `supabase/config.toml`. Recent CLI versions already write an `[auth.oauth_server]` section with `enabled = false` \u2014 edit that section rather than adding a second one:\n\n```toml\n[auth.oauth_server]\nenabled = true\nauthorization_url_path = \"/oauth/consent\"\n```", + "registryDependencies": ["button", "card", "https://supabase.com/library/r/safe-next-path.json"], + "files": [ + { + "path": "registry/default/blocks/oauth-consent/components/oauth-consent.tsx", + "type": "registry:component" + }, + { + "path": "registry/default/blocks/oauth-consent/hooks/use-oauth-consent.ts", + "type": "registry:hook" + }, + { + "path": "registry/default/blocks/oauth-consent-react-router/app/routes/oauth.consent.tsx", + "type": "registry:file", + "target": "app/routes/oauth.consent.tsx" + } + ] +} diff --git a/apps/ui-library/registry/default/blocks/oauth-consent-react/registry-item.json b/apps/ui-library/registry/default/blocks/oauth-consent-react/registry-item.json new file mode 100644 index 0000000000000..40c3f2cd74e47 --- /dev/null +++ b/apps/ui-library/registry/default/blocks/oauth-consent-react/registry-item.json @@ -0,0 +1,18 @@ +{ + "name": "oauth-consent-react", + "type": "registry:block", + "title": "OAuth Consent for React", + "description": "OAuth 2.1 consent component for an existing authenticated application.", + "docs": "After installation, set the following in `supabase/config.toml`. Recent CLI versions already write an `[auth.oauth_server]` section with `enabled = false` \u2014 edit that section rather than adding a second one:\n\n```toml\n[auth.oauth_server]\nenabled = true\nauthorization_url_path = \"/oauth/consent\"\n```", + "registryDependencies": ["button", "card", "https://supabase.com/library/r/safe-next-path.json"], + "files": [ + { + "path": "registry/default/blocks/oauth-consent/components/oauth-consent.tsx", + "type": "registry:component" + }, + { + "path": "registry/default/blocks/oauth-consent/hooks/use-oauth-consent.ts", + "type": "registry:hook" + } + ] +} diff --git a/apps/ui-library/registry/default/blocks/oauth-consent-tanstack/registry-item.json b/apps/ui-library/registry/default/blocks/oauth-consent-tanstack/registry-item.json new file mode 100644 index 0000000000000..f531763b62b30 --- /dev/null +++ b/apps/ui-library/registry/default/blocks/oauth-consent-tanstack/registry-item.json @@ -0,0 +1,23 @@ +{ + "name": "oauth-consent-tanstack", + "type": "registry:block", + "title": "OAuth Consent for TanStack Start", + "description": "OAuth 2.1 consent screen and route for an existing authenticated application.", + "docs": "After installation, set the following in `supabase/config.toml`. Recent CLI versions already write an `[auth.oauth_server]` section with `enabled = false` \u2014 edit that section rather than adding a second one:\n\n```toml\n[auth.oauth_server]\nenabled = true\nauthorization_url_path = \"/oauth/consent\"\n```", + "registryDependencies": ["button", "card", "https://supabase.com/library/r/safe-next-path.json"], + "files": [ + { + "path": "registry/default/blocks/oauth-consent/components/oauth-consent.tsx", + "type": "registry:component" + }, + { + "path": "registry/default/blocks/oauth-consent/hooks/use-oauth-consent.ts", + "type": "registry:hook" + }, + { + "path": "registry/default/blocks/oauth-consent-tanstack/routes/oauth/consent.tsx", + "type": "registry:file", + "target": "routes/oauth/consent.tsx" + } + ] +} diff --git a/apps/ui-library/registry/default/blocks/oauth-consent-tanstack/routes/oauth/consent.tsx b/apps/ui-library/registry/default/blocks/oauth-consent-tanstack/routes/oauth/consent.tsx new file mode 100644 index 0000000000000..ba795f2395570 --- /dev/null +++ b/apps/ui-library/registry/default/blocks/oauth-consent-tanstack/routes/oauth/consent.tsx @@ -0,0 +1,23 @@ +import { createFileRoute } from '@tanstack/react-router' + +import { OAuthConsent } from '@/registry/default/blocks/oauth-consent/components/oauth-consent' + +// This route is installed into the consumer's routes directory. Their TanStack +// route generator adds it to FileRoutesByPath after installation. +// @ts-expect-error The local generated route tree does not include this block route. +export const Route = createFileRoute('/oauth/consent')({ + component: ConsentPage, + validateSearch: (search) => ({ + authorization_id: typeof search.authorization_id === 'string' ? search.authorization_id : null, + }), +}) + +function ConsentPage() { + const { authorization_id } = Route.useSearch() + + return ( +
+ +
+ ) +} diff --git a/apps/ui-library/registry/default/blocks/oauth-consent/components/oauth-consent.tsx b/apps/ui-library/registry/default/blocks/oauth-consent/components/oauth-consent.tsx new file mode 100644 index 0000000000000..c143f515c778c --- /dev/null +++ b/apps/ui-library/registry/default/blocks/oauth-consent/components/oauth-consent.tsx @@ -0,0 +1,173 @@ +'use client' + +import { cn } from '@/lib/utils' +import { + useOAuthConsent, + type OAuthConsentDecision, +} from '@/registry/default/blocks/oauth-consent/hooks/use-oauth-consent' +import { Button } from '@/registry/default/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/registry/default/components/ui/card' + +const getInitial = (value: string) => value.trim().charAt(0).toUpperCase() || '?' + +interface ConsentCardShellProps extends React.ComponentPropsWithoutRef<'div'> { + clientName: string + productName: string +} + +function ConsentCardShell({ + clientName, + productName, + className, + children, + ...props +}: ConsentCardShellProps) { + return ( +
+ + +
+
+ {getInitial(clientName)} +
+ +
+ Authorize {clientName} + Review what this client gets access to. +
+ + {children} + +
+ ) +} + +export interface OAuthConsentCardProps extends React.ComponentPropsWithoutRef<'div'> { + clientName: string + productName?: string + redirectUri: string + email: string + scopes?: string[] + error?: string | null + decision?: OAuthConsentDecision | null + onApprove?: () => void + onDeny?: () => void +} + +export function OAuthConsentCard({ + clientName, + productName = 'Your product', + redirectUri, + email, + scopes = [], + error = null, + decision = null, + onApprove, + onDeny, + ...props +}: OAuthConsentCardProps) { + return ( + +
+
+
Client
+
{clientName}
+
+
+
Redirects to
+
{redirectUri}
+
+
+
Signed in as
+
{email}
+
+ {scopes.length > 0 && ( +
+
Scopes
+
{scopes.join(', ')}
+
+ )} +
+

+ Allow access only if you recognize this application. It can act on your behalf only within + the requested permissions and the access you already have. +

+ {error && ( +

+ {error} +

+ )} +
+ + +
+
+ ) +} + +interface OAuthConsentProps extends React.ComponentPropsWithoutRef<'div'> { + authorizationId?: string | null + signInPath?: string + productName?: string +} + +export function OAuthConsent({ + authorizationId, + signInPath = '/auth/login', + productName = 'Your product', + ...props +}: OAuthConsentProps) { + const { details, email, error, isLoading, decision, approve, deny } = useOAuthConsent({ + authorizationId, + signInPath, + }) + + if (isLoading || !details || !email) { + return ( + + {isLoading ? ( +

+ Loading authorization request... +

+ ) : ( +

+ {error ?? + 'Unable to load the authorization request. Start again from your OAuth client.'} +

+ )} +
+ ) + } + + return ( + void approve()} + onDeny={() => void deny()} + {...props} + /> + ) +} diff --git a/apps/ui-library/registry/default/blocks/oauth-consent/hooks/use-oauth-consent.ts b/apps/ui-library/registry/default/blocks/oauth-consent/hooks/use-oauth-consent.ts new file mode 100644 index 0000000000000..337381b6c7402 --- /dev/null +++ b/apps/ui-library/registry/default/blocks/oauth-consent/hooks/use-oauth-consent.ts @@ -0,0 +1,147 @@ +import { isAuthSessionMissingError } from '@supabase/supabase-js' +import type { OAuthAuthorizationDetails } from '@supabase/supabase-js' +import { useCallback, useEffect, useRef, useState } from 'react' + +import { safeNextPath } from '@/registry/default/blocks/safe-next-path/lib/safe-next-path' +import { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client' + +export type OAuthConsentDecision = 'approve' | 'deny' + +export interface UseOAuthConsentOptions { + authorizationId?: string | null + signInPath?: string +} + +const withNextParam = (path: string, next: string) => { + const url = new URL(path, window.location.origin) + const searchParams = new URLSearchParams(url.search) + searchParams.set('next', next) + url.search = searchParams.toString() + return `${url.pathname}${url.search}${url.hash}` +} + +const useOAuthConsent = ({ + authorizationId, + signInPath = '/auth/login', +}: UseOAuthConsentOptions) => { + const [details, setDetails] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [decision, setDecision] = useState(null) + const isDeciding = useRef(false) + + useEffect(() => { + let active = true + + const loadAuthorization = async () => { + setIsLoading(true) + setError(null) + setDetails(null) + setDecision(null) + + if (!authorizationId) { + setError('This page needs an authorization_id. Start again from your OAuth client.') + setIsLoading(false) + return + } + + const supabase = createClient() + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser() + + if (userError && !isAuthSessionMissingError(userError)) { + if (active) { + setError(userError.message) + setIsLoading(false) + } + return + } + + if (!user) { + const next = `${window.location.pathname}${window.location.search}` + if (active) { + window.location.replace(withNextParam(safeNextPath(signInPath, '/auth/login'), next)) + } + return + } + + const { data, error } = await supabase.auth.oauth.getAuthorizationDetails(authorizationId) + if (error) { + if (active) { + setError(error.message) + setIsLoading(false) + } + return + } + + if (!('authorization_id' in data)) { + if (active) { + window.location.replace(data.redirect_url) + } + return + } + + if (active) { + setDetails(data) + setIsLoading(false) + } + } + + void loadAuthorization() + return () => { + active = false + } + }, [authorizationId, signInPath]) + + const decide = useCallback( + async (action: OAuthConsentDecision) => { + if (!authorizationId || isDeciding.current) return + + isDeciding.current = true + setDecision(action) + setError(null) + const supabase = createClient() + const result = + action === 'approve' + ? await supabase.auth.oauth.approveAuthorization(authorizationId, { + skipBrowserRedirect: true, + }) + : await supabase.auth.oauth.denyAuthorization(authorizationId, { + skipBrowserRedirect: true, + }) + + if (result.error) { + setError(result.error.message) + setDecision(null) + isDeciding.current = false + return + } + + if (!result.data?.redirect_url) { + setError('The server did not return a redirect. Start again from your OAuth client.') + setDecision(null) + isDeciding.current = false + return + } + + window.location.assign(result.data.redirect_url) + }, + [authorizationId] + ) + + return { + details, + email: details?.user.email ?? null, + error, + isLoading, + decision, + approve: () => decide('approve'), + deny: () => decide('deny'), + } +} + +type UseOAuthConsentReturn = ReturnType + +export { useOAuthConsent, type OAuthAuthorizationDetails, type UseOAuthConsentReturn } diff --git a/apps/ui-library/registry/default/blocks/password-based-auth-nextjs/components/login-form.tsx b/apps/ui-library/registry/default/blocks/password-based-auth-nextjs/components/login-form.tsx index 30dc196709339..39bc9640d7bb2 100644 --- a/apps/ui-library/registry/default/blocks/password-based-auth-nextjs/components/login-form.tsx +++ b/apps/ui-library/registry/default/blocks/password-based-auth-nextjs/components/login-form.tsx @@ -4,6 +4,7 @@ import { useRouter } from 'next/navigation' import { useState } from 'react' import { cn } from '@/lib/utils' +import { safeNextPath } from '@/registry/default/blocks/safe-next-path/lib/safe-next-path' import { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client' import { Button } from '@/registry/default/components/ui/button' import { @@ -37,7 +38,8 @@ export function LoginForm({ className, ...props }: React.ComponentPropsWithoutRe }) if (error) throw error // Update this route to redirect to an authenticated route. The user already has an active session. - router.push('/example/password-based-auth/protected') + const next = new URLSearchParams(window.location.search).get('next') + router.push(safeNextPath(next, '/example/password-based-auth/protected')) } catch (error: unknown) { setError(error instanceof Error ? error.message : 'An error occurred') } finally { diff --git a/apps/ui-library/registry/default/blocks/password-based-auth-nextjs/registry-item.json b/apps/ui-library/registry/default/blocks/password-based-auth-nextjs/registry-item.json index 2203774c8dc53..f0a593ff84fec 100644 --- a/apps/ui-library/registry/default/blocks/password-based-auth-nextjs/registry-item.json +++ b/apps/ui-library/registry/default/blocks/password-based-auth-nextjs/registry-item.json @@ -3,7 +3,13 @@ "type": "registry:block", "title": "Password Based Auth flow for Nextjs and Supabase", "description": "Password Based Auth flow for Nextjs and Supabase", - "registryDependencies": ["button", "card", "input", "label"], + "registryDependencies": [ + "button", + "card", + "input", + "label", + "https://supabase.com/library/r/safe-next-path.json" + ], "dependencies": ["@supabase/ssr@latest", "@supabase/supabase-js@latest"], "files": [ { diff --git a/apps/ui-library/registry/default/blocks/password-based-auth-react-router/app/routes/login.tsx b/apps/ui-library/registry/default/blocks/password-based-auth-react-router/app/routes/login.tsx index 04ff0dc42861b..15d9defc3429f 100644 --- a/apps/ui-library/registry/default/blocks/password-based-auth-react-router/app/routes/login.tsx +++ b/apps/ui-library/registry/default/blocks/password-based-auth-react-router/app/routes/login.tsx @@ -1,5 +1,6 @@ -import { Link, redirect, useFetcher, type ActionFunctionArgs } from 'react-router' +import { Link, redirect, useFetcher, useSearchParams, type ActionFunctionArgs } from 'react-router' +import { safeNextPath } from '@/registry/default/blocks/safe-next-path/lib/safe-next-path' import { createClient } from '@/registry/default/clients/react-router/lib/supabase/server' import { Button } from '@/registry/default/components/ui/button' import { @@ -14,6 +15,7 @@ import { Label } from '@/registry/default/components/ui/label' export const action = async ({ request }: ActionFunctionArgs) => { const { supabase, headers } = createClient(request) + const origin = new URL(request.url).origin const formData = await request.formData() @@ -32,11 +34,12 @@ export const action = async ({ request }: ActionFunctionArgs) => { } // Update this route to redirect to an authenticated route. The user already has an active session. - return redirect('/protected', { headers }) + return redirect(safeNextPath(formData.get('next'), '/protected', origin), { headers }) } export default function Login() { const fetcher = useFetcher() + const [searchParams] = useSearchParams() const error = fetcher.data?.error const loading = fetcher.state === 'submitting' @@ -52,6 +55,7 @@ export default function Login() {
+
diff --git a/apps/ui-library/registry/default/blocks/password-based-auth-react-router/registry-item.json b/apps/ui-library/registry/default/blocks/password-based-auth-react-router/registry-item.json index be88ea4890485..b0ac01f4c0d71 100644 --- a/apps/ui-library/registry/default/blocks/password-based-auth-react-router/registry-item.json +++ b/apps/ui-library/registry/default/blocks/password-based-auth-react-router/registry-item.json @@ -3,7 +3,13 @@ "type": "registry:block", "title": "Password Based Auth flow for React Router and Supabase", "description": "Password Based Auth flow for React Router and Supabase", - "registryDependencies": ["button", "card", "input", "label"], + "registryDependencies": [ + "button", + "card", + "input", + "label", + "https://supabase.com/library/r/safe-next-path.json" + ], "dependencies": [ "@supabase/ssr@latest", "@react-router/dev@latest", diff --git a/apps/ui-library/registry/default/blocks/password-based-auth-react/components/login-form.tsx b/apps/ui-library/registry/default/blocks/password-based-auth-react/components/login-form.tsx index 89575682af283..9b0c71b95cf18 100644 --- a/apps/ui-library/registry/default/blocks/password-based-auth-react/components/login-form.tsx +++ b/apps/ui-library/registry/default/blocks/password-based-auth-react/components/login-form.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { cn } from '@/lib/utils' +import { safeNextPath } from '@/registry/default/blocks/safe-next-path/lib/safe-next-path' import { createClient } from '@/registry/default/clients/react/lib/supabase/client' import { Button } from '@/registry/default/components/ui/button' import { @@ -32,7 +33,8 @@ export function LoginForm({ className, ...props }: React.ComponentPropsWithoutRe }) if (error) throw error // Update this route to redirect to an authenticated route. The user already has an active session. - location.href = '/protected' + const next = new URLSearchParams(window.location.search).get('next') + location.href = safeNextPath(next, '/protected') } catch (error: unknown) { setError(error instanceof Error ? error.message : 'An error occurred') } finally { diff --git a/apps/ui-library/registry/default/blocks/password-based-auth-react/registry-item.json b/apps/ui-library/registry/default/blocks/password-based-auth-react/registry-item.json index 459510f698ed6..73b2bb342a86f 100644 --- a/apps/ui-library/registry/default/blocks/password-based-auth-react/registry-item.json +++ b/apps/ui-library/registry/default/blocks/password-based-auth-react/registry-item.json @@ -3,7 +3,13 @@ "type": "registry:block", "title": "Password Based Auth flow for React and Supabase", "description": "Password Based Auth flow for React and Supabase", - "registryDependencies": ["button", "card", "input", "label"], + "registryDependencies": [ + "button", + "card", + "input", + "label", + "https://supabase.com/library/r/safe-next-path.json" + ], "files": [ { "path": "registry/default/blocks/password-based-auth-react/components/login-form.tsx", diff --git a/apps/ui-library/registry/default/blocks/password-based-auth-tanstack/components/login-form.tsx b/apps/ui-library/registry/default/blocks/password-based-auth-tanstack/components/login-form.tsx index cfb46c7b53eb5..00824c9fad808 100644 --- a/apps/ui-library/registry/default/blocks/password-based-auth-tanstack/components/login-form.tsx +++ b/apps/ui-library/registry/default/blocks/password-based-auth-tanstack/components/login-form.tsx @@ -1,7 +1,8 @@ -import { Link, useNavigate } from '@tanstack/react-router' +import { Link } from '@tanstack/react-router' import { useState } from 'react' import { cn } from '@/lib/utils' +import { safeNextPath } from '@/registry/default/blocks/safe-next-path/lib/safe-next-path' import { createClient } from '@/registry/default/clients/tanstack/lib/supabase/client' import { Button } from '@/registry/default/components/ui/button' import { @@ -19,7 +20,6 @@ export function LoginForm({ className, ...props }: React.ComponentPropsWithoutRe const [password, setPassword] = useState('') const [error, setError] = useState(null) const [isLoading, setIsLoading] = useState(false) - const navigate = useNavigate() const handleLogin = async (e: React.FormEvent) => { e.preventDefault() @@ -33,8 +33,11 @@ export function LoginForm({ className, ...props }: React.ComponentPropsWithoutRe password, }) if (error) throw error - // Update this route to redirect to an authenticated route. The user already has an active session. - await navigate({ to: '/protected' }) + // Follow the `next` query parameter if it is a same-origin relative path, e.g. when + // the OAuth consent screen sent the user here to sign in first. It may point outside + // the typed route tree, so it needs a full navigation. + const next = new URLSearchParams(window.location.search).get('next') + window.location.assign(safeNextPath(next, '/protected')) } catch (error: unknown) { setError(error instanceof Error ? error.message : 'An error occurred') } finally { diff --git a/apps/ui-library/registry/default/blocks/password-based-auth-tanstack/registry-item.json b/apps/ui-library/registry/default/blocks/password-based-auth-tanstack/registry-item.json index eb78910ed8a1f..43f4673a848f9 100644 --- a/apps/ui-library/registry/default/blocks/password-based-auth-tanstack/registry-item.json +++ b/apps/ui-library/registry/default/blocks/password-based-auth-tanstack/registry-item.json @@ -3,7 +3,13 @@ "type": "registry:block", "title": "Password Based Auth flow for tanstack and Supabase", "description": "Password Based Auth flow for tanstack and Supabase", - "registryDependencies": ["button", "card", "input", "label"], + "registryDependencies": [ + "button", + "card", + "input", + "label", + "https://supabase.com/library/r/safe-next-path.json" + ], "dependencies": ["@supabase/ssr@latest"], "files": [ { diff --git a/apps/ui-library/registry/default/blocks/safe-next-path/lib/safe-next-path.ts b/apps/ui-library/registry/default/blocks/safe-next-path/lib/safe-next-path.ts new file mode 100644 index 0000000000000..f727da65643b9 --- /dev/null +++ b/apps/ui-library/registry/default/blocks/safe-next-path/lib/safe-next-path.ts @@ -0,0 +1,12 @@ +export const safeNextPath = (path: unknown, fallback = '/', origin?: string) => { + if (typeof path !== 'string' || !path.startsWith('/')) return fallback + + const currentOrigin = origin ?? window.location.origin + + try { + const url = new URL(path, currentOrigin) + return url.origin === currentOrigin ? `${url.pathname}${url.search}${url.hash}` : fallback + } catch { + return fallback + } +} diff --git a/apps/ui-library/registry/default/blocks/safe-next-path/registry-item.json b/apps/ui-library/registry/default/blocks/safe-next-path/registry-item.json new file mode 100644 index 0000000000000..130e136101e7e --- /dev/null +++ b/apps/ui-library/registry/default/blocks/safe-next-path/registry-item.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "safe-next-path", + "type": "registry:lib", + "title": "Safe next path", + "description": "Validates same-origin paths before redirecting.", + "registryDependencies": [], + "dependencies": [], + "files": [ + { + "path": "registry/default/blocks/safe-next-path/lib/safe-next-path.ts", + "type": "registry:lib", + "target": "lib/safe-next-path.ts" + } + ] +} diff --git a/apps/ui-library/registry/default/blocks/social-auth-nextjs/components/login-form.tsx b/apps/ui-library/registry/default/blocks/social-auth-nextjs/components/login-form.tsx index 9b12133009ff5..fb8c80893c26b 100644 --- a/apps/ui-library/registry/default/blocks/social-auth-nextjs/components/login-form.tsx +++ b/apps/ui-library/registry/default/blocks/social-auth-nextjs/components/login-form.tsx @@ -3,6 +3,7 @@ import { useState } from 'react' import { cn } from '@/lib/utils' +import { safeNextPath } from '@/registry/default/blocks/safe-next-path/lib/safe-next-path' import { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client' import { Button } from '@/registry/default/components/ui/button' import { @@ -24,10 +25,11 @@ export function LoginForm({ className, ...props }: React.ComponentPropsWithoutRe setError(null) try { + const next = new URLSearchParams(window.location.search).get('next') const { error } = await supabase.auth.signInWithOAuth({ provider: 'github', options: { - redirectTo: `${window.location.origin}/auth/oauth?next=/protected`, + redirectTo: `${window.location.origin}/auth/oauth?next=${encodeURIComponent(safeNextPath(next, '/protected'))}`, }, }) diff --git a/apps/ui-library/registry/default/blocks/social-auth-nextjs/registry-item.json b/apps/ui-library/registry/default/blocks/social-auth-nextjs/registry-item.json index c37a59023ea5d..d3b16a1fdc2b5 100644 --- a/apps/ui-library/registry/default/blocks/social-auth-nextjs/registry-item.json +++ b/apps/ui-library/registry/default/blocks/social-auth-nextjs/registry-item.json @@ -3,7 +3,7 @@ "type": "registry:block", "title": "Social Auth flow for Nextjs and Supabase", "description": "Social Auth flow for Nextjs and Supabase", - "registryDependencies": ["button", "card"], + "registryDependencies": ["button", "card", "https://supabase.com/library/r/safe-next-path.json"], "dependencies": ["@supabase/ssr@latest", "@supabase/supabase-js@latest"], "files": [ { diff --git a/apps/ui-library/registry/default/blocks/social-auth-react-router/app/routes/login.tsx b/apps/ui-library/registry/default/blocks/social-auth-react-router/app/routes/login.tsx index 9ad26a0bb4ab6..70ff49bc99c98 100644 --- a/apps/ui-library/registry/default/blocks/social-auth-react-router/app/routes/login.tsx +++ b/apps/ui-library/registry/default/blocks/social-auth-react-router/app/routes/login.tsx @@ -1,5 +1,6 @@ -import { redirect, useFetcher, type ActionFunctionArgs } from 'react-router' +import { redirect, useFetcher, useSearchParams, type ActionFunctionArgs } from 'react-router' +import { safeNextPath } from '@/registry/default/blocks/safe-next-path/lib/safe-next-path' import { createClient } from '@/registry/default/clients/react-router/lib/supabase/server' import { Button } from '@/registry/default/components/ui/button' import { @@ -11,18 +12,21 @@ import { } from '@/registry/default/components/ui/card' export const action = async ({ request }: ActionFunctionArgs) => { - const { supabase } = createClient(request) + const { supabase, headers } = createClient(request) const origin = new URL(request.url).origin + const formData = await request.formData() + const next = safeNextPath(formData.get('next'), '/protected', origin) + const { data, error } = await supabase.auth.signInWithOAuth({ provider: 'github', options: { - redirectTo: `${origin}/auth/oauth?next=/protected`, + redirectTo: `${origin}/auth/oauth?next=${encodeURIComponent(next)}`, }, }) if (data.url) { - return redirect(data.url) + return redirect(data.url, { headers }) } if (error) { @@ -34,6 +38,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { export default function Login() { const fetcher = useFetcher() + const [searchParams] = useSearchParams() const error = fetcher.data?.error const loading = fetcher.state === 'submitting' @@ -49,6 +54,7 @@ export default function Login() { +
{error &&

{error}

}