diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a355b309cea..1d631f4a7dc 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2,6 +2,7 @@ /packages/shared-data/pricing.ts @supabase/billing /packages/shared-data/plans.ts @supabase/billing /packages/common/telemetry-constants.ts @supabase/growth-eng +/packages/dev-tools/ @supabase/growth-eng /packages/pg-meta @supabase/postgres @avallete /packages/ui-patterns @supabase/design @@ -22,4 +23,4 @@ /apps/studio/data/sql/queries/ @supabase/postgres @avallete -/packages/shared-data/compute-disk-limits.ts @supabase/infra \ No newline at end of file +/packages/shared-data/compute-disk-limits.ts @supabase/infra diff --git a/apps/cms/package.json b/apps/cms/package.json index cf248894b90..e6d0e43dc93 100644 --- a/apps/cms/package.json +++ b/apps/cms/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@payloadcms/admin-bar": "^3.52.0", - "@payloadcms/db-postgres": "^3.52.0", + "@payloadcms/db-postgres": "^3.73.0", "@payloadcms/live-preview-react": "^3.52.0", "@payloadcms/next": "3.52.0", "@payloadcms/payload-cloud": "3.60.0", diff --git a/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx b/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx index c56f34d0fe9..c88b9525ed5 100644 --- a/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx @@ -8,6 +8,7 @@ import { memo, useState } from 'react' import { useIsLoggedIn, useIsUserLoading, useUser } from 'common' import { isFeatureEnabled } from 'common/enabled-features' +import { DevToolbarTrigger } from 'dev-tools' import { Button, buttonVariants, cn } from 'ui' import { AuthenticatedDropdownMenu, CommandMenuTriggerInput } from 'ui-patterns' import { getCustomContent } from '../../../lib/custom-content/getCustomContent' @@ -43,6 +44,7 @@ const TopNavBar: FC = () => {
+ diff --git a/apps/docs/content/guides/getting-started/mcp.mdx b/apps/docs/content/guides/getting-started/mcp.mdx index 30addfc7514..3a6c8982a94 100644 --- a/apps/docs/content/guides/getting-started/mcp.mdx +++ b/apps/docs/content/guides/getting-started/mcp.mdx @@ -30,6 +30,86 @@ To verify the client has access to the MCP server tools, try asking it to query For curated, ready-to-use prompts that work well with IDEs and AI agents, see our [AI Prompts](/guides/getting-started/ai-prompts) collection. +## Available tools + +The Supabase MCP server provides tools organized into feature groups. All groups except Storage are enabled by default. You can enable or disable specific groups using the [configuration panel above](#step-2-configure-your-ai-tool). + +### Database + +- `list_tables` - List all tables in the database +- `list_extensions` - List available/installed Postgres extensions +- `list_migrations` - List database migrations +- `apply_migration` - Apply a database migration +- `execute_sql` - Execute SQL queries + +### Debugging + +- `get_logs` - Retrieve service logs (API, Postgres, Edge Functions, Auth, Storage, Realtime) +- `get_advisors` - Get security and performance advisors + +### Development + +- `get_project_url` - Get the API URL for a project +- `get_publishable_keys` - Get anon/public keys +- `generate_typescript_types` - Generate TypeScript types from schema + +### Edge Functions + +- `list_edge_functions` - List all Edge Functions +- `get_edge_function` - Get a specific Edge Function +- `deploy_edge_function` - Deploy an Edge Function + +### Account management + + + +Disabled when using project-scoped mode (`project_ref` parameter). + + + +- `list_projects` / `get_project` - List or get project details +- `create_project` / `pause_project` / `restore_project` - Manage projects +- `list_organizations` / `get_organization` - Organization management +- `get_cost` / `confirm_cost` - Cost information + +### Docs + +- `search_docs` - Search Supabase documentation + +### Branching (experimental) + + + +Requires a paid plan. + + + +- `create_branch` / `list_branches` / `delete_branch` - Branch management +- `merge_branch` / `reset_branch` / `rebase_branch` - Branch operations + +### Storage (disabled by default) + +- `list_storage_buckets` - List storage buckets +- `get_storage_config` / `update_storage_config` - Storage configuration + +## Configuration options + +The [configuration panel above](#step-2-configure-your-ai-tool) can set these options for you. If you prefer to configure manually, the following URL query parameters are available: + +| Parameter | Description | Example | +| ------------------- | ---------------------------------------------------- | ------------------------- | +| `read_only=true` | Execute all queries as a read-only Postgres user | `?read_only=true` | +| `project_ref=` | Scope to a specific project (disables account tools) | `?project_ref=abc123` | +| `features=` | Enable only specific tool groups (comma-separated) | `?features=database,docs` | + +Parameters can be combined: `https://mcp.supabase.com/mcp?project_ref=abc123&read_only=true` + + + +When using [Supabase CLI](/docs/guides/cli) for local development, the MCP server is available at `http://localhost:54321/mcp`. + + + ## Manual authentication By default the hosted Supabase MCP server uses [dynamic client registration](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#dynamic-client-registration) to authenticate with your Supabase org. This means that you don't need to manually create a personal access token (PAT) or OAuth app to use the server. @@ -108,10 +188,10 @@ We recommend the following best practices to mitigate security risks when using - **Don't connect to production**: Use the MCP server with a development project, not production. LLMs are great at helping design and test applications, so leverage them in a safe environment without exposing real data. Be sure that your development environment contains non-production data (or obfuscated data). - **Don't give to your customers**: The MCP server operates under the context of your developer permissions, so you should not give it to your customers or end users. Instead, use it internally as a developer tool to help you build and test your applications. -- **Read-only mode**: If you must connect to real data, set the server to [read-only](https://github.com/supabase-community/supabase-mcp#read-only-mode) mode, which executes all queries as a read-only Postgres user. -- **Project scoping**: Scope your MCP server to a [specific project](https://github.com/supabase-community/supabase-mcp#project-scoped-mode), limiting access to only that project's resources. This prevents LLMs from accessing data from other projects in your Supabase account. +- **Read-only mode**: If you must connect to real data, set the server to [read-only](#configuration-options) mode, which executes all queries as a read-only Postgres user. +- **Project scoping**: Scope your MCP server to a [specific project](#configuration-options), limiting access to only that project's resources. This prevents LLMs from accessing data from other projects in your Supabase account. - **Branching**: Use Supabase's [branching feature](/docs/guides/deployment/branching) to create a development branch for your database. This allows you to test changes in a safe environment before merging them to production. -- **Feature groups**: The server allows you to enable or disable specific [tool groups](https://github.com/supabase-community/supabase-mcp#feature-groups), so you can control which tools are available to the LLM. This helps reduce the attack surface and limits the actions that LLMs can perform to only those that you need. +- **Feature groups**: Restrict which [tool groups](#available-tools) are available using the `features` [configuration option](#configuration-options). This helps reduce the attack surface and limits the actions that LLMs can perform to only those that you need. ## On GitHub diff --git a/apps/docs/features/app.providers.tsx b/apps/docs/features/app.providers.tsx index 99e73b93c98..6d6caf07877 100644 --- a/apps/docs/features/app.providers.tsx +++ b/apps/docs/features/app.providers.tsx @@ -1,6 +1,7 @@ import type { PropsWithChildren } from 'react' import { FeatureFlagProvider, IS_PLATFORM, ThemeProvider } from 'common' +import { DevToolbar, DevToolbarProvider } from 'dev-tools' import { SonnerToaster, TooltipProvider } from 'ui' import SiteLayout from '~/layouts/SiteLayout' import { API_URL } from '~/lib/constants' @@ -19,22 +20,25 @@ function GlobalProviders({ children }: PropsWithChildren) { - - - - - -
- - {children} - - - -
-
- -
-
+ + + + + + +
+ + {children} + + + +
+
+ + +
+
+
diff --git a/apps/docs/package.json b/apps/docs/package.json index a3d5c5914c1..4e4ab01a5fe 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -63,6 +63,7 @@ "common": "workspace:*", "common-tags": "^1.8.2", "config": "workspace:*", + "dev-tools": "workspace:*", "eslint-config-supabase": "workspace:*", "framer-motion": "^11.0.3", "github-slugger": "^2.0.0", diff --git a/apps/studio/.github/eslint-rule-baselines.json b/apps/studio/.github/eslint-rule-baselines.json index aa7b0586482..d8ffa12f340 100644 --- a/apps/studio/.github/eslint-rule-baselines.json +++ b/apps/studio/.github/eslint-rule-baselines.json @@ -1,9 +1,9 @@ { "rules": { - "react-hooks/exhaustive-deps": 216, + "react-hooks/exhaustive-deps": 213, "import/no-anonymous-default-export": 57, - "@tanstack/query/exhaustive-deps": 14, - "@typescript-eslint/no-explicit-any": 1261 + "@tanstack/query/exhaustive-deps": 13, + "@typescript-eslint/no-explicit-any": 1231 }, "ruleFiles": { "react-hooks/exhaustive-deps": { @@ -38,12 +38,11 @@ "components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx": 1, "components/interfaces/Billing/Payment/PaymentConfirmation.tsx": 1, "components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement.tsx": 2, - "components/interfaces/BranchManagement/EditBranchModal.tsx": 2, "components/interfaces/Connect/DatabaseConnectionString.tsx": 2, "components/interfaces/Database/Backups/PITR/TimeInput.tsx": 4, "components/interfaces/Database/EnumeratedTypes/CreateEnumeratedTypeSidePanel.tsx": 1, - "components/interfaces/Database/Extensions/EnableExtensionModal.tsx": 1, "components/interfaces/Database/Indexes/Indexes.tsx": 2, + "components/interfaces/Database/Replication/ReplicationDiagram/index.tsx": 1, "components/interfaces/Database/Schemas/SchemaGraph.tsx": 1, "components/interfaces/DiskManagement/DiskManagementForm.tsx": 1, "components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx": 1, @@ -54,6 +53,8 @@ "components/interfaces/Integrations/Queues/SingleQueue/SendMessageModal.tsx": 1, "components/interfaces/Integrations/Wrappers/WrapperDynamicColumns.tsx": 1, "components/interfaces/Integrations/templates/StripeSyncEngine/InstallationOverview.tsx": 1, + "components/interfaces/Observability/DatabaseInfrastructureSection.tsx": 1, + "components/interfaces/Observability/useSlowQueriesCount.ts": 1, "components/interfaces/Organization/BillingSettings/BillingCustomerData/useBillingCustomerDataForm.ts": 1, "components/interfaces/Organization/BillingSettings/BillingEmail.tsx": 1, "components/interfaces/Organization/BillingSettings/CreditTopUp.tsx": 1, @@ -126,6 +127,7 @@ "components/ui/DatePicker/TimeSplitInput.tsx": 1, "components/ui/DateRangePicker.tsx": 1, "components/ui/Error.tsx": 1, + "components/ui/FileExplorerAndEditor/index.tsx": 1, "components/ui/FilterPopover.tsx": 1, "components/ui/ScrollGradient.tsx": 1, "data/__templates/resource-query.ts": 1, @@ -140,9 +142,9 @@ "hooks/branches/useEdgeFunctionsDiff.ts": 1, "hooks/misc/useReportDateRange.ts": 1, "hooks/misc/useSchemaQueryState.ts": 1, + "hooks/misc/useTrackExperimentExposure.ts": 1, "hooks/misc/useUpgradePrompt.tsx": 1, "hooks/ui/useCsvFileDrop.ts": 1, - "hooks/ui/useHotKey.ts": 2, "pages/forgot-password-mfa.tsx": 1, "pages/integrations/vercel/[slug]/deploy-button/new-project.tsx": 1, "pages/integrations/vercel/install.tsx": 1, @@ -230,7 +232,6 @@ "data/database/foreign-key-constraints-query.ts": 1, "data/database/schemas-query.ts": 1, "data/entity-types/entity-types-infinite-query.ts": 1, - "data/table-editor/table-editor-query.ts": 1, "data/table-rows/table-rows-query.ts": 1, "data/tables/tables-query.ts": 2, "hooks/analytics/useDbQuery.tsx": 1, @@ -248,7 +249,6 @@ "components/grid/components/formatter/ReferenceRecordPeek.tsx": 4, "components/grid/components/grid/AddColumn.tsx": 2, "components/grid/components/grid/Grid.utils.tsx": 2, - "components/grid/components/grid/GridError.tsx": 4, "components/grid/components/grid/SelectColumn.tsx": 3, "components/grid/components/header/Header.utils.ts": 1, "components/grid/components/header/sort/SortRow.tsx": 1, @@ -306,12 +306,8 @@ "components/interfaces/Database/EnumeratedTypes/DeleteEnumeratedTypeModal.tsx": 1, "components/interfaces/Database/EnumeratedTypes/EditEnumeratedTypeSidePanel.tsx": 2, "components/interfaces/Database/EnumeratedTypes/EnumeratedTypeValueRow.tsx": 1, - "components/interfaces/Database/Extensions/EnableExtensionModal.tsx": 4, "components/interfaces/Database/Functions/CreateFunction/FunctionEditor.tsx": 1, "components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx": 3, - "components/interfaces/Database/Hooks/EditHookPanel.tsx": 9, - "components/interfaces/Database/Hooks/FormContents.tsx": 5, - "components/interfaces/Database/Hooks/HTTPRequestFields.tsx": 6, "components/interfaces/Database/Hooks/HooksList/HooksList.tsx": 2, "components/interfaces/Database/Indexes/Indexes.tsx": 1, "components/interfaces/Database/Migrations/Migrations.tsx": 2, @@ -327,7 +323,6 @@ "components/interfaces/Database/Roles/RolesList.tsx": 1, "components/interfaces/Database/Schemas/SchemaGraph.tsx": 1, "components/interfaces/Database/Tables/ColumnList.tsx": 2, - "components/interfaces/DiskManagement/DiskManagement.utils.ts": 3, "components/interfaces/Docs/Description.tsx": 2, "components/interfaces/Docs/GeneratingTypes.tsx": 1, "components/interfaces/Docs/Param.tsx": 1, @@ -379,7 +374,7 @@ "components/interfaces/Organization/GeneralSettings/DeleteOrganizationButton.tsx": 3, "components/interfaces/Organization/IntegrationSettings/IntegrationSettings.tsx": 1, "components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx": 1, - "components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx": 1, + "components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx": 2, "components/interfaces/Organization/OAuthApps/PublishAppSidePanel/index.tsx": 9, "components/interfaces/Organization/ProjectClaim/confirm.tsx": 1, "components/interfaces/Organization/TeamSettings/MemberRow.tsx": 2, @@ -617,7 +612,6 @@ "data/analytics/functions-resource-usage-query.ts": 1, "data/analytics/infra-monitoring-query.ts": 1, "data/analytics/project-daily-stats-queries.ts": 1, - "data/api-settings/create-and-expose-api-schema-mutation.ts": 1, "data/auth/auth-overview-query.ts": 1, "data/auth/session-access-token-query.ts": 1, "data/config/project-disk-resize-mutation.ts": 1, @@ -692,6 +686,7 @@ "hooks/analytics/useSingleLog.tsx": 1, "hooks/analytics/useTimeseriesUnixToIso.ts": 1, "hooks/branches/useBranchMergeDiff.ts": 3, + "hooks/misc/useTrackExperimentExposure.ts": 1, "hooks/misc/withAuth.tsx": 1, "hooks/ui/useClickedOutside.ts": 2, "hooks/ui/useFlag.ts": 1, diff --git a/apps/studio/components/grid/SupabaseGrid.tsx b/apps/studio/components/grid/SupabaseGrid.tsx index 8a0ae8429f5..13f492af43c 100644 --- a/apps/studio/components/grid/SupabaseGrid.tsx +++ b/apps/studio/components/grid/SupabaseGrid.tsx @@ -1,5 +1,5 @@ import { keepPreviousData, useQueryClient } from '@tanstack/react-query' -import { useFlag, useParams } from 'common' +import { useParams } from 'common' import { isMsSqlForeignTable } from 'data/table-editor/table-editor-types' import { useTableRowsQuery } from 'data/table-rows/table-rows-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' diff --git a/apps/studio/components/grid/components/footer/operations/SaveQueueActionBar.tsx b/apps/studio/components/grid/components/footer/operations/SaveQueueActionBar.tsx index c554f2065dc..8975417d268 100644 --- a/apps/studio/components/grid/components/footer/operations/SaveQueueActionBar.tsx +++ b/apps/studio/components/grid/components/footer/operations/SaveQueueActionBar.tsx @@ -2,11 +2,10 @@ import { useOperationQueueActions } from 'components/grid/hooks/useOperationQueu import { useOperationQueueShortcuts } from 'components/grid/hooks/useOperationQueueShortcuts' import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { AnimatePresence, motion } from 'framer-motion' -import { Eye } from 'lucide-react' import { createPortal } from 'react-dom' import { useTableEditorStateSnapshot } from 'state/table-editor' import { Button } from 'ui' - +import { useEffect, useRef, useState } from 'react' import { getModKeyLabel } from '@/lib/helpers' export const SaveQueueActionBar = () => { @@ -14,6 +13,7 @@ export const SaveQueueActionBar = () => { const snap = useTableEditorStateSnapshot() const isQueueOperationsEnabled = useIsQueueOperationsEnabled() const { handleSave } = useOperationQueueActions() + const [leftPosition, setLeftPosition] = useState('50%') useOperationQueueShortcuts() @@ -24,42 +24,80 @@ export const SaveQueueActionBar = () => { const isVisible = isQueueOperationsEnabled && snap.hasPendingOperations && !isOperationQueuePanelOpen + // Center position relative to grid container (viewport alignment) + useEffect(() => { + if (!isVisible) return + + const gridContainer = document.querySelector('.sb-grid') + const updatePosition = () => { + if (!gridContainer) { + setLeftPosition('50%') + return + } + + const gridRect = gridContainer.getBoundingClientRect() + const gridCenter = gridRect.left + gridRect.width / 2 + setLeftPosition(`${gridCenter}px`) + } + + updatePosition() + + if (!gridContainer) return + + const resizeObserver = new ResizeObserver(updatePosition) + resizeObserver.observe(gridContainer) + + window.addEventListener('resize', updatePosition) + window.addEventListener('scroll', updatePosition, true) + + return () => { + resizeObserver.disconnect() + window.removeEventListener('resize', updatePosition) + window.removeEventListener('scroll', updatePosition, true) + } + }, [isVisible]) + const content = ( {isVisible && ( - -
- - {operationCount} pending change{operationCount !== 1 ? 's' : ''} - -
- - + +
+ + {operationCount} pending change{operationCount !== 1 ? 's' : ''} + +
+ + +
-
- + +
)}
) diff --git a/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx b/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx index 63c9b9722c5..bfa8f2cab28 100644 --- a/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx +++ b/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx @@ -1,16 +1,16 @@ import type { PostgresTable } from '@supabase/postgres-meta' -import { ArrowRight } from 'lucide-react' -import type { PropsWithChildren } from 'react' -import type { RenderCellProps } from 'react-data-grid' - import { convertByteaToHex } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useTableEditorQuery } from 'data/table-editor/table-editor-query' import { isTableLike } from 'data/table-editor/table-editor-types' import { useTableQuery } from 'data/tables/table-retrieve-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { ArrowRight } from 'lucide-react' +import type { PropsWithChildren } from 'react' +import type { RenderCellProps } from 'react-data-grid' import { Popover_Shadcn_, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_ } from 'ui' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' + import type { SupaRow } from '../../types' import { NullValue } from '../common/NullValue' import { ReferenceRecordPeek } from './ReferenceRecordPeek' @@ -83,7 +83,14 @@ export const ForeignKeyFormatter = (props: Props) => { tooltip={{ content: { side: 'bottom', text: 'View referencing record' } }} /> - + { + e.preventDefault() + e.stopPropagation() + }} + > x.name) + const primaryKeys = useMemo(() => table.primary_keys.map((x) => x.name), [table.primary_keys]) - const columns = (table?.columns ?? []).map((column) => { - const columnDefaultWidth = getColumnDefaultWidth({ - dataType: column.data_type, - format: column.format, - } as any) - const columnWidthBasedOnName = - (column.name.length + column.format.length) * ESTIMATED_CHARACTER_PIXEL_WIDTH - const columnWidth = - columnDefaultWidth < columnWidthBasedOnName ? columnWidthBasedOnName : columnDefaultWidth - const isPrimaryKey = primaryKeys.includes(column.name) + const columns = useMemo(() => { + return (table?.columns ?? []).map((column) => { + const columnDefaultWidth = getColumnDefaultWidth({ + dataType: column.data_type, + format: column.format, + } as any) + const columnWidthBasedOnName = + (column.name.length + column.format.length) * ESTIMATED_CHARACTER_PIXEL_WIDTH + const columnWidth = + columnDefaultWidth < columnWidthBasedOnName ? columnWidthBasedOnName : columnDefaultWidth + const isPrimaryKey = primaryKeys.includes(column.name) - const res: Column = { - key: column.name, - name: column.name, - resizable: false, - draggable: false, - sortable: false, - width: columnWidth, - minWidth: COLUMN_MIN_WIDTH, - headerCellClass: 'outline-none !shadow-none', - renderHeaderCell: () => ( -
- {isPrimaryKey && ( - - - - - Primary key - - )} - {column.name} - {column.format} -
- ), - renderCell: ({ column: col, row }) => { - const value = row[col.name as any] - const formattedValue = column.format === 'bytea' ? convertByteaToHex(value) : value - return ( -
= { + key: column.name, + name: column.name, + resizable: false, + draggable: false, + sortable: false, + width: columnWidth, + minWidth: COLUMN_MIN_WIDTH, + headerCellClass: 'outline-none !shadow-none', + renderHeaderCell: () => ( +
+ {isPrimaryKey && ( + + + + + Primary key + )} - > - {formattedValue === null ? 'NULL' : formattedValue} + {column.name} + {column.format}
- ) - }, - } - return res - }) + ), + renderCell: isBinaryColumn(column.data_type) + ? BinaryFormatter + : isJsonColumn(column.data_type) && !isArrayColumn(column.data_type) + ? JsonFormatter + : DefaultFormatter, + } + return res + }) + }, [table?.columns, primaryKeys]) return ( <> diff --git a/apps/studio/components/grid/components/grid/Grid.tsx b/apps/studio/components/grid/components/grid/Grid.tsx index 12fe5bc307e..54a9febe7cd 100644 --- a/apps/studio/components/grid/components/grid/Grid.tsx +++ b/apps/studio/components/grid/components/grid/Grid.tsx @@ -237,7 +237,7 @@ export const Grid = memo( {(rows ?? []).length === 0 && (
- {isLoading && !isDisabled && } + {isLoading && !isDisabled && ( + + )} {isError && } {isSuccess && ( <> {page > 1 ? ( -
+

This page does not have any data

) : (filters ?? []).length === 0 ? ( -
+

{isDraggedOver ? ( isValidFileDraggedOver ? ( @@ -312,7 +314,7 @@ export const Grid = memo( )}

) : ( -
+

The filters applied have returned no results from this table

diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.constants.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.constants.ts new file mode 100644 index 00000000000..75e1accca3f --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.constants.ts @@ -0,0 +1,75 @@ +import { components } from 'api-types' +import { permissions } from '@supabase/shared-types' + +export type ScopedAccessTokenPermission = + components['schemas']['CreateScopedAccessTokenBody']['permissions'][number] + +export const NON_EXPIRING_TOKEN_VALUE = 'never' +export const CUSTOM_EXPIRY_VALUE = 'custom' + +export const EXPIRES_AT_OPTIONS = { + hour: { value: 'hour', label: '1 hour' }, + day: { value: 'day', label: '1 day' }, + week: { value: 'week', label: '7 days' }, + month: { value: 'month', label: '30 days' }, + never: { value: NON_EXPIRING_TOKEN_VALUE, label: 'Never' }, + custom: { value: CUSTOM_EXPIRY_VALUE, label: 'Custom' }, +} as const + +const FGA = permissions.FgaPermissions + +const getAction = (key: string): string => { + if (key.endsWith('_READ')) return 'read' + if (key.endsWith('_WRITE')) return 'write' + if (key.endsWith('_CREATE')) return 'create' + if (key.endsWith('_DELETE')) return 'delete' + return 'read' +} + +const getResource = (key: string): string => { + return key.replace(/_(READ|WRITE|CREATE|DELETE)$/, '').toLowerCase() +} + +const buildPermissionList = () => { + const list: Array<{ + scope: string + resource: string + action: string + id: string + title: string + }> = [] + + for (const [scope, scopePerms] of Object.entries(FGA)) { + for (const [key, perm] of Object.entries(scopePerms)) { + list.push({ + scope: scope.toLowerCase(), + resource: getResource(key), + action: getAction(key), + id: perm.id, + title: perm.title, + }) + } + } + + return list +} + +export const PERMISSION_LIST = buildPermissionList() + +export const ACCESS_TOKEN_RESOURCES = (() => { + const resourceMap = new Map() + + for (const p of PERMISSION_LIST) { + const key = `${p.scope}:${p.resource}` + if (!resourceMap.has(key)) { + const cleanTitle = p.title.replace(/^(Read|Manage|Create|Delete)\s+/i, '') + resourceMap.set(key, { resource: key, title: cleanTitle, actions: [] }) + } + const entry = resourceMap.get(key)! + if (!entry.actions.includes(p.action)) { + entry.actions.push(p.action) + } + } + + return Array.from(resourceMap.values()) +})() diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.schemas.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.schemas.ts new file mode 100644 index 00000000000..da0604f0633 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.schemas.ts @@ -0,0 +1,23 @@ +import { z } from 'zod' + +export const PermissionRowSchema = z.object({ + resource: z.string().min(1, 'Please select a resource'), + actions: z.array(z.string()).min(1, 'Please select at least one action'), +}) + +export const TokenSchema = z + .object({ + tokenName: z.string().min(1, 'Please enter a name for the token'), + expiresAt: z.preprocess((val) => (val === 'never' ? undefined : val), z.string().optional()), + customExpiryDate: z.string().optional(), + resourceAccess: z.enum(['all-orgs', 'selected-orgs', 'selected-projects']), + selectedOrganizations: z.array(z.string()).optional(), + selectedProjects: z.array(z.string()).optional(), + permissionRows: z.array(PermissionRowSchema).min(1, 'Please configure at least one permission'), + }) + .refine((data) => !(data.expiresAt === 'custom' && !data.customExpiryDate), { + message: 'Please select a custom expiry date', + path: ['expiresAt'], + }) + +export type TokenFormValues = z.infer diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.types.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.types.ts new file mode 100644 index 00000000000..403fe2c96eb --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.types.ts @@ -0,0 +1,25 @@ +export const ACCESS_TOKEN_SORT_VALUES = [ + 'created_at:asc', + 'created_at:desc', + 'last_used_at:asc', + 'last_used_at:desc', + 'expires_at:asc', + 'expires_at:desc', +] as const + +export type AccessTokenSort = (typeof ACCESS_TOKEN_SORT_VALUES)[number] +export type AccessTokenSortColumn = AccessTokenSort extends `${infer Column}:${string}` + ? Column + : never +export type AccessTokenSortOrder = AccessTokenSort extends `${string}:${infer Order}` + ? Order + : never + +export interface BaseToken { + id: string | number + name: string + token_alias: string + created_at: string + last_used_at?: string | null + expires_at?: string | null +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.utils.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.utils.ts new file mode 100644 index 00000000000..20b573290ca --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.utils.ts @@ -0,0 +1,148 @@ +import dayjs from 'dayjs' +import { + AccessTokenSort, + AccessTokenSortColumn, + AccessTokenSortOrder, + BaseToken, +} from './AccessToken.types' +import { PERMISSION_LIST, ScopedAccessTokenPermission } from './AccessToken.constants' + +export const handleSortChange = ( + currentSort: AccessTokenSort, + column: AccessTokenSortColumn, + setSort: (sort: AccessTokenSort) => void +) => { + const [currentCol, currentOrder] = currentSort.split(':') as [ + AccessTokenSortColumn, + AccessTokenSortOrder, + ] + if (currentCol === column) { + if (currentOrder === 'asc') { + setSort(`${column}:desc` as AccessTokenSort) + } else { + setSort('created_at:desc') + } + } else { + setSort(`${column}:asc` as AccessTokenSort) + } +} + +export const filterAndSortTokens = ( + tokens: T[] | undefined, + searchString: string, + sort: AccessTokenSort +): T[] | undefined => { + const filtered = !searchString + ? tokens + : tokens?.filter((token) => token.name.toLowerCase().includes(searchString.toLowerCase())) + + if (!filtered) return filtered + + const [sortCol, sortOrder] = sort.split(':') as [AccessTokenSortColumn, AccessTokenSortOrder] + const orderMultiplier = sortOrder === 'asc' ? 1 : -1 + + return [...filtered].sort((a, b) => { + if (sortCol === 'created_at') { + return (new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) * orderMultiplier + } + if (sortCol === 'last_used_at') { + if (!a.last_used_at && !b.last_used_at) return 0 + if (!a.last_used_at) return 1 + if (!b.last_used_at) return -1 + return ( + (new Date(a.last_used_at).getTime() - new Date(b.last_used_at).getTime()) * orderMultiplier + ) + } + if (sortCol === 'expires_at') { + if (!a.expires_at && !b.expires_at) return 0 + if (!a.expires_at) return 1 + if (!b.expires_at) return -1 + return (new Date(a.expires_at).getTime() - new Date(b.expires_at).getTime()) * orderMultiplier + } + return 0 + }) +} + +export const mapPermissionToFGA = ( + resourceKey: string, + action: string +): ScopedAccessTokenPermission[] => { + const [scope, resource] = resourceKey.split(':') + const match = PERMISSION_LIST.find( + (p) => p.scope === scope && p.resource === resource && p.action === action + ) + return match ? [match.id as ScopedAccessTokenPermission] : [] +} + +// [kemal]: Not sure how efficient this will be, but it should get permissions from shared types and transform them whenever @supabase/shared-types updates. +export const getResourcePermissions = ( + resourceKey: string +): Record => { + const [scope, resource] = resourceKey.split(':') + const result: Record = { 'no access': [] } + + PERMISSION_LIST.filter((p) => p.scope === scope && p.resource === resource).forEach((p) => { + result[p.action] = [p.id as ScopedAccessTokenPermission] + }) + + if (result['read'] && result['write']) { + result['read-write'] = [...result['read'], ...result['write']] + } + + return result +} + +export const getRealAccess = (resource: string, tokenPermissions: string[]) => { + const resourcePermissions = getResourcePermissions(resource) + const actionTypes = ['read', 'write', 'create', 'delete'] as const + const grantedActions = actionTypes.filter((action) => + resourcePermissions[action]?.some((p) => tokenPermissions.includes(p)) + ) + + if (grantedActions.length === 0) { + return 'no access' + } + + if (grantedActions.length === 1) { + return grantedActions[0] + } + + if ( + grantedActions.length === 2 && + grantedActions[0] === 'read' && + grantedActions[1] === 'write' + ) { + return 'read-write' + } + + return grantedActions.join('-') +} + +export const formatAccessText = (action: string): string => { + switch (action) { + case 'no access': + return 'No access' + default: + return action + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join('-') + } +} + +export const getExpirationDate = (key: string): string | undefined => { + switch (key) { + case 'hour': + return dayjs().add(1, 'hours').toISOString() + case 'day': + return dayjs().add(1, 'day').toISOString() + case 'week': + return dayjs().add(7, 'days').toISOString() + case 'month': + return dayjs().add(30, 'days').toISOString() + case 'never': + return undefined + default: + return undefined + } +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenList.tsx b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenList.tsx index 1a0e8f30dbf..d6433c3ff86 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenList.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenList.tsx @@ -1,100 +1,29 @@ import AlertError from 'components/ui/AlertError' import { useAccessTokenDeleteMutation } from 'data/access-tokens/access-tokens-delete-mutation' import { AccessToken, useAccessTokensQuery } from 'data/access-tokens/access-tokens-query' -import dayjs from 'dayjs' import { MoreVertical, Trash } from 'lucide-react' import { useMemo, useState } from 'react' import { toast } from 'sonner' import { Button, - Card, - CardContent, - cn, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, - Skeleton, } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableHeadSort, - TableRow, -} from 'ui/src/components/shadcn/ui/table' -import { TimestampInfo } from 'ui-patterns/TimestampInfo' +import { TableCell, TableRow } from 'ui/src/components/shadcn/ui/table' import { parseAsStringLiteral, useQueryState } from 'nuqs' -const ACCESS_TOKEN_SORT_VALUES = [ - 'created_at:asc', - 'created_at:desc', - 'last_used_at:asc', - 'last_used_at:desc', - 'expires_at:asc', - 'expires_at:desc', -] as const - -type AccessTokenSort = (typeof ACCESS_TOKEN_SORT_VALUES)[number] -type AccessTokenSortColumn = AccessTokenSort extends `${infer Column}:${string}` ? Column : unknown -type AccessTokenSortOrder = AccessTokenSort extends `${string}:${infer Order}` ? Order : unknown - -const RowLoading = () => ( - - - - - - - - - - - - - - - - - -) - -const tableHeaderClass = 'text-left font-mono uppercase text-xs text-foreground-lighter py-2' - -interface TableContainerProps { - children: React.ReactNode - sort: AccessTokenSort - onSortChange: (column: AccessTokenSortColumn) => void -} - -const TableContainer = ({ children, sort, onSortChange }: TableContainerProps) => ( - - - - - - Token - - - Last used - - - - - Expires - - - - - - {children} -
-
-
-) +import { + ACCESS_TOKEN_SORT_VALUES, + AccessTokenSort, + AccessTokenSortColumn, +} from './AccessToken.types' +import { handleSortChange, filterAndSortTokens } from './AccessToken.utils' +import { TableContainer } from './AccessTokenTable/TableContainer' +import { RowLoading } from './AccessTokenTable/RowLoading' +import { TokenNameCell, LastUsedCell, ExpiresCell } from './AccessTokenTable/TokenCells' export interface AccessTokenListProps { searchString?: string @@ -122,70 +51,20 @@ export const AccessTokenList = ({ searchString = '', onDeleteSuccess }: AccessTo }, }) - const handleSortChange = (column: AccessTokenSortColumn) => { - const [currentCol, currentOrder] = sort.split(':') as [ - AccessTokenSortColumn, - AccessTokenSortOrder, - ] - if (currentCol === column) { - if (currentOrder === 'asc') { - setSort(`${column}:desc` as AccessTokenSort) - } else { - setSort('created_at:desc') - } - } else { - setSort(`${column}:asc` as AccessTokenSort) - } + const onSortChange = (column: AccessTokenSortColumn) => { + handleSortChange(sort, column, setSort) } - const onDeleteToken = async (tokenId: number) => { - deleteToken({ id: tokenId }) - } - - const filteredTokens = useMemo(() => { - const filtered = !searchString - ? tokens - : tokens?.filter((token) => { - return token.name.toLowerCase().includes(searchString.toLowerCase()) - }) - - if (!filtered) return filtered - - const [sortCol, sortOrder] = sort.split(':') as [AccessTokenSortColumn, AccessTokenSortOrder] - const orderMultiplier = sortOrder === 'asc' ? 1 : -1 - - return [...filtered].sort((a, b) => { - if (sortCol === 'created_at') { - return ( - (new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) * orderMultiplier - ) - } - if (sortCol === 'last_used_at') { - if (!a.last_used_at && !b.last_used_at) return 0 - if (!a.last_used_at) return 1 - if (!b.last_used_at) return -1 - return ( - (new Date(a.last_used_at).getTime() - new Date(b.last_used_at).getTime()) * - orderMultiplier - ) - } - if (sortCol === 'expires_at') { - if (!a.expires_at && !b.expires_at) return 0 - if (!a.expires_at) return 1 - if (!b.expires_at) return -1 - return ( - (new Date(a.expires_at).getTime() - new Date(b.expires_at).getTime()) * orderMultiplier - ) - } - return 0 - }) - }, [tokens, searchString, sort]) + const filteredTokens = useMemo( + () => filterAndSortTokens(tokens, searchString, sort), + [tokens, searchString, sort] + ) const empty = filteredTokens?.length === 0 && !isLoading if (isError) { return ( - + + @@ -210,7 +89,7 @@ export const AccessTokenList = ({ searchString = '', onDeleteSuccess }: AccessTo if (empty) { return ( - +

No access tokens found

@@ -225,93 +104,51 @@ export const AccessTokenList = ({ searchString = '', onDeleteSuccess }: AccessTo return ( <> - - {filteredTokens?.map((x) => { - return ( - - -

- {x.name} -

-

- {x.token_alias} -

-
- - {x.last_used_at ? ( - - ) : ( -

Never used

- )} -
- - {x.expires_at ? ( - dayjs(x.expires_at).isBefore(dayjs()) ? ( - + {filteredTokens?.map((x) => ( + + + + + +
+ + +
-
-
- ) - })} + + + { + setToken(x) + setIsOpen(true) + }} + > + +

Delete token

+
+
+ +
+ + + ))} setIsOpen(false)} onConfirm={() => { - if (token) onDeleteToken(token.id) + if (token) deleteToken({ id: token.id }) }} >

diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/AccessTokenNewBanner.tsx b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/AccessTokenNewBanner.tsx new file mode 100644 index 00000000000..bb055d856e2 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/AccessTokenNewBanner.tsx @@ -0,0 +1,67 @@ +import { X } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from 'ui' +import { Admonition } from 'ui-patterns' +import { Input } from 'ui-patterns/DataInputs/Input' +import { TokenPermissionsSection } from './TokenPermissionSection' +import { useGroupedPermissions } from '../hooks/useGroupedPermissions' + +interface AccessTokenNewBannerProps { + token: T + onClose: () => void + getTokenValue: (token: T) => string + getTokenPermissions?: (token: T) => string[] | undefined + title?: string + description?: string +} + +export const AccessTokenNewBanner = ({ + token, + onClose, + getTokenValue, + getTokenPermissions, + title = 'Successfully generated a new token!', + description = 'Copy this access token and store it in a secure place. You will not be able to see it again.', +}: AccessTokenNewBannerProps) => { + const tokenPermissions = getTokenPermissions?.(token) + const { groupedPermissions, totalCount } = useGroupedPermissions(tokenPermissions) + + return ( + } + className="w-7 h-7 absolute top-2.5 right-2.5" + onClick={onClose} + /> + } + > +

+

{description}

+
+ {}} + onCopy={() => toast.success('Token copied to clipboard')} + /> +
+ + {tokenPermissions && tokenPermissions.length > 0 && ( + + )} +
+ + ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/PermissionList.tsx b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/PermissionList.tsx new file mode 100644 index 00000000000..6353f874dcb --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/PermissionList.tsx @@ -0,0 +1,28 @@ +interface PermissionsListProps { + groupedPermissions: Record +} + +export const PermissionsList = ({ groupedPermissions }: PermissionsListProps) => { + return ( +
+ {Object.entries(groupedPermissions).map(([accessLevel, resources]) => ( +
+ + {accessLevel}: + +
+ {resources.map((resource, index) => ( + + {resource} + {index < resources.length - 1 ? ',' : '.'} + + ))} +
+
+ ))} +
+ ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/TokenPermissionSection.tsx b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/TokenPermissionSection.tsx new file mode 100644 index 00000000000..c5b99c5d0c7 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/TokenPermissionSection.tsx @@ -0,0 +1,55 @@ +import { ChevronDown, ChevronRight } from 'lucide-react' +import { useState } from 'react' +import { Button } from 'ui' +import { + Collapsible_Shadcn_ as Collapsible, + CollapsibleContent_Shadcn_ as CollapsibleContent, + CollapsibleTrigger_Shadcn_ as CollapsibleTrigger, +} from 'ui' +import { PermissionsList } from './PermissionList' + +const PERMISSIONS_COLLAPSE_THRESHOLD = 5 + +interface TokenPermissionsSectionProps { + groupedPermissions: Record + totalCount: number +} + +export const TokenPermissionsSection = ({ + groupedPermissions, + totalCount, +}: TokenPermissionsSectionProps) => { + const [permissionsOpen, setPermissionsOpen] = useState(false) + const shouldCollapse = totalCount > PERMISSIONS_COLLAPSE_THRESHOLD + + if (totalCount === 0) return null + + return ( +
+ {shouldCollapse ? ( + + + + + + + + + ) : ( + <> +

Permissions assigned to this token:

+ + + )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenTable/RowLoading.tsx b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenTable/RowLoading.tsx new file mode 100644 index 00000000000..53f799da494 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenTable/RowLoading.tsx @@ -0,0 +1,22 @@ +import { Skeleton } from 'ui' +import { TableCell, TableRow } from 'ui/src/components/shadcn/ui/table' + +export const RowLoading = () => ( + + + + + + + + + + + + + + + + + +) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenTable/TableContainer.tsx b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenTable/TableContainer.tsx new file mode 100644 index 00000000000..b36702b25f9 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenTable/TableContainer.tsx @@ -0,0 +1,44 @@ +import { Card, CardContent, cn } from 'ui' +import { + Table, + TableBody, + TableHead, + TableHeader, + TableHeadSort, + TableRow, +} from 'ui/src/components/shadcn/ui/table' +import { AccessTokenSort, AccessTokenSortColumn } from '../AccessToken.types' + +const tableHeaderClass = 'text-left font-mono uppercase text-xs text-foreground-lighter py-2' + +interface TableContainerProps { + children: React.ReactNode + sort: AccessTokenSort + onSortChange: (column: AccessTokenSortColumn) => void +} + +export const TableContainer = ({ children, sort, onSortChange }: TableContainerProps) => ( + + + + + + Token + + + Last used + + + + + Expires + + + + + + {children} +
+
+
+) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenTable/TokenCells.tsx b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenTable/TokenCells.tsx new file mode 100644 index 00000000000..b1cf4f95c1a --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenTable/TokenCells.tsx @@ -0,0 +1,62 @@ +import dayjs from 'dayjs' +import { TableCell } from 'ui/src/components/shadcn/ui/table' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' + +interface TokenNameCellProps { + name: string + tokenAlias: string +} + +export const TokenNameCell = ({ name, tokenAlias }: TokenNameCellProps) => ( + +

+ {name} +

+

+ {tokenAlias} +

+
+) + +interface LastUsedCellProps { + lastUsedAt: string | null | undefined +} + +export const LastUsedCell = ({ lastUsedAt }: LastUsedCellProps) => ( + + {lastUsedAt ? ( + + ) : ( +

Never used

+ )} +
+) + +interface ExpiresCellProps { + expiresAt: string | null | undefined +} + +export const ExpiresCell = ({ expiresAt }: ExpiresCellProps) => ( + + {expiresAt ? ( + dayjs(expiresAt).isBefore(dayjs()) ? ( + + ) : ( + + ) + ) : ( +

Never

+ )} +
+) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokens.constants.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokens.constants.ts deleted file mode 100644 index 745c2f89ca7..00000000000 --- a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokens.constants.ts +++ /dev/null @@ -1,31 +0,0 @@ -import dayjs from 'dayjs' - -export const NON_EXPIRING_TOKEN_VALUE = 'never' -export const CUSTOM_EXPIRY_VALUE = 'custom' - -export const ExpiresAtOptions: Record = { - hour: { - value: dayjs().add(1, 'hour').toISOString(), - label: '1 hour', - }, - day: { - value: dayjs().add(1, 'days').toISOString(), - label: '1 day', - }, - week: { - value: dayjs().add(7, 'days').toISOString(), - label: '7 days', - }, - month: { - value: dayjs().add(30, 'days').toISOString(), - label: '30 days', - }, - never: { - value: NON_EXPIRING_TOKEN_VALUE, - label: 'Never', - }, - custom: { - value: CUSTOM_EXPIRY_VALUE, - label: 'Custom', - }, -} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/NewAccessTokenButton.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Classic/NewTokenButton.tsx similarity index 84% rename from apps/studio/components/interfaces/Account/AccessTokens/NewAccessTokenButton.tsx rename to apps/studio/components/interfaces/Account/AccessTokens/Classic/NewTokenButton.tsx index a17eaa5cce8..c89aa724d12 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/NewAccessTokenButton.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Classic/NewTokenButton.tsx @@ -8,13 +8,14 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from 'ui' -import { NewAccessTokenDialog } from './NewAccessTokenDialog' +import { NewTokenDialog } from './NewTokenDialog' +import { type NewAccessToken } from 'data/access-tokens/access-tokens-create-mutation' export interface NewAccessTokenButtonProps { - onCreateToken: (token: any) => void + onCreateToken: (token: NewAccessToken) => void } -export const NewAccessTokenButton = ({ onCreateToken }: NewAccessTokenButtonProps) => { +export const NewTokenButton = ({ onCreateToken }: NewAccessTokenButtonProps) => { const [visible, setVisible] = useState(false) const [tokenScope, setTokenScope] = useState<'V0' | undefined>(undefined) @@ -55,7 +56,7 @@ export const NewAccessTokenButton = ({ onCreateToken }: NewAccessTokenButtonProp
- void - onCreateToken: (token: any) => void + onCreateToken: (token: NewAccessToken) => void } -export const NewAccessTokenDialog = ({ +export const NewTokenDialog = ({ open, tokenScope, onOpenChange, @@ -64,18 +68,18 @@ export const NewAccessTokenDialog = ({ const form = useForm>({ resolver: zodResolver(TokenSchema), - defaultValues: { tokenName: '', expiresAt: ExpiresAtOptions['month'].value }, + defaultValues: { tokenName: '', expiresAt: EXPIRES_AT_OPTIONS['month'].value }, mode: 'onChange', }) const { mutate: createAccessToken, isPending } = useAccessTokenCreateMutation() const onSubmit: SubmitHandler> = async (values) => { - // Use custom date if custom option is selected - let expiresAt = values.expiresAt + let expiresAt: string | undefined if (isCustomExpiry && customExpiryDate) { - // Use the date from the TokensDatePicker expiresAt = customExpiryDate.date + } else { + expiresAt = getExpirationDate(values.expiresAt || '') } createAccessToken( @@ -201,11 +205,13 @@ export const NewAccessTokenDialog = ({ - {Object.values(ExpiresAtOptions).map((option) => ( - - {option.label} - - ))} + {Object.values(EXPIRES_AT_OPTIONS).map( + (option: { value: string; label: string }) => ( + + {option.label} + + ) + )} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/NewAccessTokenButton.test.tsx b/apps/studio/components/interfaces/Account/AccessTokens/NewAccessTokenButton.test.tsx deleted file mode 100644 index 41a45511e54..00000000000 --- a/apps/studio/components/interfaces/Account/AccessTokens/NewAccessTokenButton.test.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { faker } from '@faker-js/faker' -import { screen, waitFor } from '@testing-library/dom' -import userEvent from '@testing-library/user-event' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { render } from 'tests/helpers' -import { addAPIMock } from 'tests/lib/msw' -import { NewAccessTokenButton } from './NewAccessTokenButton' - -describe(`NewAccessTokenButton`, () => { - beforeEach(() => { - addAPIMock({ - method: `post`, - path: `/platform/profile/access-tokens`, - response: { - name: faker.lorem.word(), - scope: faker.helpers.arrayElement(['V0', undefined]), - created_at: faker.date.past().toISOString(), - expires_at: null, - id: faker.number.int(), - token_alias: faker.lorem.words(), - token: faker.lorem.words(), - last_used_at: faker.date.recent().toISOString(), - }, - }) - }) - - it(`generates regular tokens`, async () => { - const onCreateToken = vi.fn() - render() - - const dialogTrigger = screen.getByRole(`button`, { name: `Generate new token` }) - await userEvent.click(dialogTrigger) - - // Fill in the token name - const nameInput = screen.getByLabelText(`Name`) - await userEvent.type(nameInput, `test`) - - // Verify the form is open and the name field works - expect(nameInput).toHaveValue(`test`) - expect(screen.getByRole(`button`, { name: `Generate token` })).toBeInTheDocument() - }) - - it(`generates experimental tokens`, async () => { - const onCreateToken = vi.fn() - render() - - const dropdownTrigger = screen.getByTitle(`Choose token scope`) - await userEvent.click(dropdownTrigger) - - const experimentalMenuItem = await screen.findByRole(`menuitem`, { - name: `Generate token for experimental API`, - }) - await userEvent.click(experimentalMenuItem) - - await waitFor(() => { - expect( - screen.getByRole(`heading`, { name: `Generate token for experimental API` }) - ).toBeInTheDocument() - expect( - screen.getByText( - `The experimental API provides additional endpoints which allows you to manage your organizations and projects.` - ) - ).toBeInTheDocument() - }) - - // Fill in the token name - const nameInput = screen.getByLabelText(`Name`) - await userEvent.type(nameInput, `test`) - - // Verify the form is open and the name field works - expect(nameInput).toHaveValue(`test`) - expect(screen.getByRole(`button`, { name: `Generate token` })).toBeInTheDocument() - }) - - it(`resets the form on close/cancel`, async () => { - render() - - // pass 1: open dialog and fill form - const dialogTrigger = screen.getByRole(`button`, { name: `Generate new token` }) - await userEvent.click(dialogTrigger) - - let nameInput = screen.getByLabelText(`Name`) - await userEvent.type(nameInput, `cancel button test`) - expect(nameInput).toHaveValue(`cancel button test`) - - // reset the form by pressing the cancel button - const cancelButton = screen.getByRole(`button`, { name: `Cancel` }) - await userEvent.click(cancelButton) - - // pass 2: check that the form is reset, then fill it again - await userEvent.click(dialogTrigger) - - nameInput = screen.getByLabelText(`Name`) - expect(nameInput).not.toHaveValue(`cancel button test`) - - await userEvent.type(nameInput, `close modal test`) - expect(nameInput).toHaveValue(`close modal test`) - - // reset the form by closing the dialog - await userEvent.keyboard(`{Escape}`) - - // pass 3: check that the form has been reset again - await userEvent.click(dialogTrigger) - - nameInput = screen.getByLabelText(`Name`) - expect(nameInput).not.toHaveValue(`close modal test`) - }) - - // it(`shows validation error when no permissions are configured`, async () => { - // const onCreateToken = vi.fn() - // render() - - // const dialogTrigger = screen.getByRole(`button`, { name: `Generate new token` }) - // await userEvent.click(dialogTrigger) - - // // Fill in the token name - // const nameInput = screen.getByLabelText(`Name`) - // await userEvent.type(nameInput, `test`) - - // // Try to submit without adding permissions - // const generateButton = screen.getByRole(`button`, { name: `Generate token` }) - // await userEvent.click(generateButton) - - // // The form should not submit and onCreateToken should not be called - // // because validation prevents submission when no permissions are configured - // expect(onCreateToken).not.toHaveBeenCalled() - - // // The form should still be open and the button should still be enabled - // expect(screen.getByRole(`button`, { name: `Generate token` })).toBeInTheDocument() - // }) -}) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/NewTokenBanner.tsx b/apps/studio/components/interfaces/Account/AccessTokens/NewTokenBanner.tsx deleted file mode 100644 index ccdb05bcd83..00000000000 --- a/apps/studio/components/interfaces/Account/AccessTokens/NewTokenBanner.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { X } from 'lucide-react' -import { toast } from 'sonner' - -import { NewAccessToken } from 'data/access-tokens/access-tokens-create-mutation' -import { Button } from 'ui' -import { Admonition } from 'ui-patterns' -import { Input } from 'ui-patterns/DataInputs/Input' - -interface NewTokenBannerProps { - token: NewAccessToken - onClose: () => void -} - -export const NewTokenBanner = ({ token, onClose }: NewTokenBannerProps) => { - return ( - -

- Do copy this access token and store it in a secure place - you will not be able to see - it again. -

-
- {}} - onCopy={() => toast.success('Token copied to clipboard')} - /> -
-
- } - > - + + + + + + No resources found. + + +
+ {ACCESS_TOKEN_RESOURCES.map((resource) => { + const isChecked = permissionRows.some( + (row: PermissionRow) => row.resource === resource.resource + ) + return ( + handleToggleResource(resource)} + className="text-foreground" + > +
+ handleToggleResource(resource)} + onClick={(e) => e.stopPropagation()} + /> + +
+ + {resource.title} + +
+
+
+ ) + })} +
+
+
+
+
+ + ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.tsx new file mode 100644 index 00000000000..3a06202b1a9 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.tsx @@ -0,0 +1,172 @@ +import { Path, PathValue } from 'react-hook-form' +import { ChevronDown } from 'lucide-react' +import { + Button, + Popover_Shadcn_, + PopoverContent_Shadcn_, + PopoverTrigger_Shadcn_, + Checkbox_Shadcn_, + WarningIcon, +} from 'ui' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { X, RotateCcw } from 'lucide-react' +import { ACCESS_TOKEN_RESOURCES } from '../../../AccessToken.constants' +import { PermissionRow, PermissionsFormValues, PermissionsProps } from './Permissions.types' +import { sortActions } from './Permissions.utils' +import { formatAccessText } from '../../../AccessToken.utils' +import { PermissionResourceSelector } from './PermissionResourceSelector' + +export const Permissions = ({ + setValue, + watch, + resourceSearchOpen, + setResourceSearchOpen, +}: PermissionsProps) => { + const permissionRows = (watch('permissionRows' as Path) || []) as PermissionRow[] + + return ( +
+
+
+ Configure permissions +
+ {permissionRows.length > 0 && ( + { + setValue( + 'permissionRows' as Path, + [] as PathValue> + ) + }} + icon={} + tooltip={{ + content: { + side: 'top', + align: 'center', + alignOffset: -10, + text: 'Reset all permissions', + }, + }} + /> + )} + +
+
+ + {permissionRows.length === 0 ? ( +
+

No permissions configured yet.

+
+ ) : ( +
+ {permissionRows.map((row, index) => { + const selectedResource = ACCESS_TOKEN_RESOURCES.find( + (r) => r.resource === row.resource + ) + return ( +
+
+
+
+
+ + {selectedResource?.title} + +
+
+
+
+ {selectedResource && ( + + + + + +
+ {sortActions(selectedResource.actions).map((action) => ( + + ))} +
+
+
+ )} +
+
+ {index < permissionRows.length - 1 &&
} +
+ ) + })} +
+ )} +
+ +
+ + + Once you've set these permissions, you cannot edit them. + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.types.ts b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.types.ts new file mode 100644 index 00000000000..ffb26fe487a --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.types.ts @@ -0,0 +1,33 @@ +import { FieldValues, UseFormSetValue, UseFormWatch } from 'react-hook-form' + +export interface PermissionResource { + resource: string + title: string + actions: string[] +} + +export interface PermissionRow { + resource: string + actions: string[] +} + +export interface PermissionsFormValues extends FieldValues { + permissionRows?: PermissionRow[] +} + +export interface PermissionsProps< + TFormValues extends PermissionsFormValues = PermissionsFormValues, +> { + setValue: UseFormSetValue + watch: UseFormWatch + resourceSearchOpen: boolean + setResourceSearchOpen: (open: boolean) => void +} + +export interface PermissionResourceSelectorProps { + open: boolean + onOpenChange: (open: boolean) => void + permissionRows: PermissionRow[] + setValue: UseFormSetValue + align?: 'center' | 'end' | 'start' +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.utils.ts b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.utils.ts new file mode 100644 index 00000000000..059d21d26d4 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.utils.ts @@ -0,0 +1,48 @@ +import { PermissionResource, PermissionRow } from './Permissions.types' + +const getBestAction = (actions: string[]): string => { + const availableActions = actions.filter((a) => a !== 'no access') + + if (availableActions.length === 0) return 'no access' + + const priority = ['read', 'write', 'create', 'delete', 'read-write'] + + for (const priorityAction of priority) { + if (availableActions.includes(priorityAction)) { + return priorityAction + } + } + + return availableActions[0] +} + +export const sortActions = (actions: string[]): string[] => { + const sorted: string[] = [] + const remaining = [...actions] + const priority = ['read', 'write', 'create', 'delete'] + + for (const action of priority) { + const index = remaining.indexOf(action) + if (index !== -1) { + sorted.push(action) + remaining.splice(index, 1) + } + } + + sorted.push(...remaining) + return sorted +} + +export const togglePermissionResource = ( + permissionRows: PermissionRow[], + resource: PermissionResource +): PermissionRow[] => { + const isAlreadyAdded = permissionRows.some((row) => row.resource === resource.resource) + + if (isAlreadyAdded) { + return permissionRows.filter((row) => row.resource !== resource.resource) + } + + const defaultActions = resource.actions.includes('read') ? ['read'] : [resource.actions[0]] + return [...permissionRows, { resource: resource.resource, actions: defaultActions }] +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccess/ResourceAccess.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccess/ResourceAccess.tsx new file mode 100644 index 00000000000..d7bc2fea0b8 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccess/ResourceAccess.tsx @@ -0,0 +1,115 @@ +import { useProfile } from 'lib/profile' +import { FormControl_Shadcn_, FormField_Shadcn_, cn } from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { useOrgAndProjectData } from '../../../hooks/useOrgAndProjectData' +import { ResourceAccessProps } from './ResourceAccess.types' +import { ResourceMultiSelector } from './ResourceMultiSelector' +import { ResourceOption } from './ResourceOption' + +const RESOURCE_OPTIONS = [ + { + value: 'all-orgs' as const, + label: 'Everything', + description: 'Access to all projects across all organizations you have access to.', + }, + { + value: 'selected-orgs' as const, + label: 'Selected orgs', + description: 'Access only to the organizations you have specified.', + }, + { + value: 'selected-projects' as const, + label: 'Selected projects', + description: 'Access only to the projects you have specified.', + }, +] + +export const ResourceAccess = ({ control, resourceAccess, setValue }: ResourceAccessProps) => { + const { profile } = useProfile() + + const { organizations, projects, isLoadingOrgs, isLoadingProjects } = useOrgAndProjectData({ + enabled: !!profile, + }) + + const handleResourceAccessChange = (newValue: string, currentValue: string) => { + if (newValue !== currentValue) { + setValue('selectedOrganizations', []) + setValue('selectedProjects', []) + } + } + + return ( +
+ ( + + +
+
+ Resource access level + {RESOURCE_OPTIONS.map((option) => ( + { + handleResourceAccessChange(option.value, field.value) + field.onChange(option.value) + }} + /> + ))} +
+ +

+ {RESOURCE_OPTIONS.find((opt) => opt.value === field.value)?.description} +

+
+
+
+ )} + /> + + {resourceAccess === 'selected-orgs' && ( + ( + ({ id: org.slug, name: org.name }))} + isLoading={isLoadingOrgs} + fieldName="selectedOrganizations" + label="Select organizations" + loadingMessage="Loading organizations..." + emptyMessage="No organizations available" + /> + )} + /> + )} + + {resourceAccess === 'selected-projects' && ( + ( + ({ id: project.ref, name: project.name }))} + isLoading={isLoadingProjects} + fieldName="selectedProjects" + label="Select projects" + loadingMessage="Loading projects..." + emptyMessage="No projects available" + /> + )} + /> + )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccess/ResourceAccess.types.ts b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccess/ResourceAccess.types.ts new file mode 100644 index 00000000000..df1409affc8 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccess/ResourceAccess.types.ts @@ -0,0 +1,30 @@ +import { Control, ControllerRenderProps, UseFormSetValue } from 'react-hook-form' +import { TokenFormValues } from '../../../AccessToken.schemas' + +export interface ResourceAccessProps { + control: Control + resourceAccess: string + setValue: UseFormSetValue +} + +export interface ResourceItem { + id: string + name: string +} + +export interface ResourceMultiSelectorProps { + field: ControllerRenderProps + items: ResourceItem[] + isLoading: boolean + fieldName: string + label: string + loadingMessage: string + emptyMessage: string +} + +export interface ResourceOptionProps { + value: string + label: string + isSelected: boolean + onChange: () => void +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccess/ResourceMultiSelector.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccess/ResourceMultiSelector.tsx new file mode 100644 index 00000000000..b32619fd883 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccess/ResourceMultiSelector.tsx @@ -0,0 +1,68 @@ +import { useMemo } from 'react' +import { FormControl_Shadcn_ } from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { + MultiSelector, + MultiSelectorContent, + MultiSelectorItem, + MultiSelectorList, + MultiSelectorTrigger, +} from 'ui-patterns/multi-select' +import { ResourceMultiSelectorProps } from './ResourceAccess.types' + +export const ResourceMultiSelector = ({ + field, + items, + isLoading, + fieldName, + label, + loadingMessage, + emptyMessage, +}: ResourceMultiSelectorProps) => { + const idToName = useMemo(() => new Map(items.map((item) => [item.id, item.name])), [items]) + + const displayValues = (field.value || []).map((id: string) => idToName.get(id) || id) + + const handleValuesChange = (names: string[]) => { + const ids = names + .map((name) => { + for (const [id, itemName] of idToName.entries()) { + if (itemName === name) return id + } + return name + }) + .filter(Boolean) + field.onChange(ids) + } + + return ( + + + + + + {isLoading ? ( +
{loadingMessage}
+ ) : items.length === 0 ? ( +
{emptyMessage}
+ ) : ( + + {items.map((item) => ( + + {item.name} + + ))} + + )} +
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccess/ResourceOption.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccess/ResourceOption.tsx new file mode 100644 index 00000000000..0aed24cf0b2 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccess/ResourceOption.tsx @@ -0,0 +1,42 @@ +import { cn } from 'ui' +import { Box } from 'lucide-react' +import { Check } from 'lucide-react' + +export const ResourceOption = ({ + value, + label, + isSelected, + onChange, +}: { + value: string + label: string + isSelected: boolean + onChange: () => void +}) => ( + +) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenButton.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenButton.tsx new file mode 100644 index 00000000000..c95257038f9 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenButton.tsx @@ -0,0 +1,33 @@ +import { useState } from 'react' + +import { Button } from 'ui' +import { type NewScopedAccessToken } from 'data/scoped-access-tokens/scoped-access-token-create-mutation' +import { NewScopedTokenSheet } from './NewScopedTokenSheet' + +export interface NewScopedTokenButtonProps { + onCreateToken: (token: NewScopedAccessToken) => void +} + +export const NewScopedTokenButton = ({ onCreateToken }: NewScopedTokenButtonProps) => { + const [visible, setVisible] = useState(false) + + return ( + <> + + + + + ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx new file mode 100644 index 00000000000..2eaf5380d36 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx @@ -0,0 +1,320 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import dayjs from 'dayjs' +import { ExternalLink } from 'lucide-react' +import Link from 'next/link' +import { useCallback, useState } from 'react' +import { type SubmitHandler, useForm } from 'react-hook-form' +import { toast } from 'sonner' + +import { + useAccessTokenCreateMutation, + type NewScopedAccessToken, + type ScopedAccessTokenCreateVariables, +} from 'data/scoped-access-tokens/scoped-access-token-create-mutation' +import { + Button, + Form_Shadcn_, + ScrollArea, + Separator, + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetTitle, + SheetDescription, +} from 'ui' +import { Admonition } from 'ui-patterns' +import { BasicInfo } from './Form/BasicInfo' +import { Permissions } from './Form/Permissions/Permissions' +import { ResourceAccess } from './Form/ResourceAccess/ResourceAccess' +import { + CUSTOM_EXPIRY_VALUE, + EXPIRES_AT_OPTIONS, + type ScopedAccessTokenPermission, +} from '../AccessToken.constants' +import { useOrgAndProjectData } from '../hooks/useOrgAndProjectData' +import { mapPermissionToFGA, getExpirationDate } from '../AccessToken.utils' +import { TokenSchema, type TokenFormValues } from '../AccessToken.schemas' + +export interface NewScopedTokenSheetProps { + visible: boolean + onOpenChange: (open: boolean) => void + tokenScope: 'V0' | undefined + onCreateToken: (token: NewScopedAccessToken) => void +} + +export const NewScopedTokenSheet = ({ + visible, + onOpenChange, + tokenScope, + onCreateToken, +}: NewScopedTokenSheetProps) => { + const [resourceSearchOpen, setResourceSearchOpen] = useState(false) + const { organizations, projects } = useOrgAndProjectData() + + const form = useForm({ + resolver: zodResolver(TokenSchema), + defaultValues: { + tokenName: '', + expiresAt: EXPIRES_AT_OPTIONS['month'].value, + customExpiryDate: undefined, + resourceAccess: 'all-orgs', + selectedOrganizations: [], + selectedProjects: [], + permissionRows: [], + }, + mode: 'onChange', + }) + const { mutate: createAccessToken, isPending } = useAccessTokenCreateMutation() + + const resourceAccess = form.watch('resourceAccess') + const expiresAt = form.watch('expiresAt') + const permissionRows = form.watch('permissionRows') || [] + + const onSubmit: SubmitHandler = async (values) => { + if (!permissionRows || permissionRows.length === 0) { + toast.error('Please configure at least one permission.') + return + } + + const hasValidPermissions = permissionRows.every( + (row) => row.resource && row.actions && row.actions.length > 0 + ) + if (!hasValidPermissions) { + toast.error('Please ensure all permissions have both resource and action selected.') + return + } + + if (values.resourceAccess === 'selected-orgs') { + const selectedOrgs = values.selectedOrganizations || [] + + if (selectedOrgs.length === 0) { + toast.error('Please select at least one organization.') + return + } + + const availableOrgSlugs = organizations.map((org) => org.slug) + const invalidOrgs = selectedOrgs.filter((slug) => !availableOrgSlugs.includes(slug)) + + if (invalidOrgs.length > 0) { + toast.error( + `You don't have access to the following organization(s): ${invalidOrgs.join(', ')}` + ) + return + } + } + + if (values.resourceAccess === 'selected-projects') { + const selectedProjects = values.selectedProjects || [] + + if (selectedProjects.length === 0) { + toast.error('Please select at least one project.') + return + } + + const availableProjectRefs = projects.map((project) => project.ref) + const invalidProjects = selectedProjects.filter((ref) => !availableProjectRefs.includes(ref)) + + if (invalidProjects.length > 0) { + toast.error( + `You don't have access to the following project(s): ${invalidProjects.join(', ')}` + ) + return + } + } + + const finalExpiresAt = + values.expiresAt === CUSTOM_EXPIRY_VALUE + ? values.customExpiryDate + : getExpirationDate(values.expiresAt || '') + + const permissions = permissionRows + .flatMap((row) => { + const { resource, actions } = row + return actions.flatMap((action) => mapPermissionToFGA(resource, action)) + }) + .filter(Boolean) as ScopedAccessTokenPermission[] + + if (!permissions || permissions.length === 0) { + toast.error('Please configure at least one valid permission.') + return + } + + const finalPayload: ScopedAccessTokenCreateVariables = { + name: values.tokenName, + permissions, + } + + if (finalExpiresAt) { + finalPayload.expires_at = finalExpiresAt + } + + if ( + values.resourceAccess === 'selected-orgs' && + values.selectedOrganizations && + values.selectedOrganizations.length > 0 + ) { + finalPayload.organization_slugs = values.selectedOrganizations + } else if ( + values.resourceAccess === 'selected-projects' && + values.selectedProjects && + values.selectedProjects.length > 0 + ) { + finalPayload.project_refs = values.selectedProjects + } + + if (!finalPayload.name || finalPayload.name.trim() === '') { + toast.error('Please enter a token name.') + return + } + + if (!finalPayload.permissions || finalPayload.permissions.length === 0) { + toast.error('Please configure at least one permission.') + return + } + + createAccessToken(finalPayload, { + onSuccess: (data) => { + toast.success('Access token created successfully') + onCreateToken(data) + handleClose() + }, + onError: (error) => { + if (error.message && error.message.includes("don't have access")) { + toast.error( + `Access Error: ${error.message}. Please verify you have access to the selected resources.` + ) + } else { + toast.error(`Failed to create access token: ${error.message}`) + } + }, + }) + } + + const handleClose = () => { + form.reset({ + tokenName: '', + expiresAt: EXPIRES_AT_OPTIONS['month'].value, + customExpiryDate: undefined, + resourceAccess: 'all-orgs', + selectedOrganizations: [], + selectedProjects: [], + permissionRows: [], + }) + onOpenChange(false) + } + + const handleCustomDateChange = useCallback( + (date: { date: string } | undefined) => { + form.setValue('customExpiryDate', date?.date, { shouldValidate: true }) + }, + [form] + ) + + const handleCustomExpiryChange = useCallback( + (isCustom: boolean) => { + if (isCustom && !form.getValues('customExpiryDate')) { + form.setValue('customExpiryDate', dayjs().endOf('day').toISOString(), { + shouldValidate: true, + }) + } + if (!isCustom) { + form.setValue('customExpiryDate', undefined, { shouldValidate: true }) + } + }, + [form] + ) + + return ( + { + if (!open) { + handleClose() + } else { + onOpenChange(open) + } + }} + > + + + + {tokenScope === 'V0' ? 'Generate token for experimental API' : 'Generate New Token'} + + + A form to generate a new scoped access token. + + + +
+ {tokenScope === 'V0' && ( +
+ +

+ These include deleting organizations and projects which cannot be undone. As + such, be very careful when using this API. +

+
+ +
+ + } + /> +
+ )} + + +
+ + + + + +
+
+
+
+ +
+ + +
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ScopedTokenList.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ScopedTokenList.tsx new file mode 100644 index 00000000000..85307d63561 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ScopedTokenList.tsx @@ -0,0 +1,187 @@ +import AlertError from 'components/ui/AlertError' +import { useScopedAccessTokenDeleteMutation } from 'data/scoped-access-tokens/scoped-access-tokens-delete-mutation' +import { + ScopedAccessToken, + useScopedAccessTokensQuery, +} from 'data/scoped-access-tokens/scoped-access-token-query' +import { MoreVertical, Trash, Key } from 'lucide-react' +import { useMemo, useState } from 'react' +import { toast } from 'sonner' +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from 'ui' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { TableCell, TableRow } from 'ui/src/components/shadcn/ui/table' +import { parseAsStringLiteral, useQueryState } from 'nuqs' + +import { + ACCESS_TOKEN_SORT_VALUES, + AccessTokenSort, + AccessTokenSortColumn, +} from '../AccessToken.types' +import { handleSortChange, filterAndSortTokens } from '../AccessToken.utils' +import { TableContainer } from '../AccessTokenTable/TableContainer' +import { RowLoading } from '../AccessTokenTable/RowLoading' +import { TokenNameCell, LastUsedCell, ExpiresCell } from '../AccessTokenTable/TokenCells' +import { ViewTokenSheet } from './ViewTokenSheet' + +export interface ScopedTokenListProps { + searchString?: string + onDeleteSuccess: (id: string | number) => void +} + +export const ScopedTokenList = ({ searchString = '', onDeleteSuccess }: ScopedTokenListProps) => { + const [isOpen, setIsOpen] = useState(false) + const [token, setToken] = useState(undefined) + const [viewToken, setViewToken] = useState(undefined) + const [isViewSheetOpen, setIsViewSheetOpen] = useState(false) + const [sort, setSort] = useQueryState( + 'sort', + parseAsStringLiteral(ACCESS_TOKEN_SORT_VALUES).withDefault('created_at:desc') + ) + + const { data: tokensData, error, isPending: isLoading, isError } = useScopedAccessTokensQuery() + + const tokens = tokensData?.tokens + + const { mutate: deleteToken } = useScopedAccessTokenDeleteMutation({ + onSuccess: (_, vars) => { + onDeleteSuccess(vars.id) + toast.success('Successfully deleted access token') + setIsOpen(false) + }, + onError: (error) => { + toast.error(`Failed to delete access token: ${error.message}`) + }, + }) + + const onSortChange = (column: AccessTokenSortColumn) => { + handleSortChange(sort, column, setSort) + } + + const filteredTokens = useMemo( + () => filterAndSortTokens(tokens, searchString, sort), + [tokens, searchString, sort] + ) + + const empty = filteredTokens?.length === 0 && !isLoading + + if (isError) { + return ( + + + + + + + + ) + } + + if (isLoading) { + return ( + + + + + ) + } + + if (empty) { + return ( + + + +

No scoped access tokens found

+

+ You do not have any scoped tokens created yet +

+
+
+
+ ) + } + + return ( + <> + + {filteredTokens?.map((x) => ( + + + + + +
+ + +
+
+
+ ))} +
+ + setIsOpen(false)} + onConfirm={() => { + if (token) deleteToken({ id: token.id as string }) + }} + > +

+ This action cannot be undone. Are you sure you want to delete "{token?.name}" token? +

+
+ + { + setIsViewSheetOpen(false) + setViewToken(undefined) + }} + /> + + ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx new file mode 100644 index 00000000000..f1810cf962c --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx @@ -0,0 +1,304 @@ +import dayjs from 'dayjs' +import { + Sheet, + SheetContent, + SheetHeader, + ScrollArea, + cn, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from 'ui' +import { useScopedAccessTokenQuery } from 'data/scoped-access-tokens/scoped-access-token-query' +import { DocsButton } from 'components/ui/DocsButton' +import { Card, CardContent } from 'ui' +import { ACCESS_TOKEN_RESOURCES } from '../AccessToken.constants' +import { useMemo } from 'react' +import { formatAccessText, getRealAccess } from '../AccessToken.utils' +import { useOrgAndProjectData } from '../hooks/useOrgAndProjectData' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' + +interface ViewTokenSheetProps { + visible: boolean + tokenId: string | undefined + onClose: () => void +} + +export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProps) { + const { organizations, projects } = useOrgAndProjectData() + + const { + data: token, + isLoading: isTokenLoading, + error: tokenError, + } = useScopedAccessTokenQuery( + { id: tokenId! }, + { + enabled: visible && !!tokenId, + retry: 1, + retryDelay: 1000, + } + ) + + const groupedResourcesByAccess = useMemo(() => { + const grouped: Record = {} + + if (!token?.permissions) { + return grouped + } + + ACCESS_TOKEN_RESOURCES.forEach((resource) => { + const access = getRealAccess(resource.resource, token.permissions) + if (access !== 'no access') { + const formattedAccess = formatAccessText(access) + if (!grouped[formattedAccess]) { + grouped[formattedAccess] = [] + } + grouped[formattedAccess].push(resource.title) + } + }) + + return grouped + }, [token?.permissions]) + + const getResourceAccessInfo = () => { + const resources: Array<{ name: string; type: string; identifier: string }> = [] + + const organizationSlugs = token?.organization_slugs + if (organizationSlugs && Array.isArray(organizationSlugs) && organizationSlugs.length > 0) { + organizationSlugs.forEach((orgSlug: string) => { + const org = organizations.find((o) => o.slug === orgSlug) + resources.push({ + name: org?.name || orgSlug, + type: 'Organization', + identifier: orgSlug, + }) + }) + } + + const projectRefs = token?.project_refs + if (projectRefs && Array.isArray(projectRefs) && projectRefs.length > 0) { + projectRefs.forEach((projectRef: string) => { + const project = projects.find((p) => p.ref === projectRef) + resources.push({ + name: project?.name || projectRef, + type: 'Project', + identifier: projectRef, + }) + }) + } + + return resources + } + + return ( + <> + onClose()}> + + +

+ View access for {token?.name} +

+ +
+ +
+ {isTokenLoading && ( +
+

Loading token information...

+
+ )} + + {tokenError && ( +
+

+ Error loading token information. Please try again. +

+
+ )} + + {token && ( + <> +
+

Token Information

+ + + + + + + Info + + + Date + + + + + + +

Created

+
+ + {token?.created_at ? ( + + ) : ( + Unknown + )} + +
+ + +

Last used

+
+ + {token?.last_used_at ? ( + + ) : ( + Never + )} + +
+ + +

Expires

+
+ + {token?.expires_at ? ( + + ) : ( + Never + )} + +
+
+
+
+
+
+ +
+

Resource Access

+ + + + + + + Resource + + + Type + + + + + {getResourceAccessInfo().length > 0 ? ( + getResourceAccessInfo().map((resource, index) => ( + + +

{resource.name}

+
+ + {resource.type} + +
+ )) + ) : ( + + +

+ {(token?.organization_slugs && + token.organization_slugs.length > 0) || + (token?.project_refs && token.project_refs.length > 0) + ? 'This token has access to specific organizations and projects.' + : 'This token has access to all resources.'} +

+
+
+ )} +
+
+
+
+
+ +
+

Permissions

+ + + + + + + Permission + + + Access + + + + + {Object.keys(groupedResourcesByAccess).length === 0 ? ( + + +

+ No permissions configured for this token. +

+
+
+ ) : ( + Object.entries(groupedResourcesByAccess).map( + ([accessLevel, resources]) => { + return resources.map((resource) => ( + + +

+ {resource} +

+
+ + + {formatAccessText(accessLevel)} + + +
+ )) + } + ) + )} +
+
+
+
+
+ + )} +
+
+
+
+ + ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/hooks/useGroupedPermissions.ts b/apps/studio/components/interfaces/Account/AccessTokens/hooks/useGroupedPermissions.ts new file mode 100644 index 00000000000..d69f3e23e1d --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/hooks/useGroupedPermissions.ts @@ -0,0 +1,33 @@ +import { useMemo } from 'react' +import { ACCESS_TOKEN_RESOURCES } from '../AccessToken.constants' +import { getRealAccess, formatAccessText } from '../AccessToken.utils' + +export const useGroupedPermissions = (tokenPermissions: string[] | undefined) => { + const groupedPermissions = useMemo(() => { + const grouped: Record = {} + + if (!tokenPermissions || tokenPermissions.length === 0) { + return grouped + } + + ACCESS_TOKEN_RESOURCES.forEach((resource) => { + const access = getRealAccess(resource.resource, tokenPermissions) + if (access !== 'no access') { + const formattedAccess = formatAccessText(access) + if (!grouped[formattedAccess]) { + grouped[formattedAccess] = [] + } + grouped[formattedAccess].push(resource.title) + } + }) + + return grouped + }, [tokenPermissions]) + + const totalCount = Object.values(groupedPermissions).reduce( + (sum, resources) => sum + resources.length, + 0 + ) + + return { groupedPermissions, totalCount } +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/hooks/useOrgAndProjectData.ts b/apps/studio/components/interfaces/Account/AccessTokens/hooks/useOrgAndProjectData.ts new file mode 100644 index 00000000000..2ae0c8bc0b4 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/hooks/useOrgAndProjectData.ts @@ -0,0 +1,29 @@ +import { useMemo } from 'react' +import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import { useProjectsInfiniteQuery } from 'data/projects/projects-infinite-query' + +interface UseOrgAndProjectDataOptions { + enabled?: boolean +} + +export const useOrgAndProjectData = (options: UseOrgAndProjectDataOptions = {}) => { + const { enabled = true } = options + + const { data: organizations = [], isLoading: isLoadingOrgs } = useOrganizationsQuery({ enabled }) + + const { data: projectsData, isLoading: isLoadingProjects } = useProjectsInfiniteQuery({ + limit: 100, + }) + + const projects = useMemo( + () => projectsData?.pages.flatMap((page) => page.projects) ?? [], + [projectsData] + ) + + return { + organizations, + projects, + isLoadingOrgs, + isLoadingProjects, + } +} diff --git a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx index de62e8abea1..215efbfa17d 100644 --- a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx +++ b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx @@ -1,32 +1,19 @@ import { useFlag } from 'common' import { ClockSkewBanner } from 'components/layouts/AppLayout/ClockSkewBanner' -import { IncidentBanner } from 'components/layouts/AppLayout/IncidentBanner' import { NoticeBanner } from 'components/layouts/AppLayout/NoticeBanner' +import { StatusPageBanner } from 'components/layouts/AppLayout/StatusPageBanner' import { PropsWithChildren } from 'react' import { OrganizationResourceBanner } from '../Organization/HeaderBanner' -import { MaintenanceBanner } from '@/components/layouts/AppLayout/MaintenanceBanner' -import { useIncidentStatusQuery } from '@/data/platform/incident-status-query' export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => { - const { data: allStatusPageEvents } = useIncidentStatusQuery() - const { maintenanceEvents = [], incidents = [] } = allStatusPageEvents ?? {} - - // Only show incident banner for incidents with real impact (not "none") - const hasBannerWorthyIncidents = incidents.some((incident) => incident.impact !== 'none') - const ongoingIncident = - useFlag('ongoingIncident') || - process.env.NEXT_PUBLIC_ONGOING_INCIDENT === 'true' || - hasBannerWorthyIncidents - const ongoingMaintenance = maintenanceEvents.length > 0 - const showNoticeBanner = useFlag('showNoticeBanner') const clockSkewBanner = useFlag('clockSkewBanner') return (
- {ongoingIncident ? : ongoingMaintenance ? : null} + {showNoticeBanner && } {clockSkewBanner && } diff --git a/apps/studio/components/interfaces/EdgeFunctions/DeployEdgeFunctionButton.tsx b/apps/studio/components/interfaces/EdgeFunctions/DeployEdgeFunctionButton.tsx index c33b20001ad..06b36e34196 100644 --- a/apps/studio/components/interfaces/EdgeFunctions/DeployEdgeFunctionButton.tsx +++ b/apps/studio/components/interfaces/EdgeFunctions/DeployEdgeFunctionButton.tsx @@ -1,37 +1,54 @@ -import { ChevronDown, Code, Terminal } from 'lucide-react' -import { parseAsString, useQueryState } from 'nuqs' -import { useRouter } from 'next/router' - import { useParams } from 'common' +import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { ChevronDown, Code, Terminal } from 'lucide-react' +import { useRouter } from 'next/router' +import { parseAsString, useQueryState } from 'nuqs' import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' +import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' import { AiIconAnimation, - Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from 'ui' -import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' -import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' + +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { useIsProjectActive } from '@/hooks/misc/useSelectedProject' export const DeployEdgeFunctionButton = () => { const router = useRouter() const { ref } = useParams() const { data: org } = useSelectedOrganizationQuery() + const snap = useAiAssistantStateSnapshot() const { openSidebar } = useSidebarManagerSnapshot() + const { mutate: sendEvent } = useSendEventMutation() const [, setCreateMethod] = useQueryState('create', parseAsString) + const isProjectActive = useIsProjectActive() + return ( - - + { app.listen(8000);`, }, { - value: 'openai-completion', - name: 'OpenAI Text Completion', - description: 'Generate text completions using OpenAI GPT-3', - content: `// Setup type definitions for built-in Supabase Runtime APIs -import "jsr:@supabase/functions-js/edge-runtime.d.ts" -import { OpenAI } from "npm:openai@4.8.0" + value: 'stream-text-with-ai-sdk', + name: 'Stream text with AI SDK', + description: 'Generate and stream text with Vercel AI SDK', + content: `/* + * Setup OPENAI_API_KEY secret to get started. + * For usage with useChat, point transport.api to this endpoint + * and include your publishable key as Authorization: Bearer in transport.headers. + */ -const openai = new OpenAI({ - apiKey: Deno.env.get('OPENAI_API_KEY') -}) +import { createOpenAI } from 'npm:@ai-sdk/openai'; +import { convertToModelMessages, streamText } from 'npm:ai'; -Deno.serve(async (req)=>{ - const { prompt } = await req.json() - const response = await openai.chat.completions.create({ - model: "gpt-3.5-turbo", - messages: [ - { - role: "user", - content: prompt - } - ] - }) - return new Response(JSON.stringify({ - text: response.choices[0].message.content - }), { - headers: { - 'Content-Type': 'application/json', - 'Connection': 'keep-alive' +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'authorization, content-type', + 'Access-Control-Max-Age': '3600', + Vary: 'Access-Control-Request-Headers', +}; + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + +class ClientError extends Error {} + +const openai = createOpenAI({ + apiKey: Deno.env.get('OPENAI_API_KEY'), +}); + +const SYSTEM_PROMPT = 'You are a helpful AI assistant.'; + +Deno.serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders }); + } + + try { + const body = await req.json().catch(() => { + throw new ClientError('Invalid JSON payload'); + }) as { messages?: unknown; model?: unknown }; + + const { messages, model: modelName } = body; + + if (!Array.isArray(messages)) { + throw new ClientError('Request must include a messages array'); } - }) -})`, + + const normalizedMessages = await convertToModelMessages(messages); + + const model = openai( + typeof modelName === 'string' ? modelName : 'gpt-5.1-chat-latest', + ); + + const result = streamText({ + model, + messages: normalizedMessages, + system: SYSTEM_PROMPT, + }); + + return result.toUIMessageStreamResponse({ + headers: corsHeaders, + sendReasoning: true, + sendSources: true, + }); + } catch (err) { + if (err instanceof ClientError) { + return json(400, { error: err.message }); + } + + console.error('Assistant chat error:', err); + return json(500, { + error: 'Failed to process chat request', + details: err instanceof Error ? err.message : String(err), + }); + } +});`, + }, + { + value: 'generate-recipes-with-ai-sdk', + name: 'Generate recipes with AI SDK', + description: 'Generate structured cooking recipes with Vercel AI SDK', + content: `/* +* 1) Setup OPENAI_API_KEY secret to get started. +* 2) Call this endpoint with { prompt, model? } to generate a recipe object matching the schema below. +*/ + +import { createOpenAI } from 'npm:@ai-sdk/openai'; +import { generateText, Output } from "npm:ai"; +import { z } from 'npm:zod'; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'authorization, content-type', + 'Access-Control-Max-Age': '3600', + Vary: 'Access-Control-Request-Headers', +}; + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + +class ClientError extends Error {} + +const openai = createOpenAI({ + apiKey: Deno.env.get('OPENAI_API_KEY'), +}); + +const RecipeSchema = z.object({ + recipe: z.object({ + name: z.string(), + ingredients: z.array(z.string()), + steps: z.array(z.string()), + }), +}); + +const SYSTEM_PROMPT = + 'You are a recipe generator. Always return a structured recipe matching the given schema.'; + +Deno.serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders }); + } + + try { + const body = await req.json().catch(() => { + throw new ClientError('Invalid JSON payload'); + }) as { + model?: unknown; + prompt?: unknown; + }; + + const { model: modelName, prompt } = body; + + if (typeof prompt !== 'string' || !prompt.trim()) { + throw new ClientError('Request must include a non-empty prompt string'); + } + + const model = openai( + typeof modelName === 'string' ? modelName : 'gpt-5.1-chat-latest', + ); + + const result = await generateText({ + model, + system: SYSTEM_PROMPT, + prompt, + output: Output.object({ + schema: RecipeSchema, + }), + }); + + return json(200, result.output); + } catch (err) { + if (err instanceof ClientError) { + return json(400, { error: err.message }); + } + + console.error('generateText error:', err); + return json(500, { + error: 'Failed to process generateText request', + details: err instanceof Error ? err.message : String(err), + }); + } +});`, }, { value: 'stripe-webhook', diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobPage.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobPage.tsx index 20bd50c3d0f..11f05811907 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobPage.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobPage.tsx @@ -1,14 +1,13 @@ +import { useParams } from 'common' import { toString as CronToString } from 'cronstrue' +import { useCronJobQuery } from 'data/database-cron-jobs/database-cron-job-query' +import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { type ConfirmOnCloseModalProps, useConfirmOnClose } from 'hooks/ui/useConfirmOnClose' import { Edit3, List } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { useState } from 'react' - -import { useParams } from 'common' -import { useCronJobQuery } from 'data/database-cron-jobs/database-cron-job-query' -import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' import { BreadcrumbItem_Shadcn_ as BreadcrumbItem, BreadcrumbLink_Shadcn_ as BreadcrumbLink, @@ -16,13 +15,13 @@ import { BreadcrumbPage_Shadcn_ as BreadcrumbPage, BreadcrumbSeparator_Shadcn_ as BreadcrumbSeparator, Button, - cn, CodeBlock, Sheet, SheetContent, Tooltip, TooltipContent, TooltipTrigger, + cn, } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { @@ -35,6 +34,7 @@ import { PageHeaderTitle, } from 'ui-patterns/PageHeader' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' + import { CreateCronJobSheet } from './CreateCronJobSheet/CreateCronJobSheet' import { isSecondsFormat, parseCronJobCommand } from './CronJobs.utils' import { PreviousRunsTab } from './PreviousRunsTab' @@ -189,6 +189,7 @@ export const CronJobPage = () => { {secondaryActions.length > 0 && {secondaryActions}} + diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx index 8883ad7c2db..77b488e4a19 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobTableCell.tsx @@ -1,6 +1,6 @@ import parser from 'cron-parser' import dayjs from 'dayjs' -import { Copy, Edit, MoreVertical, Play, Trash } from 'lucide-react' +import { Copy, Edit, Minus, MoreVertical, Play, Trash } from 'lucide-react' import { parseAsString, useQueryState } from 'nuqs' import { useState } from 'react' import { toast } from 'sonner' @@ -90,6 +90,7 @@ export const CronJobTableCell = ({ const [showToggleModal, setShowToggleModal] = useState(false) const value = row?.[col.id] + const hasValue = col.id in row const { jobid, schedule, latest_run, status, active, jobname } = row const formattedValue = @@ -241,7 +242,9 @@ export const CronJobTableCell = ({
{['latest_run', 'next_run'].includes(col.id) ? ( - col.id === 'latest_run' && formattedValue === null ? ( + !hasValue ? ( + + ) : col.id === 'latest_run' && formattedValue === null ? (

Job has not been run yet

) : col.id === 'next_run' && !formattedValue ? (

Unable to parse next run for job

diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.CleanupNotice.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.CleanupNotice.tsx index 5f2f8efdf77..cb31fc6d2e0 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.CleanupNotice.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.CleanupNotice.tsx @@ -1,8 +1,15 @@ -import { CheckCircle2, RefreshCw, XCircle } from 'lucide-react' - +import { getScheduleDeleteCronJobRunDetailsSql } from 'data/sql/queries/delete-cron-job-run-details' +import { CheckCircle2, XCircle } from 'lucide-react' import { Button, CodeBlock, + Dialog, + DialogContent, + DialogHeader, + DialogSection, + DialogSectionSeparator, + DialogTitle, + DialogTrigger, Progress, Select_Shadcn_, SelectContent_Shadcn_, @@ -12,138 +19,178 @@ import { } from 'ui' import { Admonition } from 'ui-patterns/admonition' -import { getScheduleDeleteCronJobRunDetailsSql } from 'data/sql/queries/delete-cron-job-run-details' import { CLEANUP_INTERVALS } from './CronJobsTab.constants' -import type { BatchDeletionProgress, CleanupState } from './CronJobsTab.useCleanupActions' +import { + useCronJobsCleanupActions, + type BatchDeletionProgress, +} from './CronJobsTab.useCleanupActions' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' -export interface CronJobRunDetailsOverflowNoticeProps { - estimatedRows?: number - mode: 'confirmed' | 'suspected' - cleanupState: CleanupState - selectedInterval: string - onIntervalChange: (interval: string) => void - onRunDeleteSql: () => void - onRunScheduleSql: () => void - onCancelDeletion: () => void - onRetryDeletion: () => void - onRefresh: () => void +interface CronJobRunDetailsOverflowNoticeV2Props { + refetchJobs: () => void } -export const CronJobRunDetailsOverflowNotice = ({ - estimatedRows, - mode, - cleanupState, - selectedInterval, - onIntervalChange, - onRunDeleteSql, - onRunScheduleSql, - onCancelDeletion, - onRetryDeletion, - onRefresh, -}: CronJobRunDetailsOverflowNoticeProps) => { - const formattedRowEstimate = - typeof estimatedRows === 'number' ? estimatedRows.toLocaleString() : 'unknown' - const noticeTitle = - mode === 'confirmed' - ? 'cron.job_run_details is too large to load' - : 'Cron job overview timed out' - const noticeDescription = - mode === 'confirmed' - ? `We detected approximately ${formattedRowEstimate} rows in cron.job_run_details, which prevents the overview from running.` - : `Loading the cron job overview timed out. The issue might be caused by your cron.job_run_details table having too many rows.` +export const CronJobRunDetailsOverflowNoticeV2 = ( + props: CronJobRunDetailsOverflowNoticeV2Props +) => { + return ( + } + > +

Last run for each cron job omitted due to high query cost

+
+ ) +} + +const CronJobRunDetailsOverflowDialog = ({ + refetchJobs, +}: CronJobRunDetailsOverflowNoticeV2Props) => { + const { data: project } = useSelectedProjectQuery() + + const { + cleanupInterval, + cleanupState, + isScheduling, + isScheduleSuccess, + setCleanupInterval, + runBatchedDeletion, + scheduleCleanup, + cancelDeletion, + } = useCronJobsCleanupActions({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) const isDeleting = cleanupState.status === 'deleting' - const isScheduling = cleanupState.status === 'scheduling' const isDeleteSuccess = cleanupState.status === 'delete-success' const isDeleteError = cleanupState.status === 'delete-error' - const isScheduleSuccess = cleanupState.status === 'schedule-success' const isBusy = isDeleting || isScheduling - const canSchedule = isDeleteSuccess || isScheduleSuccess return ( - -
-

- Remove old run history now, then schedule a cron job that keeps trimming{' '} - cron.job_run_details automatically so the overview remains responsive. -

+ + + + + + + Last run for cron jobs omitted for overview + + + +

+ The dashboard fetches data for the cron jobs overview by running a join between the{' '} + cron.job and{' '} + cron.job_run_details tables to + show each cron job's latest run. +

- {/* Step 1: Delete older entries */} -
-

Step 1: Delete older entries

+

+ However, the join was skipped as the estimated query cost exceeds safety thresholds, + likely due to the size of{' '} + cron.job_run_details table. +

+ - {isDeleting ? ( - - ) : isDeleteSuccess ? ( - - ) : isDeleteError ? ( - - ) : ( -
-
- - - - - - {CLEANUP_INTERVALS.map((option) => ( - - {option.label} - - ))} - - -
- -
- )} -
+ - {/* Step 2: Schedule automated cleanup (only available after successful deletion) */} -
-

Step 2: Schedule an automated cleanup

+ +

+ Suggested steps +

- {!canSchedule ? ( -

- Complete step 1 to enable scheduling a daily cleanup job. -

- ) : isScheduleSuccess ? ( - - ) : ( - <> - + We recommend removing the old run history now, then scheduling a cron job that keeps + trimming the cron.job_run_details table + automatically. This also prevents unnecessary bloat on the database. +

+ +
+

Step 1: Delete older entries

+ + {isDeleting ? ( + + ) : isDeleteSuccess ? ( + + ) : isDeleteError ? ( + runBatchedDeletion(cleanupInterval)} /> - - - )} -
-
- + ) : ( +
+
+ + + + + + {CLEANUP_INTERVALS.map((option) => ( + + {option.label} + + ))} + + +
+ +
+ )} +
+ +
+

Step 2: Schedule an automated cleanup

+ + {!canSchedule ? ( +

+ Complete step 1 to enable scheduling a daily cleanup job. +

+ ) : isScheduleSuccess ? ( + + ) : ( + <> + + + + )} +
+ + + ) } @@ -205,11 +252,7 @@ const DeletionError = ({ error, onRetry }: DeletionErrorProps) => (
) -interface ScheduleSuccessProps { - onRefresh: () => void -} - -const ScheduleSuccess = ({ onRefresh }: ScheduleSuccessProps) => ( +const ScheduleSuccess = () => (
@@ -217,9 +260,8 @@ const ScheduleSuccess = ({ onRefresh }: ScheduleSuccessProps) => (

- Refresh to reload the cron jobs and view the new cleanup job. + New cleanup job should now be visible in the cron jobs overview.

-
) diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.DataGrid.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.DataGrid.tsx index b28921c4dfa..2d5063c263d 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.DataGrid.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.DataGrid.tsx @@ -1,4 +1,4 @@ -import { type MouseEvent, type ReactNode, type UIEvent } from 'react' +import { type MouseEvent, type UIEvent } from 'react' import DataGrid, { type Column, Row } from 'react-data-grid' import AlertError from 'components/ui/AlertError' @@ -15,7 +15,6 @@ interface CronJobsTabDataGridProps { searchQuery?: string | null onScroll: (event: UIEvent) => void onRowClick: (row: CronJob, event: MouseEvent) => void - overlay?: ReactNode } export const CronJobsTabDataGrid = ({ @@ -26,16 +25,13 @@ export const CronJobsTabDataGrid = ({ searchQuery, onScroll, onRowClick, - overlay, }: CronJobsTabDataGridProps) => { - const fallbackContent = overlay ? ( -
{overlay}
- ) : isLoading ? ( + const fallbackContent = isLoading ? (
) : error ? ( -
+
) : ( diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx index 9ce5ae58730..f893e73db28 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx @@ -1,9 +1,3 @@ -import { Loader2 } from 'lucide-react' -import { useRouter } from 'next/router' -import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs' -import { MouseEvent, UIEvent, useMemo, useRef, useState } from 'react' -import { toast } from 'sonner' - import { useParams } from 'common' import { CreateCronJobSheet } from 'components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet' import { CronJob } from 'data/database-cron-jobs/database-cron-jobs-infinite-query' @@ -13,17 +7,21 @@ import { handleErrorOnDelete, useQueryStateWithSelect } from 'hooks/misc/useQuer import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' +import { cleanPointerEventsNoneOnBody, isAtBottom } from 'lib/helpers' import { createNavigationHandler } from 'lib/navigation' import { isGreaterThanOrEqual } from 'lib/semver' -import { cleanPointerEventsNoneOnBody, isAtBottom } from 'lib/helpers' +import { Loader2 } from 'lucide-react' +import { useRouter } from 'next/router' +import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs' +import { MouseEvent, UIEvent, useMemo, useRef, useState } from 'react' +import { toast } from 'sonner' import { LoadingLine, Sheet, SheetContent } from 'ui' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' + import { formatCronJobColumns } from './CronJobs.utils' -import { CronJobRunDetailsOverflowNotice } from './CronJobsTab.CleanupNotice' +import { CronJobRunDetailsOverflowNoticeV2 } from './CronJobsTab.CleanupNotice' import { CronJobsTabDataGrid } from './CronJobsTab.DataGrid' -import { CronJobRunDetailsEstimateErrorNotice } from './CronJobsTab.EstimateErrorNotice' import { CronJobsTabHeader } from './CronJobsTab.Header' -import { useCronJobsCleanupActions } from './CronJobsTab.useCleanupActions' import { useCronJobsData } from './CronJobsTab.useCronJobsData' import { DeleteCronJob } from './DeleteCronJob' @@ -47,83 +45,12 @@ export const CronjobsTab = () => { setSearchQuery(null) } - const { dataStatus, isQueryEnabled, grid, count } = useCronJobsData({ + const { grid, count } = useCronJobsData({ projectRef: project?.ref, connectionString: project?.connectionString, searchQuery, }) - const { - cleanupInterval, - setCleanupInterval, - cleanupState, - runBatchedDeletion, - scheduleCleanup, - cancelDeletion, - } = useCronJobsCleanupActions({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - - const gridOverlay = useMemo(() => { - switch (dataStatus.status) { - case 'overflow-confirmed': - return ( - runBatchedDeletion(cleanupInterval)} - onRunScheduleSql={() => scheduleCleanup(cleanupInterval)} - onCancelDeletion={cancelDeletion} - onRetryDeletion={() => runBatchedDeletion(cleanupInterval)} - onRefresh={grid.refetch} - /> - ) - - case 'overflow-suspected': - return ( - runBatchedDeletion(cleanupInterval)} - onRunScheduleSql={() => scheduleCleanup(cleanupInterval)} - onCancelDeletion={cancelDeletion} - onRetryDeletion={() => runBatchedDeletion(cleanupInterval)} - onRefresh={grid.refetch} - /> - ) - - case 'estimate-error': - return ( - - ) - - case 'loading': - case 'ready': - default: - return undefined - } - }, [ - dataStatus, - cleanupInterval, - setCleanupInterval, - cleanupState, - runBatchedDeletion, - scheduleCleanup, - cancelDeletion, - grid.refetch, - ]) - const deletingCronJobIdRef = useRef(null) const { setValue: setCronJobForEditing, value: cronJobForEditing } = useQueryStateWithSelect({ @@ -184,8 +111,6 @@ export const CronjobsTab = () => { const xScroll = useRef(0) const handleScroll = (event: UIEvent) => { - if (!isQueryEnabled) return - const isScrollingHorizontally = xScroll.current !== event.currentTarget.scrollLeft xScroll.current = event.currentTarget.scrollLeft @@ -259,6 +184,7 @@ export const CronjobsTab = () => { onCreateJob={onOpenCreateJobSheet} /> + {grid.isMinimal && } { searchQuery={searchQuery} onScroll={handleScroll} onRowClick={handleRowClick} - overlay={gridOverlay} />
diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.useCleanupActions.ts b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.useCleanupActions.ts index 16ea05d0ada..9800370904a 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.useCleanupActions.ts +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.useCleanupActions.ts @@ -1,7 +1,3 @@ -import { useCallback, useRef, useState } from 'react' -import { toast } from 'sonner' - -import type { ConnectionVars } from '@/data/common.types' import { useExecuteSqlMutation } from 'data/sql/execute-sql-mutation' import { CTID_BATCH_PAGE_SIZE, @@ -9,10 +5,13 @@ import { getDeleteOldCronJobRunDetailsByCtidSql, getJobRunDetailsPageCountKey, getJobRunDetailsPageCountSql, - getScheduleDeleteCronJobRunDetailsKey, - getScheduleDeleteCronJobRunDetailsSql, } from 'data/sql/queries/delete-cron-job-run-details' +import { useCallback, useRef, useState } from 'react' +import { toast } from 'sonner' + import { CLEANUP_INTERVALS } from './CronJobsTab.constants' +import type { ConnectionVars } from '@/data/common.types' +import { useScheduleCronJobRunDetailsCleanupMutation } from '@/data/database-cron-jobs/schedule-clean-up-mutation' // Delay between batches to allow other queries to proceed (in milliseconds) const BATCH_DELAY_MS = 100 @@ -30,9 +29,6 @@ export type CleanupState = | { status: 'deleting'; progress: BatchDeletionProgress } | { status: 'delete-success'; totalRowsDeleted: number } | { status: 'delete-error'; error: string } - | { status: 'scheduling' } - | { status: 'schedule-success' } - | { status: 'schedule-error'; error: string } export const useCronJobsCleanupActions = ({ projectRef, @@ -48,6 +44,12 @@ export const useCronJobsCleanupActions = ({ onError: () => {}, // Error handled inline }) + const { + mutate: scheduleCronJobCleanup, + isPending: isScheduling, + isSuccess: isScheduleSuccess, + } = useScheduleCronJobRunDetailsCleanupMutation() + /** * Run batched deletion using ctid ranges. * This approach scans the table in page chunks to avoid: @@ -157,33 +159,24 @@ export const useCronJobsCleanupActions = ({ * This should only be called after a successful initial deletion. */ const scheduleCleanup = useCallback( - async (interval: string) => { + async ({ interval, onSuccess }: { interval: string; onSuccess?: () => void }) => { if (!projectRef) { console.error('[CronJobsTab > schedule cleanup] Project reference is required') toast.error('There was an error scheduling the cleanup. Please try again.') return } - try { - setCleanupState({ status: 'scheduling' }) - - await executeSql({ - projectRef, - connectionString, - sql: getScheduleDeleteCronJobRunDetailsSql(interval), - queryKey: getScheduleDeleteCronJobRunDetailsKey(projectRef, interval), - }) - - setCleanupState({ status: 'schedule-success' }) - toast.success('Scheduled daily cleanup job.') - } catch (error) { - console.error('[CronJobs] Failed to schedule cleanup with error: %O', error) - const errorMessage = error instanceof Error ? error.message : 'Unknown error' - setCleanupState({ status: 'schedule-error', error: errorMessage }) - toast.error('Scheduling the cleanup job failed. Please try again.') - } + scheduleCronJobCleanup( + { projectRef, connectionString, interval }, + { + onSuccess: () => { + toast.success('Scheduled daily cleanup job.') + onSuccess?.() + }, + } + ) }, - [projectRef, connectionString, executeSql] + [connectionString, projectRef, scheduleCronJobCleanup] ) /** @@ -196,8 +189,10 @@ export const useCronJobsCleanupActions = ({ return { cleanupInterval, - setCleanupInterval, cleanupState, + isScheduling, + isScheduleSuccess, + setCleanupInterval, runBatchedDeletion, scheduleCleanup, cancelDeletion, diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.useCronJobsData.ts b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.useCronJobsData.ts index b982135674f..1939b8895be 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.useCronJobsData.ts +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.useCronJobsData.ts @@ -1,16 +1,17 @@ import { keepPreviousData } from '@tanstack/react-query' -import { useMemo } from 'react' - -import type { ConnectionVars } from '@/data/common.types' -import { useCronJobRunDetailsEstimateQuery } from 'data/database-cron-jobs/database-cron-job-run-details-estimate-query' import { useCronJobsCountEstimateQuery } from 'data/database-cron-jobs/database-cron-jobs-count-estimate-query' import { useCronJobsCountQuery } from 'data/database-cron-jobs/database-cron-jobs-count-query' import { CronJob, useCronJobsInfiniteQuery, } from 'data/database-cron-jobs/database-cron-jobs-infinite-query' +import { useMemo } from 'react' import type { ResponseError } from 'types' -import { CRON_JOBS_THRESHOLD, JOB_RUN_DETAILS_THRESHOLD } from './CronJobsTab.constants' + +import { CRON_JOBS_THRESHOLD } from './CronJobsTab.constants' +import type { ConnectionVars } from '@/data/common.types' +import { useCronJobsMinimalInfiniteQuery } from '@/data/database-cron-jobs/database-cron-jobs-minimal-infinite-query' +import { COST_THRESHOLD_ERROR } from '@/data/sql/execute-sql-query' // ============================================================================= // Input types @@ -31,6 +32,7 @@ interface CronJobsGridState { isRefetching: boolean isFetchingNextPage: boolean hasNextPage: boolean + isMinimal: boolean refetch: () => void fetchNextPage: () => void } @@ -41,81 +43,16 @@ interface CronJobsCountState { isLoading: boolean } -// ============================================================================= -// Discriminated union for data fetching status -// ============================================================================= - -/** Still checking the size of the job_run_details table */ -interface StatusLoading { - status: 'loading' -} - -/** Failed to check the table size - show error with retry option */ -interface StatusEstimateError { - status: 'estimate-error' - error: Error - isRetrying: boolean - retry: () => void -} - -/** Table is confirmed to be too large - show cleanup notice */ -interface StatusOverflowConfirmed { - status: 'overflow-confirmed' - estimatedRows: number -} - -/** Query timed out - suspected large table, show cleanup notice */ -interface StatusOverflowSuspected { - status: 'overflow-suspected' - estimatedRows: number | undefined -} - -/** Normal state - queries can run */ -interface StatusReady { - status: 'ready' -} - -type CronJobsDataStatus = - | StatusLoading - | StatusEstimateError - | StatusOverflowConfirmed - | StatusOverflowSuspected - | StatusReady - // ============================================================================= // Result type // ============================================================================= interface UseCronJobsDataResult { - /** Discriminated union indicating the current data fetching status */ - dataStatus: CronJobsDataStatus - /** Whether queries are enabled (not gated due to large table or loading) */ - isQueryEnabled: boolean /** State for the cron jobs grid */ grid: CronJobsGridState /** State for the cron jobs count (exact or estimate) */ count: CronJobsCountState } - -// ============================================================================= -// Hook implementation -// ============================================================================= - -/** - * Custom hook that encapsulates all data fetching logic for the CronJobsTab. - * - * This hook manages the complex query dependencies: - * 1. First, estimate the size of the job_run_details table - * 2. If the table is small enough, fetch the actual cron jobs - * 3. If the table is too large, show estimates instead and display a cleanup notice - * - * The `dataStatus` discriminated union ensures type-safe handling of all states: - * - 'loading': Still checking table size - * - 'estimate-error': Failed to check, show retry option - * - 'overflow-confirmed': Table too large (from estimate) - * - 'overflow-suspected': Query timed out - * - 'ready': Normal operation - */ export function useCronJobsData({ projectRef, connectionString, @@ -123,37 +60,10 @@ export function useCronJobsData({ }: UseCronJobsDataParams): UseCronJobsDataResult { const isProjectReady = !!projectRef - const { - data: runDetailsEstimateValue, - error: runDetailsEstimateError, - isError: isRunDetailsEstimateError, - isPending: isRunDetailsEstimatePending, - isRefetching: isRefetchingRunDetailsEstimate, - refetch: refetchRunDetailsEstimate, - } = useCronJobRunDetailsEstimateQuery( - { projectRef, connectionString }, - { enabled: isProjectReady } - ) - - const hasLargeRunDetailsTable = - typeof runDetailsEstimateValue === 'number' && - runDetailsEstimateValue > JOB_RUN_DETAILS_THRESHOLD - - // Queries are enabled only when: - // - Project is ready - // - Estimate query has completed successfully - // - Table is not too large - const isQueryEnabled = - isProjectReady && - !isRunDetailsEstimatePending && - !isRunDetailsEstimateError && - !hasLargeRunDetailsTable - const { data: cronJobsData, error: cronJobsError, isLoading: isCronJobsLoading, - isError: isCronJobsError, isRefetching: isCronJobsRefetching, isFetchingNextPage, hasNextPage = false, @@ -164,14 +74,35 @@ export function useCronJobsData({ { placeholderData: Boolean(searchQuery) ? keepPreviousData : undefined, staleTime: Infinity, - enabled: isQueryEnabled, + } + ) + const useMinimalQuery = cronJobsError?.message === COST_THRESHOLD_ERROR + + const { + data: cronJobsMinimalData, + error: cronJobsMinimalError, + isLoading: isCronJobsMinimalLoading, + isRefetching: isCronJobsMinimalRefetching, + isFetchingNextPage: isFetchingNextPageMinimal, + hasNextPage: hasNextPageMinimal = false, + refetch: refetchCronJobsMinimal, + fetchNextPage: fetchNextPageMinimal, + } = useCronJobsMinimalInfiniteQuery( + { projectRef, connectionString, searchTerm: searchQuery ?? undefined }, + { + placeholderData: Boolean(searchQuery) ? keepPreviousData : undefined, + staleTime: Infinity, + enabled: useMinimalQuery, } ) - const cronJobs = useMemo( - () => cronJobsData?.pages.flatMap((page) => page) ?? [], - [cronJobsData?.pages] - ) + const cronJobs = useMemo(() => { + if (useMinimalQuery) { + return cronJobsMinimalData?.pages.flatMap((page) => page) ?? [] + } else { + return cronJobsData?.pages.flatMap((page) => page) ?? [] + } + }, [useMinimalQuery, cronJobsData?.pages, cronJobsMinimalData?.pages]) // Fetch count - gated on cron.job table size // Always fetch the estimate first (it's fast since it uses pg_stat) @@ -193,77 +124,18 @@ export function useCronJobsData({ { enabled: isCountQueryEnabled } ) - // Determine if we should show a timeout error as a suspected overflow - const isTimeoutError = - isCronJobsError && - typeof cronJobsError?.message === 'string' && - cronJobsError.message.toLowerCase().includes('timeout') - - // Compute the discriminated status - const dataStatus: CronJobsDataStatus = useMemo(() => { - if (!isProjectReady || isRunDetailsEstimatePending) { - return { status: 'loading' } - } - - if (isRunDetailsEstimateError) { - return { - status: 'estimate-error', - error: runDetailsEstimateError, - isRetrying: isRefetchingRunDetailsEstimate, - retry: refetchRunDetailsEstimate, - } - } - - if (hasLargeRunDetailsTable) { - return { - status: 'overflow-confirmed', - estimatedRows: runDetailsEstimateValue!, - } - } - - if (isTimeoutError) { - return { - status: 'overflow-suspected', - estimatedRows: runDetailsEstimateValue, - } - } - - return { status: 'ready' } - }, [ - isProjectReady, - isRunDetailsEstimatePending, - isRunDetailsEstimateError, - runDetailsEstimateError, - isRefetchingRunDetailsEstimate, - refetchRunDetailsEstimate, - hasLargeRunDetailsTable, - runDetailsEstimateValue, - isTimeoutError, - ]) - - // Compute derived grid state - const isGridLoading = - dataStatus.status === 'loading' || (dataStatus.status === 'ready' && isCronJobsLoading) - const gridError = - dataStatus.status === 'ready' && isCronJobsError && !isTimeoutError - ? cronJobsError ?? null - : null - return { - dataStatus, - isQueryEnabled, - grid: { rows: cronJobs, - isLoading: isGridLoading, - error: gridError, - isRefetching: isCronJobsRefetching, - isFetchingNextPage, - hasNextPage, - refetch: refetchCronJobs, - fetchNextPage, + error: useMinimalQuery ? cronJobsMinimalError : cronJobsError, + isLoading: useMinimalQuery ? isCronJobsMinimalLoading : isCronJobsLoading, + isRefetching: useMinimalQuery ? isCronJobsMinimalRefetching : isCronJobsRefetching, + isFetchingNextPage: useMinimalQuery ? isFetchingNextPageMinimal : isFetchingNextPage, + hasNextPage: useMinimalQuery ? hasNextPageMinimal : hasNextPage, + isMinimal: useMinimalQuery, + refetch: useMinimalQuery ? refetchCronJobsMinimal : refetchCronJobs, + fetchNextPage: useMinimalQuery ? fetchNextPageMinimal : fetchNextPage, }, - count: { value: isCountQueryEnabled ? exactCount : estimatedCount, isEstimate: !isCountQueryEnabled, @@ -271,9 +143,3 @@ export function useCronJobsData({ }, } } - -// ============================================================================= -// Type exports for consumers -// ============================================================================= - -export type { CronJobsCountState, CronJobsDataStatus, CronJobsGridState } diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CreditCodeRedemption.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CreditCodeRedemption.tsx index d34d78f8720..968e63bf7cc 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CreditCodeRedemption.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CreditCodeRedemption.tsx @@ -1,19 +1,15 @@ import HCaptcha from '@hcaptcha/react-hcaptcha' import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { Alert, AlertDescription, AlertTitle } from '@ui/components/shadcn/ui/alert' import { useFlag } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { AlertCircle, Calendar, PartyPopper } from 'lucide-react' +import { Calendar, PartyPopper } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { useEffect, useRef, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { - AlertDescription_Shadcn_, - AlertTitle_Shadcn_, - Alert_Shadcn_, Button, Dialog, DialogContent, @@ -24,19 +20,20 @@ import { DialogSectionSeparator, DialogTitle, DialogTrigger, - FormField_Shadcn_, Form_Shadcn_, + FormField_Shadcn_, Input_Shadcn_, Separator, } from 'ui' -import { ShimmeringLoader } from 'ui-patterns' +import { Admonition, ShimmeringLoader, TimestampInfo } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { z } from 'zod' +import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton' import { useOrganizationCreditCodeRedemptionMutation } from '@/data/organizations/organization-credit-code-redemption-mutation' import { useOrganizationCustomerProfileQuery } from '@/data/organizations/organization-customer-profile-query' import { useOrganizationQuery } from '@/data/organizations/organization-query' -import useLatest from '@/hooks/misc/useLatest' +import { useLatest } from '@/hooks/misc/useLatest' const FORM_ID = 'credit-code-redemption' @@ -51,59 +48,49 @@ export const CreditCodeRedemption = ({ modalVisible = false, onClose, }: { - slug: string | undefined + slug?: string modalVisible?: boolean onClose?: () => void }) => { + const router = useRouter() + const redeemCodeEnabled = useFlag('redeemCodeEnabled') + const [codeRedemptionModalVisible, setCodeRedemptionModalVisible] = useState( + modalVisible || false + ) + + const { data: org, isLoading: isOrgLoading } = useOrganizationQuery({ slug }) + const { data: customerProfile, isLoading: isCustomerProfileLoading } = + useOrganizationCustomerProfileQuery({ slug }) + const { can: canRedeemCode, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( PermissionAction.BILLING_WRITE, 'stripe.subscriptions', undefined, - { - organizationSlug: slug, - } + { organizationSlug: slug } ) - const redeemCodeEnabled = useFlag('redeemCodeEnabled') - - const { data: org, isLoading: isOrgLoading } = useOrganizationQuery({ slug }) - - const router = useRouter() + const captchaRef = useRef(null) + const captchaTokenRef = useRef(null) + const codeRedemptionDisabled = + !canRedeemCode || !isPermissionsLoaded || isOrgLoading || isCustomerProfileLoading const form = useForm({ resolver: zodResolver(FormSchema), - defaultValues: { - code: '', - }, + defaultValues: { code: '' }, }) - const { data: customerProfile, isLoading: isCustomerProfileLoading } = - useOrganizationCustomerProfileQuery({ slug }) - - useEffect(() => { - if (!router.isReady) return - - const queryCode = router.query.code - const codeFromParams = Array.isArray(queryCode) ? queryCode[0] : queryCode - - if (typeof codeFromParams === 'string' && codeFromParams.trim().length > 2) { - form.setValue('code', codeFromParams) - } - }, [router.isReady, router.query.code, form]) - - const [codeRedemptionModalVisible, setCodeRedemptionModalVisible] = useState( - modalVisible || false - ) - const captchaRef = useRef(null) - const captchaTokenRef = useRef(null) - const { - mutateAsync: redeemCode, + mutate: redeemCode, isPending: redeemingCode, error: errorRedeemingCode, data: codeRedemptionResult, reset: resetCodeRedemption, - } = useOrganizationCreditCodeRedemptionMutation() + } = useOrganizationCreditCodeRedemptionMutation({ + onSuccess: () => { + form.setValue('code', '') + resetCaptcha() + }, + }) const resetCaptcha = () => { captchaTokenRef.current = null @@ -128,28 +115,9 @@ export const CreditCodeRedemption = ({ } const initHcaptchaRef = useLatest(initHcaptcha) - useEffect(() => { - if (codeRedemptionModalVisible) { - initHcaptchaRef.current() - } - }, [codeRedemptionModalVisible, initHcaptchaRef]) - const onSubmit: SubmitHandler = async ({ code }) => { const token = await initHcaptcha() - - await redeemCode( - { - slug, - code, - hcaptchaToken: token, - }, - { - onSuccess: () => { - form.setValue('code', '') - resetCaptcha() - }, - } - ) + redeemCode({ slug, code, hcaptchaToken: token }) } const onCodeRedemptionDialogVisibilityChange = (visible: boolean) => { @@ -161,12 +129,24 @@ export const CreditCodeRedemption = ({ } } - if (!redeemCodeEnabled) { - return null - } + useEffect(() => { + if (!router.isReady) return - const codeRedemptionDisabled = - !canRedeemCode || !isPermissionsLoaded || isOrgLoading || isCustomerProfileLoading + const queryCode = router.query.code + const codeFromParams = Array.isArray(queryCode) ? queryCode[0] : queryCode + + if (typeof codeFromParams === 'string' && codeFromParams.trim().length > 2) { + form.setValue('code', codeFromParams) + } + }, [router.isReady, router.query.code, form]) + + useEffect(() => { + if (codeRedemptionModalVisible) { + initHcaptchaRef.current() + } + }, [codeRedemptionModalVisible, initHcaptchaRef]) + + if (!redeemCodeEnabled) return null return ( @@ -191,7 +171,7 @@ export const CreditCodeRedemption = ({ )} - e.preventDefault()} size={'large'}> + e.preventDefault()}> - {codeRedemptionResult ? ( + {!!codeRedemptionResult ? (
- +
-

Credits Redeemed!

+

Credits redeemed!

-

$

-

{codeRedemptionResult.amount_cents / 100}

-

credits applied

+

$

+

{codeRedemptionResult.amount_cents / 100}

+

credits applied

@@ -234,26 +214,29 @@ export const CreditCodeRedemption = ({
- {'Expires on '} - {new Date(codeRedemptionResult.credits_expire_at).toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - })} + Expires on{' '} +
)} - {org?.plan.id === 'free' && ( -
- -
- )} +
) : ( <> @@ -282,10 +265,10 @@ export const CreditCodeRedemption = ({ control={form.control} name="code" render={({ field }) => ( - + @@ -293,35 +276,34 @@ export const CreditCodeRedemption = ({ /> {customerProfile && customerProfile.balance < 0 && ( -
- Current Balance -
-

$

-

{customerProfile.balance / -100}

-

/credits

+
+ Current Balance +
+

$

+

{customerProfile.balance / -100}

+

/credits

)} - - - Potential future charges - - Credits are applied to {org?.name} only and can't be shared - or transferred to other organizations. Credits are automatically used toward - invoices. When credits run out on a paid plan, your default payment method - will be charged—your plan won't be downgraded automatically. - - + +

+ Credits are applied to {org?.name} only and cannot be + shared or transferred to other organizations. Credits are automatically used + toward invoices. +

+

+ When credits run out on a paid plan, your default payment method will be + charged—your plan won't be downgraded automatically. +

+
{errorRedeemingCode && ( - - - Code cannot be redeemed - - {errorRedeemingCode?.message} - - + )} diff --git a/apps/studio/components/interfaces/Organization/OrganizationCard.tsx b/apps/studio/components/interfaces/Organization/OrganizationCard.tsx index a1e8abe1232..3e081c81ac3 100644 --- a/apps/studio/components/interfaces/Organization/OrganizationCard.tsx +++ b/apps/studio/components/interfaces/Organization/OrganizationCard.tsx @@ -12,11 +12,13 @@ export const OrganizationCard = ({ href, isLink = true, className, + onClick, }: { organization: Organization href?: string isLink?: boolean className?: string + onClick?: () => void }) => { const isUserMFAEnabled = useIsMFAEnabled() const { data } = useOrgProjectsInfiniteQuery({ slug: organization.slug }) @@ -32,6 +34,7 @@ export const OrganizationCard = ({ )} icon={} title={organization.name} + onClick={onClick} description={
diff --git a/apps/studio/components/interfaces/Settings/Addons/Addons.tsx b/apps/studio/components/interfaces/Settings/Addons/Addons.tsx index ac705165304..68dca08580a 100644 --- a/apps/studio/components/interfaces/Settings/Addons/Addons.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/Addons.tsx @@ -1,10 +1,3 @@ -import dayjs from 'dayjs' -import { AlertCircle, ChevronRight, ExternalLink, Info } from 'lucide-react' -import { useTheme } from 'next-themes' -import Image from 'next/image' -import Link from 'next/link' -import { useMemo } from 'react' - import { SupportCategories } from '@supabase/shared-types/out/constants' import { useFlag, useParams } from 'common' import { @@ -14,7 +7,6 @@ import { import { NoticeBar } from 'components/interfaces/DiskManagement/ui/NoticeBar' import ProjectUpdateDisabledTooltip from 'components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip' import { SupportLink } from 'components/interfaces/Support/SupportLink' -import { useIsProjectActive } from 'components/layouts/ProjectLayout/ProjectContext' import { ScaffoldContainer, ScaffoldDivider, @@ -31,16 +23,27 @@ import { useProjectDetailQuery } from 'data/projects/project-detail-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import type { ProjectAddonVariantMeta } from 'data/subscriptions/types' +import dayjs from 'dayjs' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' -import { useIsOrioleDbInAws, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { + useIsOrioleDbInAws, + useIsProjectActive, + useSelectedProjectQuery, +} from 'hooks/misc/useSelectedProject' import { getCloudProviderArchitecture } from 'lib/cloudprovider-utils' import { BASE_PATH, DOCS_URL, INSTANCE_MICRO_SPECS, INSTANCE_NANO_SPECS } from 'lib/constants' import { getDatabaseMajorVersion, getSemanticVersion } from 'lib/helpers' +import { AlertCircle, ChevronRight, ExternalLink, Info } from 'lucide-react' +import { useTheme } from 'next-themes' +import Image from 'next/image' +import Link from 'next/link' +import { useMemo } from 'react' import { useAddonsPagePanel } from 'state/addons-page' -import { Alert, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' +import { Alert, Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Button } from 'ui' import { ComputeBadge } from 'ui-patterns/ComputeBadge' import { GenericSkeletonLoader, ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' + import CustomDomainSidePanel from './CustomDomainSidePanel' import IPv4SidePanel from './IPv4SidePanel' import PITRSidePanel from './PITRSidePanel' diff --git a/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx b/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx index 47cd9883c2b..0656bb21417 100644 --- a/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx +++ b/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx @@ -1,17 +1,15 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useEffect, useState } from 'react' -import { toast } from 'sonner' - import { useParams } from 'common' -import { useIsProjectActive } from 'components/layouts/ProjectLayout/ProjectContext' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { PasswordStrengthBar } from 'components/ui/PasswordStrengthBar' import { useDatabasePasswordResetMutation } from 'data/database/database-password-reset-mutation' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { useIsProjectActive, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { DEFAULT_MINIMUM_PASSWORD_STRENGTH } from 'lib/constants' import { passwordStrength, PasswordStrengthScore } from 'lib/password-strength' import { generateStrongPassword } from 'lib/project' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' import { Button, Card, CardContent, Input, Modal } from 'ui' import { FormLayout } from 'ui-patterns/form/Layout/FormLayout' diff --git a/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx b/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx index 010cd814c07..b689f02bd1d 100644 --- a/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx +++ b/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx @@ -1,17 +1,19 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { Pause } from 'lucide-react' -import { useRouter } from 'next/router' -import { useState } from 'react' -import { toast } from 'sonner' - -import { useIsProjectActive } from 'components/layouts/ProjectLayout/ProjectContext' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useSetProjectStatus } from 'data/projects/project-detail-query' import { useProjectPauseMutation } from 'data/projects/project-pause-mutation' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' -import { useIsAwsK8sCloudProvider, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { + useIsAwsK8sCloudProvider, + useIsProjectActive, + useSelectedProjectQuery, +} from 'hooks/misc/useSelectedProject' import { PROJECT_STATUS } from 'lib/constants' +import { Pause } from 'lucide-react' +import { useRouter } from 'next/router' +import { useState } from 'react' +import { toast } from 'sonner' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' const PauseProjectButton = () => { diff --git a/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx b/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx index b2a9580b975..ccd34795724 100644 --- a/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx +++ b/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx @@ -1,26 +1,28 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { ChevronDown } from 'lucide-react' -import { useRouter } from 'next/router' -import { useState } from 'react' -import { toast } from 'sonner' - import { useFlag } from 'common' -import { useIsProjectActive } from 'components/layouts/ProjectLayout/ProjectContext' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useSetProjectStatus } from 'data/projects/project-detail-query' import { useProjectRestartMutation } from 'data/projects/project-restart-mutation' import { useProjectRestartServicesMutation } from 'data/projects/project-restart-services-mutation' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' -import { useIsAwsK8sCloudProvider, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { + useIsAwsK8sCloudProvider, + useIsProjectActive, + useSelectedProjectQuery, +} from 'hooks/misc/useSelectedProject' import { PROJECT_STATUS } from 'lib/constants' +import { ChevronDown } from 'lucide-react' +import { useRouter } from 'next/router' +import { useState } from 'react' +import { toast } from 'sonner' import { Button, + cn, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, - cn, } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' diff --git a/apps/studio/components/interfaces/Storage/StorageSettings/CreateCredentialModal.tsx b/apps/studio/components/interfaces/Storage/StorageSettings/CreateCredentialModal.tsx index fd2855a3997..848faca105c 100644 --- a/apps/studio/components/interfaces/Storage/StorageSettings/CreateCredentialModal.tsx +++ b/apps/studio/components/interfaces/Storage/StorageSettings/CreateCredentialModal.tsx @@ -1,16 +1,14 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { Plus } from 'lucide-react' -import { useState } from 'react' -import { useForm } from 'react-hook-form' -import { z } from 'zod' - import { FormField } from '@ui/components/shadcn/ui/form' import { useParams } from 'common' -import { useIsProjectActive } from 'components/layouts/ProjectLayout/ProjectContext' import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query' import { useS3AccessKeyCreateMutation } from 'data/storage/s3-access-key-create-mutation' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useIsProjectActive } from 'hooks/misc/useSelectedProject' +import { Plus } from 'lucide-react' +import { useState } from 'react' +import { useForm } from 'react-hook-form' import { Button, Dialog, @@ -29,6 +27,7 @@ import { } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { z } from 'zod' interface CreateCredentialModalProps { visible: boolean diff --git a/apps/studio/components/interfaces/Storage/StorageSettings/S3Connection.tsx b/apps/studio/components/interfaces/Storage/StorageSettings/S3Connection.tsx index 4878a5878bd..afe09252182 100644 --- a/apps/studio/components/interfaces/Storage/StorageSettings/S3Connection.tsx +++ b/apps/studio/components/interfaces/Storage/StorageSettings/S3Connection.tsx @@ -1,14 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { AlertTitle } from '@ui/components/shadcn/ui/alert' -import Link from 'next/link' -import { useEffect, useState } from 'react' -import { SubmitHandler, useForm } from 'react-hook-form' -import { toast } from 'sonner' -import * as z from 'zod' - import { useParams } from 'common' -import { useIsProjectActive } from 'components/layouts/ProjectLayout/ProjectContext' import AlertError from 'components/ui/AlertError' import { DocsButton } from 'components/ui/DocsButton' import NoPermission from 'components/ui/NoPermission' @@ -17,19 +10,22 @@ import { useProjectStorageConfigQuery } from 'data/config/project-storage-config import { useProjectStorageConfigUpdateUpdateMutation } from 'data/config/project-storage-config-update-mutation' import { useStorageCredentialsQuery } from 'data/storage/s3-access-key-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { useIsProjectActive, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { DOCS_URL } from 'lib/constants' +import Link from 'next/link' +import { useEffect, useState } from 'react' +import { SubmitHandler, useForm } from 'react-hook-form' +import { toast } from 'sonner' import { - AlertDescription_Shadcn_, Alert_Shadcn_, + AlertDescription_Shadcn_, Button, Card, CardContent, CardFooter, + Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, - Form_Shadcn_, - Separator, Switch, Table, TableBody, @@ -52,6 +48,8 @@ import { PageSectionTitle, } from 'ui-patterns/PageSection' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' +import * as z from 'zod' + import { CreateCredentialModal } from './CreateCredentialModal' import { RevokeCredentialModal } from './RevokeCredentialModal' import { StorageCredItem } from './StorageCredItem' diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/AddRowOperationItem.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/AddRowOperationItem.tsx index 6dcd99ce3e0..0b2878cee77 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/AddRowOperationItem.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/AddRowOperationItem.tsx @@ -1,12 +1,13 @@ import { useQueryClient } from '@tanstack/react-query' +import { Plus, Undo2 } from 'lucide-react' import { tableRowKeys } from 'data/table-rows/keys' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { Plus, X } from 'lucide-react' import { useTableEditorStateSnapshot } from 'state/table-editor' -import { Button } from 'ui' import { formatOperationItemValue } from './OperationQueueSidePanel.utils' +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { AddRowPayload } from '@/state/table-editor-operation-queue.types' +import { Card, CardContent, CardHeader } from 'ui' interface AddRowOperationItemProps { operationId: string @@ -47,40 +48,52 @@ export const AddRowOperationItem = ({ } return ( -
-
+ +
- -
-
{fullTableName}
-
- New row +
+ + {fullTableName} + +
+ New row
-
+ -
+ {previewColumns.map(([key, value]) => ( -
- {key}: - +
+ + + {key}: + {formatOperationItemValue(value)}
))} {remainingCount > 0 && ( -
+{remainingCount} more column(s)
+
+ + + +{remainingCount} more column(s) +
)} -
-
+ + ) } diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/DeleteRowOperationItem.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/DeleteRowOperationItem.tsx index 8e641460788..56069c3b2c6 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/DeleteRowOperationItem.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/DeleteRowOperationItem.tsx @@ -1,12 +1,13 @@ import { useQueryClient } from '@tanstack/react-query' -import { Trash2, X } from 'lucide-react' -import { Button } from 'ui' - +import { Undo2 } from 'lucide-react' import { tableRowKeys } from 'data/table-rows/keys' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useTableEditorStateSnapshot } from 'state/table-editor' -import { DeleteRowPayload } from '@/state/table-editor-operation-queue.types' + import { formatOperationItemValue } from './OperationQueueSidePanel.utils' +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { DeleteRowPayload } from '@/state/table-editor-operation-queue.types' +import { Card, CardContent, CardHeader } from 'ui' interface DeleteRowOperationItemProps { operationId: string @@ -45,32 +46,40 @@ export const DeleteRowOperationItem = ({ } return ( -
-
+ +
- -
-
{fullTableName}
-
- Delete row - · - where {whereClause} +
+ + {fullTableName} + +
+ Delete row + · + where {whereClause}
-
+ -
-
Row will be deleted
-
-
+ +
Row will be deleted
+
+
) } diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationItem.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationItem.tsx index c9476493de6..4773099a270 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationItem.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationItem.tsx @@ -1,12 +1,13 @@ import { useQueryClient } from '@tanstack/react-query' +import { Undo2 } from 'lucide-react' import { tableRowKeys } from 'data/table-rows/keys' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { X } from 'lucide-react' import { useTableEditorStateSnapshot } from 'state/table-editor' import { formatOperationItemValue } from './OperationQueueSidePanel.utils' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { EditCellContentPayload } from '@/state/table-editor-operation-queue.types' +import { Card, CardContent, CardHeader } from 'ui' interface OperationItemProps { operationId: string @@ -44,42 +45,50 @@ export const OperationItem = ({ operationId, tableId, content }: OperationItemPr } return ( -
-
+ +
-
{fullTableName}
-
- {columnName} - • - where {whereClause} + + {fullTableName} + +
+ {columnName} + · + where {whereClause}
} + aria-label="Revert change" + className="px-1.5" + icon={} onClick={handleDelete} - className="shrink-0 w-7" - aria-label="Remove operation" - tooltip={{ content: { side: 'bottom', text: 'Remove operation' } }} + tooltip={{ + content: { + side: 'bottom', + align: 'end', + text: 'Revert change', + }, + }} /> -
+
-
-
- - - + +
+ - + {formattedOldValue}
-
- + - +
+ + + {formattedNewValue}
-
-
+ + ) } diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationList.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationList.tsx index 0468be02806..1c3dc3b7559 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationList.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationList.tsx @@ -15,7 +15,11 @@ interface OperationListProps { export const OperationList = ({ operations }: OperationListProps) => { if (operations.length === 0) { - return

No pending changes

+ return ( +
+

No pending changes

+
+ ) } const addOperations = operations.filter(isAddRowOperation) @@ -23,12 +27,13 @@ export const OperationList = ({ operations }: OperationListProps) => { const editOperations = operations.filter(isEditCellContentOperation) return ( -
+
{deleteOperations.length > 0 && (
-

- Rows to Delete ({deleteOperations.length}) +

+ {deleteOperations.length} row deletion{deleteOperations.length !== 1 ? 's' : ''}

+
{deleteOperations.map((op) => ( { {addOperations.length > 0 && (
-

- Rows to Add ({addOperations.length}) +

+ {addOperations.length} row addition{addOperations.length !== 1 ? 's' : ''}

{addOperations.map((op) => ( @@ -62,9 +67,10 @@ export const OperationList = ({ operations }: OperationListProps) => { {editOperations.length > 0 && (
-

- Cell Edits ({editOperations.length}) +

+ {editOperations.length} cell edit{editOperations.length !== 1 ? 's' : ''}

+
{editOperations.map((op) => ( event.preventDefault()} // Prevent focus on first focussable element since it is the revert changes ButtonTooltip header={
Pending changes - + {operations.length} operation{operations.length !== 1 ? 's' : ''}
@@ -41,25 +42,29 @@ export const OperationQueueSidePanel = ({ visible, closePanel }: OperationQueueS
-
} > - + diff --git a/apps/studio/components/layouts/AccessTokens/AccessTokensLayout.tsx b/apps/studio/components/layouts/AccessTokens/AccessTokensLayout.tsx new file mode 100644 index 00000000000..43840b62216 --- /dev/null +++ b/apps/studio/components/layouts/AccessTokens/AccessTokensLayout.tsx @@ -0,0 +1,72 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' +import { PropsWithChildren } from 'react' + +import { useFlag } from 'common' +import { NavMenu, NavMenuItem } from 'ui' +import { PageContainer } from 'ui-patterns/PageContainer' +import { + PageHeader, + PageHeaderDescription, + PageHeaderMeta, + PageHeaderNavigationTabs, + PageHeaderSummary, + PageHeaderTitle, +} from 'ui-patterns/PageHeader' + +const AccessTokensLayout = ({ children }: PropsWithChildren) => { + const router = useRouter() + const scopedTokensEnabled = useFlag('scopedPAT') + + const navigationItems = [ + { + label: 'Classic Tokens', + href: `/account/tokens`, + id: 'classic-tokens', + }, + ...(scopedTokensEnabled + ? [ + { + label: 'Scoped Tokens', + href: `/account/tokens/scoped`, + id: 'scoped-tokens', + }, + ] + : []), + ] + + const title = 'Access Tokens' + const description = 'Create and manage access tokens for API authentication.' + + return ( + <> + + + + {title} + {description} + + + {navigationItems.length > 0 && ( + + + {navigationItems.map((item) => { + const isActive = router.asPath.split('?')[0] === item.href + return ( + + {item.label} + + ) + })} + + + )} + + + {children} + + + ) +} + +export default AccessTokensLayout diff --git a/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx b/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx index 33b83ecf4fa..84640554271 100644 --- a/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx +++ b/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx @@ -71,7 +71,8 @@ const AccountLayout = ({ children, title }: PropsWithChildren { - const pathname = usePathname() - const links = [ - { - isActive: pathname === `/account/me`, - label: 'Preferences', - href: `/account/me`, - key: `/account/me`, - }, - { - isActive: pathname === `/account/tokens`, - label: 'Access Tokens', - href: `/account/tokens`, - key: `/account/tokens`, - }, - - { - isActive: pathname === `/account/security`, - label: 'Security', - href: `/account/security`, - key: `/account/security`, - }, - ] - - return ( - - -
{children}
-
- ) -} diff --git a/apps/studio/components/layouts/AppLayout/IncidentBanner.tsx b/apps/studio/components/layouts/AppLayout/IncidentBanner.tsx deleted file mode 100644 index 5f5830ef2e1..00000000000 --- a/apps/studio/components/layouts/AppLayout/IncidentBanner.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { HeaderBanner } from 'components/interfaces/Organization/HeaderBanner' -import { InlineLink } from 'components/ui/InlineLink' - -/** - * Used to display ongoing incidents - */ -export const IncidentBanner = () => { - return ( - - Follow the status page for - updates - - } - /> - ) -} diff --git a/apps/studio/components/layouts/AppLayout/MaintenanceBanner.tsx b/apps/studio/components/layouts/AppLayout/MaintenanceBanner.tsx deleted file mode 100644 index 4c5610b36b6..00000000000 --- a/apps/studio/components/layouts/AppLayout/MaintenanceBanner.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { LOCAL_STORAGE_KEYS } from 'common' -import { HeaderBanner } from 'components/interfaces/Organization/HeaderBanner' -import { InlineLink } from 'components/ui/InlineLink' - -import { useIncidentStatusQuery } from '@/data/platform/incident-status-query' -import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' - -/** - * Used to display ongoing maintenance - */ -export function MaintenanceBanner() { - const { data: allStatusPageEvents } = useIncidentStatusQuery() - const { maintenanceEvents = [] } = allStatusPageEvents ?? {} - const currentEventId = maintenanceEvents[0]?.id ?? '' - - const [dismissed, setDismissed] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.MAINTENANCE_BANNER_DISMISSED(currentEventId), - false - ) - - if (dismissed) return null - - return ( - - Follow the status page for - updates - - } - onDismiss={() => setDismissed(true)} - /> - ) -} diff --git a/apps/studio/components/layouts/AppLayout/StatusPageBanner.tsx b/apps/studio/components/layouts/AppLayout/StatusPageBanner.tsx new file mode 100644 index 00000000000..b5ffe723f8c --- /dev/null +++ b/apps/studio/components/layouts/AppLayout/StatusPageBanner.tsx @@ -0,0 +1,68 @@ +import { LOCAL_STORAGE_KEYS, useFlag } from 'common' +import { HeaderBanner } from 'components/interfaces/Organization/HeaderBanner' +import { InlineLink } from 'components/ui/InlineLink' + +import { useIncidentStatusQuery } from '@/data/platform/incident-status-query' +import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' + +const BANNER_DESCRIPTION = ( + <> + Follow the status page for updates + +) + +/** + * Used to display ongoing incidents or maintenances + */ +export const StatusPageBanner = () => { + const { data: allStatusPageEvents } = useIncidentStatusQuery() + const { incidents = [], maintenanceEvents = [] } = allStatusPageEvents ?? {} + + // Only show incident banner for incidents with real impact (not "none") + const highImpactIncident = incidents.find((incident) => incident.impact !== 'none') + const incidentEventId = highImpactIncident?.id ?? '' + + const showIncidentBannerOverride = + useFlag('ongoingIncident') || process.env.NEXT_PUBLIC_ONGOING_INCIDENT === 'true' + + const ongoingMaintenance = maintenanceEvents.length > 0 + const maintenanceEventId = maintenanceEvents[0]?.id ?? '' + + const [dismissedIncident, setDismissedIncident] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.INCIDENT_BANNER_DISMISSED(incidentEventId), + false + ) + + const [dismissedMaintenance, setDismissedMaintenance] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.MAINTENANCE_BANNER_DISMISSED(maintenanceEventId), + false + ) + + if (showIncidentBannerOverride || (highImpactIncident && !dismissedIncident)) { + return ( + setDismissedIncident(true) + } + /> + ) + } + + if (ongoingMaintenance && !dismissedMaintenance) { + return ( + setDismissedMaintenance(true)} + /> + ) + } + + return null +} diff --git a/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx b/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx index e78f9cefaad..895917be4f9 100644 --- a/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx +++ b/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx @@ -119,16 +119,16 @@ const EdgeFunctionDetailsLayout = ({ label: 'Logs', href: `/project/${ref}/functions/${functionSlug}/logs`, }, - { - label: 'Code', - href: `/project/${ref}/functions/${functionSlug}/code`, - }, ] : []), { label: 'Details', href: `/project/${ref}/functions/${functionSlug}/details`, }, + { + label: 'Code', + href: `/project/${ref}/functions/${functionSlug}/code`, + }, ] : [] @@ -264,15 +264,15 @@ const EdgeFunctionDetailsLayout = ({ /> )} - {IS_PLATFORM && ( - <> - - - - - + + + + + + {IS_PLATFORM && ( + <>

Download via CLI

-
- -
-
-
+ + )} +
+ +
+
+
+ {IS_PLATFORM && ( + <> {!!functionSlug && ( + {IS_PLATFORM && ( + + )}
} isEditing={isEditing} onEditSubmit={(value) => { - if (originalId !== null) { + if (IS_PLATFORM && originalId !== null) { handleFileNameChange(originalId, value) } }} @@ -424,7 +427,7 @@ export const FileExplorerAndEditor = ({ } }} onDoubleClick={() => { - if (originalId !== null) { + if (IS_PLATFORM && originalId !== null) { handleStartRename(originalId) } }} @@ -445,36 +448,38 @@ export const FileExplorerAndEditor = ({ />
- e.stopPropagation()}> - { - if (originalId !== null) handleStartRename(originalId) - }} - onFocusCapture={(e) => e.stopPropagation()} - > - - Rename file - + {IS_PLATFORM && ( + e.stopPropagation()}> + { + if (originalId !== null) handleStartRename(originalId) + }} + onFocusCapture={(e) => e.stopPropagation()} + > + + Rename file + - {files.length > 1 && ( - <> - - { - if (originalId !== null) { - handleFileDelete(originalId) - } - }} - onFocusCapture={(e) => e.stopPropagation()} - > - - Delete file - - - )} - + {files.length > 1 && ( + <> + + { + if (originalId !== null) { + handleFileDelete(originalId) + } + }} + onFocusCapture={(e) => e.stopPropagation()} + > + + Delete file + + + )} + + )} ) }} @@ -509,6 +514,7 @@ export const FileExplorerAndEditor = ({ padding: { top: 20, bottom: 20 }, lineNumbersMinChars: 3, fixedOverflowWidgets: true, + readOnly: !IS_PLATFORM, }} /> )} diff --git a/apps/studio/components/ui/RequestUpgradeToBillingOwners.tsx b/apps/studio/components/ui/RequestUpgradeToBillingOwners.tsx index 32e302cc630..5b4bf6735d5 100644 --- a/apps/studio/components/ui/RequestUpgradeToBillingOwners.tsx +++ b/apps/studio/components/ui/RequestUpgradeToBillingOwners.tsx @@ -1,9 +1,4 @@ import { zodResolver } from '@hookform/resolvers/zod' -import { PropsWithChildren, useState } from 'react' -import { SubmitHandler, useForm } from 'react-hook-form' -import { toast } from 'sonner' -import z from 'zod' - import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query' import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query' import { @@ -13,6 +8,9 @@ import { import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useTrack } from 'lib/telemetry/track' +import { PropsWithChildren, useState } from 'react' +import { SubmitHandler, useForm } from 'react-hook-form' +import { toast } from 'sonner' import { Badge, Button, @@ -34,6 +32,7 @@ import { TooltipTrigger, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import z from 'zod' const FormSchema = z.object({ note: z.string().optional(), diff --git a/apps/studio/components/ui/UpgradePlanButton.tsx b/apps/studio/components/ui/UpgradePlanButton.tsx index 7c9851d2b5e..186feac161e 100644 --- a/apps/studio/components/ui/UpgradePlanButton.tsx +++ b/apps/studio/components/ui/UpgradePlanButton.tsx @@ -1,13 +1,13 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import Link from 'next/link' -import { PropsWithChildren } from 'react' - import { useFlag, useParams } from 'common' import { SupportLink } from 'components/interfaces/Support/SupportLink' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import Link from 'next/link' +import { PropsWithChildren } from 'react' import { Button } from 'ui' + import { ButtonTooltip } from './ButtonTooltip' import { RequestUpgradeToBillingOwners } from './RequestUpgradeToBillingOwners' @@ -24,6 +24,7 @@ interface UpgradePlanButtonProps { featureProposition?: string disabled?: boolean className?: string + slug?: string } /** @@ -40,18 +41,21 @@ export const UpgradePlanButton = ({ disabled, children, className, + slug: slugParam, }: PropsWithChildren) => { const { ref } = useParams() const { data: organization } = useSelectedOrganizationQuery() const isFreePlan = organization?.plan?.id === 'free' - const slug = organization?.slug ?? '_' + const slug = slugParam ?? organization?.slug ?? '_' const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate') const { billingAll } = useIsFeatureEnabled(['billing:all']) const { can: canUpdateSubscription } = useAsyncCheckPermissions( PermissionAction.BILLING_WRITE, - 'stripe.subscriptions' + 'stripe.subscriptions', + undefined, + { organizationSlug: slug } ) const subject = `Enquiry to upgrade ${!!plan ? `to ${plan} ` : ''}plan for organization` diff --git a/apps/studio/data/access-tokens/keys.ts b/apps/studio/data/access-tokens/keys.ts index 8d5e3a8f3da..e000d4a01bc 100644 --- a/apps/studio/data/access-tokens/keys.ts +++ b/apps/studio/data/access-tokens/keys.ts @@ -1,3 +1,4 @@ export const accessTokenKeys = { list: () => ['access-tokens'] as const, + detail: (id: string) => ['access-tokens', id] as const, } diff --git a/apps/studio/data/database-cron-jobs/database-cron-job-run-details-estimate-query.ts b/apps/studio/data/database-cron-jobs/database-cron-job-run-details-estimate-query.ts deleted file mode 100644 index 89b40815049..00000000000 --- a/apps/studio/data/database-cron-jobs/database-cron-job-run-details-estimate-query.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { QueryClient, useQuery } from '@tanstack/react-query' - -import type { ConnectionVars } from 'data/common.types' -import { executeSql } from 'data/sql/execute-sql-query' -import { - getLiveTupleEstimate, - getLiveTupleEstimateKey, -} from 'data/sql/queries/get-live-tuple-stats' -import type { UseCustomQueryOptions } from 'types' - -type DatabaseCronJobRunDetailsEstimateVariables = ConnectionVars - -const cronJobRunDetailsEstimateSql = getLiveTupleEstimate('job_run_details', 'cron') -const cronJobRunDetailsEstimateKey = (projectRef: string | undefined) => - getLiveTupleEstimateKey(projectRef, 'job_run_details', 'cron') - -export async function getCronJobRunDetailsEstimate({ - projectRef, - connectionString, -}: DatabaseCronJobRunDetailsEstimateVariables) { - if (!projectRef) throw new Error('Project ref is required') - - const { result } = await executeSql<{ live_tuple_estimate: number }[]>({ - projectRef, - connectionString, - sql: cronJobRunDetailsEstimateSql, - queryKey: cronJobRunDetailsEstimateKey(projectRef), - }) - - return result?.[0]?.live_tuple_estimate -} - -export type DatabaseCronJobRunDetailsEstimateData = Awaited< - ReturnType -> -export type DatabaseCronJobRunDetailsEstimateError = Error - -export const useCronJobRunDetailsEstimateQuery = ( - { projectRef, connectionString }: DatabaseCronJobRunDetailsEstimateVariables, - { - enabled = true, - ...options - }: UseCustomQueryOptions< - DatabaseCronJobRunDetailsEstimateData, - DatabaseCronJobRunDetailsEstimateError, - TData - > = {} -) => - useQuery({ - queryKey: cronJobRunDetailsEstimateKey(projectRef), - queryFn: () => getCronJobRunDetailsEstimate({ projectRef, connectionString }), - enabled: enabled && projectRef !== undefined, - staleTime: 5 * 60 * 1000, - ...options, - }) - -export function prefetchCronJobRunDetailsEstimate( - client: QueryClient, - { projectRef, connectionString }: DatabaseCronJobRunDetailsEstimateVariables -) { - return client.fetchQuery({ - // Does not change if connection string changes - // eslint-disable-next-line @tanstack/query/exhaustive-deps - queryKey: cronJobRunDetailsEstimateKey(projectRef), - queryFn: () => getCronJobRunDetailsEstimate({ projectRef, connectionString }), - }) -} diff --git a/apps/studio/data/database-cron-jobs/database-cron-jobs-infinite-query.ts b/apps/studio/data/database-cron-jobs/database-cron-jobs-infinite-query.ts index fb8d737f4bf..84cd0ee9c33 100644 --- a/apps/studio/data/database-cron-jobs/database-cron-jobs-infinite-query.ts +++ b/apps/studio/data/database-cron-jobs/database-cron-jobs-infinite-query.ts @@ -1,12 +1,13 @@ import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query' - -import { executeSql } from 'data/sql/execute-sql-query' +import { COST_THRESHOLD_ERROR, executeSql } from 'data/sql/execute-sql-query' import type { ResponseError, UseCustomInfiniteQueryOptions } from 'types' + +import { getCronJobsSql } from '../sql/queries/get-cron-jobs' import { databaseCronJobsKeys } from './keys' -const CRON_JOBS_PAGE_LIMIT = 20 +export const CRON_JOBS_PAGE_LIMIT = 20 -type DatabaseCronJobRunsVariables = { +export type DatabaseCronJobRunsVariables = { projectRef?: string connectionString?: string | null searchTerm?: string @@ -17,50 +18,11 @@ export type CronJob = { jobname: string | null active: boolean command: string - latest_run: string schedule: string - status: string + latest_run?: string + status?: string } -// [Joshen] Just to call out that I had AI help me with this, so please let me know if this can be optimized -const getCronJobSql = ({ searchTerm, page }: { searchTerm?: string; page: number }) => - ` -WITH latest_runs AS ( - SELECT - jobid, - status, - MAX(start_time) AS latest_run - FROM cron.job_run_details - GROUP BY jobid, status -), most_recent_runs AS ( - SELECT - jobid, - status, - latest_run - FROM latest_runs lr1 - WHERE latest_run = ( - SELECT MAX(latest_run) - FROM latest_runs lr2 - WHERE lr2.jobid = lr1.jobid - ) -) -SELECT - job.jobid, - job.jobname, - job.schedule, - job.command, - job.active, - mr.latest_run, - mr.status -FROM - cron.job job -LEFT JOIN most_recent_runs mr ON job.jobid = mr.jobid -${!!searchTerm ? `WHERE job.jobname ILIKE '%${searchTerm}%'` : ''} -ORDER BY job.jobid -LIMIT ${CRON_JOBS_PAGE_LIMIT} -OFFSET ${page * CRON_JOBS_PAGE_LIMIT}; -`.trim() - export async function getDatabaseCronJobs({ projectRef, connectionString, @@ -72,8 +34,9 @@ export async function getDatabaseCronJobs({ const { result } = await executeSql({ projectRef, connectionString, - sql: getCronJobSql({ searchTerm, page }), + sql: getCronJobsSql({ searchTerm, page, limit: CRON_JOBS_PAGE_LIMIT }), queryKey: ['cron-jobs'], + preflightCheck: true, }) return result @@ -114,5 +77,9 @@ export const useCronJobsInfiniteQuery = ( if (!hasNextPage) return undefined return page }, + retry: (failureCount, error) => { + if (error.message === COST_THRESHOLD_ERROR) return false + return failureCount < 3 + }, ...options, }) diff --git a/apps/studio/data/database-cron-jobs/database-cron-jobs-minimal-infinite-query.ts b/apps/studio/data/database-cron-jobs/database-cron-jobs-minimal-infinite-query.ts new file mode 100644 index 00000000000..26106ae581a --- /dev/null +++ b/apps/studio/data/database-cron-jobs/database-cron-jobs-minimal-infinite-query.ts @@ -0,0 +1,67 @@ +import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query' +import { executeSql } from 'data/sql/execute-sql-query' +import type { ResponseError, UseCustomInfiniteQueryOptions } from 'types' + +import { getCronJobsMinimalSql } from '../sql/queries/get-cron-jobs' +import { + CRON_JOBS_PAGE_LIMIT, + CronJob, + DatabaseCronJobRunsVariables, +} from './database-cron-jobs-infinite-query' +import { databaseCronJobsKeys } from './keys' + +export async function getDatabaseCronJobsMinimal({ + projectRef, + connectionString, + searchTerm, + page = 0, +}: DatabaseCronJobRunsVariables & { page: number }) { + if (!projectRef) throw new Error('Project ref is required') + + const { result } = await executeSql({ + projectRef, + connectionString, + sql: getCronJobsMinimalSql({ searchTerm, page, limit: CRON_JOBS_PAGE_LIMIT }), + queryKey: ['cron-jobs-minimal'], + }) + + return result +} + +type DatabaseCronJobsMinimalInfiniteData = CronJob[] +type DatabaseCronJobsMinimalInfiniteError = ResponseError + +export const useCronJobsMinimalInfiniteQuery = ( + { projectRef, connectionString, searchTerm }: DatabaseCronJobRunsVariables, + { + enabled = true, + ...options + }: UseCustomInfiniteQueryOptions< + DatabaseCronJobsMinimalInfiniteData, + DatabaseCronJobsMinimalInfiniteError, + InfiniteData, + readonly unknown[], + number + > = {} +) => + useInfiniteQuery({ + queryKey: databaseCronJobsKeys.listInfiniteMinimal(projectRef, searchTerm), + queryFn: ({ pageParam }) => { + return getDatabaseCronJobsMinimal({ + projectRef, + connectionString, + searchTerm, + page: pageParam, + }) + }, + staleTime: 0, + enabled: enabled && typeof projectRef !== 'undefined', + initialPageParam: 0, + getNextPageParam(lastPage, pages) { + const page = pages.length + const hasNextPage = lastPage.length >= CRON_JOBS_PAGE_LIMIT + if (!hasNextPage) return undefined + return page + }, + ...options, + }) diff --git a/apps/studio/data/database-cron-jobs/keys.ts b/apps/studio/data/database-cron-jobs/keys.ts index 6a6505fb085..964542f2c35 100644 --- a/apps/studio/data/database-cron-jobs/keys.ts +++ b/apps/studio/data/database-cron-jobs/keys.ts @@ -6,6 +6,8 @@ export const databaseCronJobsKeys = { ['projects', projectRef, 'cron-jobs', identifier] as const, listInfinite: (projectRef: string | undefined, searchTerm: string | undefined) => ['projects', projectRef, 'cron-jobs', { searchTerm }] as const, + listInfiniteMinimal: (projectRef: string | undefined, searchTerm: string | undefined) => + ['projects', projectRef, 'cron-jobs-mininal', { searchTerm }] as const, count: (projectRef: string | undefined) => ['projects', projectRef, 'cron-jobs', 'count'] as const, run: (projectRef: string | undefined, jobId: number) => [ diff --git a/apps/studio/data/database-cron-jobs/schedule-clean-up-mutation.ts b/apps/studio/data/database-cron-jobs/schedule-clean-up-mutation.ts new file mode 100644 index 00000000000..7923c247524 --- /dev/null +++ b/apps/studio/data/database-cron-jobs/schedule-clean-up-mutation.ts @@ -0,0 +1,66 @@ +import { useMutation } from '@tanstack/react-query' +import { executeSql } from 'data/sql/execute-sql-query' +import { toast } from 'sonner' +import type { ResponseError, UseCustomMutationOptions } from 'types' + +import { + getScheduleDeleteCronJobRunDetailsKey, + getScheduleDeleteCronJobRunDetailsSql, +} from '../sql/queries/delete-cron-job-run-details' + +export type ScheduleCronJobRunDetailsCleanupVariables = { + projectRef: string + connectionString?: string | null + interval: string +} + +export async function scheduleCronJobRunDetailsCleanup({ + projectRef, + connectionString, + interval, +}: ScheduleCronJobRunDetailsCleanupVariables) { + const { result } = await executeSql({ + projectRef, + connectionString, + sql: getScheduleDeleteCronJobRunDetailsSql(interval), + queryKey: getScheduleDeleteCronJobRunDetailsKey(projectRef, interval), + }) + + return result +} + +type ScheduleCronJobRunDetailsCleanupData = Awaited< + ReturnType +> + +export const useScheduleCronJobRunDetailsCleanupMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions< + ScheduleCronJobRunDetailsCleanupData, + ResponseError, + ScheduleCronJobRunDetailsCleanupVariables + >, + 'mutationFn' +> = {}) => { + return useMutation< + ScheduleCronJobRunDetailsCleanupData, + ResponseError, + ScheduleCronJobRunDetailsCleanupVariables + >({ + mutationFn: (vars) => scheduleCronJobRunDetailsCleanup(vars), + async onSuccess(data, variables, context) { + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to schedule clean up job: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/edge-functions/edge-function-body-query.ts b/apps/studio/data/edge-functions/edge-function-body-query.ts index 772a91ca6f2..8ee38e9a930 100644 --- a/apps/studio/data/edge-functions/edge-function-body-query.ts +++ b/apps/studio/data/edge-functions/edge-function-body-query.ts @@ -72,7 +72,6 @@ export const useEdgeFunctionBodyQuery = ( useQuery({ queryKey: edgeFunctionsKeys.body(projectRef, slug), queryFn: ({ signal }) => getEdgeFunctionBody({ projectRef, slug }, signal), - enabled: - IS_PLATFORM && enabled && typeof projectRef !== 'undefined' && typeof slug !== 'undefined', + enabled: enabled && typeof projectRef !== 'undefined' && typeof slug !== 'undefined', ...options, }) diff --git a/apps/studio/data/organizations/organization-credit-code-redemption-mutation.ts b/apps/studio/data/organizations/organization-credit-code-redemption-mutation.ts index d8ece7ba77c..daf36dd9e8b 100644 --- a/apps/studio/data/organizations/organization-credit-code-redemption-mutation.ts +++ b/apps/studio/data/organizations/organization-credit-code-redemption-mutation.ts @@ -60,10 +60,13 @@ export const useOrganizationCreditCodeRedemptionMutation = ({ return useMutation({ mutationFn: (vars) => redeemCode(vars), async onSuccess(data, variables, context) { - const { slug, code } = variables + const { slug } = variables + + await Promise.all([ + queryClient.invalidateQueries({ queryKey: organizationKeys.customerProfile(slug) }), + queryClient.invalidateQueries({ queryKey: subscriptionKeys.orgSubscription(slug) }), + ]) - await queryClient.invalidateQueries({ queryKey: organizationKeys.customerProfile(slug) }) - await queryClient.invalidateQueries({ queryKey: subscriptionKeys.orgSubscription(slug) }) await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { diff --git a/apps/studio/data/platform/incident-status-query.ts b/apps/studio/data/platform/incident-status-query.ts index a6db924e096..7c767fc1cb5 100644 --- a/apps/studio/data/platform/incident-status-query.ts +++ b/apps/studio/data/platform/incident-status-query.ts @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query' import type { IncidentInfo } from 'lib/api/incident-status' -import { BASE_PATH, IS_PLATFORM } from 'lib/constants' +import { BASE_PATH, IS_PLATFORM, IS_TEST_ENV } from 'lib/constants' import { partition } from 'lodash' import { UseCustomQueryOptions } from 'types' @@ -42,5 +42,6 @@ export const useIncidentStatusQuery = ( queryFn: ({ signal }) => getIncidentStatus(signal), staleTime: 1000 * 60 * 5, // 5 minutes to match API cache ...options, - enabled: IS_PLATFORM && (options.enabled ?? true), + // Enable in platform mode, or in test environment for E2E testing + enabled: (IS_PLATFORM || IS_TEST_ENV) && (options.enabled ?? true), }) diff --git a/apps/studio/data/scoped-access-tokens/keys.ts b/apps/studio/data/scoped-access-tokens/keys.ts new file mode 100644 index 00000000000..f3dc146c2bf --- /dev/null +++ b/apps/studio/data/scoped-access-tokens/keys.ts @@ -0,0 +1,4 @@ +export const scopedAccessTokenKeys = { + list: () => ['scoped-access-tokens'] as const, + detail: (id: string) => ['scoped-access-tokens', id] as const, +} diff --git a/apps/studio/data/scoped-access-tokens/scoped-access-token-create-mutation.ts b/apps/studio/data/scoped-access-tokens/scoped-access-token-create-mutation.ts new file mode 100644 index 00000000000..3c02d2a794a --- /dev/null +++ b/apps/studio/data/scoped-access-tokens/scoped-access-token-create-mutation.ts @@ -0,0 +1,55 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { components } from 'api-types' +import { handleError, post } from 'data/fetchers' +import type { ResponseError, UseCustomMutationOptions } from 'types' +import { scopedAccessTokenKeys } from './keys' + +export type ScopedAccessTokenCreateVariables = components['schemas']['CreateScopedAccessTokenBody'] + +export async function createScopedAccessToken({ + name, + expires_at, + organization_slugs, + permissions, + project_refs, +}: ScopedAccessTokenCreateVariables) { + const { data, error } = await post('/platform/profile/scoped-access-tokens', { + body: { name, organization_slugs, expires_at, permissions, project_refs }, + }) + + if (error) handleError(error) + + return data +} + +export type NewScopedAccessToken = components['schemas']['CreateScopedAccessTokenResponse'] + +export const useAccessTokenCreateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (vars) => createScopedAccessToken(vars), + async onSuccess(data, variables, context) { + await queryClient.invalidateQueries({ queryKey: scopedAccessTokenKeys.list() }) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to create access token: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/scoped-access-tokens/scoped-access-token-query.ts b/apps/studio/data/scoped-access-tokens/scoped-access-token-query.ts new file mode 100644 index 00000000000..849b3812e93 --- /dev/null +++ b/apps/studio/data/scoped-access-tokens/scoped-access-token-query.ts @@ -0,0 +1,60 @@ +import { useQuery } from '@tanstack/react-query' + +import { components } from 'api-types' +import { get, handleError } from 'data/fetchers' +import type { ResponseError, UseCustomQueryOptions } from 'types' +import { scopedAccessTokenKeys } from './keys' + +export async function getScopedAccessTokens(signal?: AbortSignal) { + const { data, error } = await get('/platform/profile/scoped-access-tokens', { signal }) + + if (error) handleError(error) + + return data +} + +export async function getScopedAccessToken({ id }: { id: string }, signal?: AbortSignal) { + if (!id) throw new Error('Token ID is required') + + const { data, error } = await get('/platform/profile/scoped-access-tokens/{id}', { + params: { path: { id } }, + signal, + }) + + if (error) handleError(error) + + return data +} + +export type ScopedAccessTokensData = Awaited> +export type ScopedAccessTokensError = ResponseError + +export type ScopedAccessToken = + components['schemas']['GetScopedAccessTokensResponse']['tokens'][number] + +export type ScopedAccessTokenData = Awaited> + +export const useScopedAccessTokensQuery = ({ + enabled = true, + ...options +}: UseCustomQueryOptions = {}) => + useQuery({ + queryKey: scopedAccessTokenKeys.list(), + queryFn: ({ signal }) => getScopedAccessTokens(signal), + enabled, + ...options, + }) + +export const useScopedAccessTokenQuery = ( + { id }: { id: string }, + { + enabled = true, + ...options + }: UseCustomQueryOptions = {} +) => + useQuery({ + queryKey: scopedAccessTokenKeys.detail(id), + queryFn: ({ signal }) => getScopedAccessToken({ id }, signal), + enabled: enabled && typeof id !== 'undefined', + ...options, + }) diff --git a/apps/studio/data/scoped-access-tokens/scoped-access-tokens-delete-mutation.ts b/apps/studio/data/scoped-access-tokens/scoped-access-tokens-delete-mutation.ts new file mode 100644 index 00000000000..701991ad40c --- /dev/null +++ b/apps/studio/data/scoped-access-tokens/scoped-access-tokens-delete-mutation.ts @@ -0,0 +1,50 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { del, handleError } from 'data/fetchers' +import type { ResponseError } from 'types' +import { scopedAccessTokenKeys } from './keys' + +export type ScopedAccessTokenDeleteVariables = { + id: string +} + +export async function deleteScopedAccessToken({ id }: ScopedAccessTokenDeleteVariables) { + const { data, error } = await del('/platform/profile/scoped-access-tokens/{id}', { + params: { path: { id } }, + }) + + if (error) handleError(error) + return data +} + +type ScopedAccessTokenDeleteData = Awaited> + +export const useScopedAccessTokenDeleteMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (vars) => deleteScopedAccessToken(vars), + async onSuccess(data, variables, context) { + await queryClient.invalidateQueries({ queryKey: scopedAccessTokenKeys.list() }) + await queryClient.invalidateQueries({ queryKey: scopedAccessTokenKeys.detail(variables.id) }) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to delete access token: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/sql/queries/get-cron-jobs.ts b/apps/studio/data/sql/queries/get-cron-jobs.ts new file mode 100644 index 00000000000..aa930819ca7 --- /dev/null +++ b/apps/studio/data/sql/queries/get-cron-jobs.ts @@ -0,0 +1,72 @@ +import { literal } from '@supabase/pg-meta/src/pg-format' + +// [Joshen] Just omits the LEFT JOIN as that's the heavy part +export const getCronJobsMinimalSql = ({ + searchTerm, + page, + limit, +}: { + searchTerm?: string + page: number + limit: number +}) => + ` +SELECT + job.jobid, + job.jobname, + job.schedule, + job.command, + job.active +FROM + cron.job job +${!!searchTerm ? `WHERE job.jobname ILIKE ${literal(`%${searchTerm}%`)}` : ''} +ORDER BY job.jobid +LIMIT ${limit} +OFFSET ${page * limit}; +`.trim() + +export const getCronJobsSql = ({ + searchTerm, + page, + limit, +}: { + searchTerm?: string + page: number + limit: number +}) => + ` +WITH latest_runs AS ( + SELECT + jobid, + status, + MAX(start_time) AS latest_run + FROM cron.job_run_details + GROUP BY jobid, status +), most_recent_runs AS ( + SELECT + jobid, + status, + latest_run + FROM latest_runs lr1 + WHERE latest_run = ( + SELECT MAX(latest_run) + FROM latest_runs lr2 + WHERE lr2.jobid = lr1.jobid + ) +) +SELECT + job.jobid, + job.jobname, + job.schedule, + job.command, + job.active, + mr.latest_run, + mr.status +FROM + cron.job job +LEFT JOIN most_recent_runs mr ON job.jobid = mr.jobid +${!!searchTerm ? `WHERE job.jobname ILIKE ${literal(`%${searchTerm}%`)}` : ''} +ORDER BY job.jobid +LIMIT ${limit} +OFFSET ${page * limit}; +`.trim() diff --git a/apps/studio/hooks/misc/useCronJobsEstimatePrefetch.ts b/apps/studio/hooks/misc/useCronJobsEstimatePrefetch.ts deleted file mode 100644 index dc894ff1f92..00000000000 --- a/apps/studio/hooks/misc/useCronJobsEstimatePrefetch.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useQueryClient } from '@tanstack/react-query' -import { useEffect } from 'react' - -import { prefetchCronJobRunDetailsEstimate } from 'data/database-cron-jobs/database-cron-job-run-details-estimate-query' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { useStaticEffectEvent } from 'hooks/useStaticEffectEvent' - -/** - * Prefetches the cron job run details estimate when on a Cron integration page. - * This avoids a query waterfall when navigating to the Jobs tab, since the - * Jobs tab gates loading cron jobs on this estimate query completing first. - */ -export function useCronJobsEstimatePrefetch(integrationId: string | undefined) { - const queryClient = useQueryClient() - const { data: project } = useSelectedProjectQuery() - - const prefetch = useStaticEffectEvent(() => { - prefetchCronJobRunDetailsEstimate(queryClient, { - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - }) - - useEffect(() => { - if (integrationId === 'cron' && project?.ref) { - prefetch() - } - }, [integrationId, project?.ref, prefetch]) -} diff --git a/apps/studio/hooks/misc/useSelectedProject.ts b/apps/studio/hooks/misc/useSelectedProject.ts index 9829532f968..48e208b939b 100644 --- a/apps/studio/hooks/misc/useSelectedProject.ts +++ b/apps/studio/hooks/misc/useSelectedProject.ts @@ -1,6 +1,6 @@ import { useParams } from 'common' import { useProjectDetailQuery } from 'data/projects/project-detail-query' -import { PROVIDERS } from 'lib/constants' +import { PROJECT_STATUS, PROVIDERS } from 'lib/constants' export function useSelectedProjectQuery({ enabled = true } = {}) { const { ref } = useParams() @@ -52,3 +52,8 @@ export const useIsOrioleDbInAws = () => { project?.dbVersion?.endsWith('orioledb') && project?.cloud_provider === PROVIDERS.AWS.id return isOrioleDbInAws } + +export const useIsProjectActive = () => { + const { data: project } = useSelectedProjectQuery() + return project?.status === PROJECT_STATUS.ACTIVE_HEALTHY +} diff --git a/apps/studio/lib/api/self-hosted/functions/fileSystemStore.ts b/apps/studio/lib/api/self-hosted/functions/fileSystemStore.ts index 3cd98f49239..f5b783866a8 100644 --- a/apps/studio/lib/api/self-hosted/functions/fileSystemStore.ts +++ b/apps/studio/lib/api/self-hosted/functions/fileSystemStore.ts @@ -3,7 +3,7 @@ import { readdir, stat } from 'node:fs/promises' import path from 'node:path' import { pathToFileURL } from 'node:url' -import { FunctionArtifact } from './types' +import { FunctionArtifact, FunctionFileEntry } from './types' export class FileSystemFunctionsArtifactStore { constructor(private folderPath: string) {} @@ -29,6 +29,34 @@ export class FileSystemFunctionsArtifactStore { return parseFolderToFunctionArtifact(functionFolder) } + + async getFileEntriesBySlug(slug: string): Promise> { + if (slug === 'main') return [] + + const functionFolderPath = path.resolve(this.folderPath, slug) + if (!functionFolderPath.startsWith(path.resolve(this.folderPath) + path.sep)) return [] + + const entries = await readdir(functionFolderPath, { + recursive: true, + withFileTypes: true, + }) + + const fileEntries = await Promise.all( + entries + .filter((entry) => entry.isFile()) + .map(async (entry) => { + const absolutePath = path.join(entry.parentPath, entry.name) + const fileStat = await stat(absolutePath) + return { + absolutePath, + relativePath: path.relative(functionFolderPath, absolutePath), + size: fileStat.size, + } + }) + ) + + return fileEntries + } } async function parseFolderToFunctionArtifact( diff --git a/apps/studio/lib/api/self-hosted/functions/types.ts b/apps/studio/lib/api/self-hosted/functions/types.ts index 0a124c69669..31f22cc79bc 100644 --- a/apps/studio/lib/api/self-hosted/functions/types.ts +++ b/apps/studio/lib/api/self-hosted/functions/types.ts @@ -4,3 +4,11 @@ export type FunctionArtifact = { created_at: number updated_at: number } + +export type FunctionFileEntry = { + /** Absolute path on disk */ + absolutePath: string + /** Path relative to the function folder, used as the multipart filename */ + relativePath: string + size: number +} diff --git a/apps/studio/package.json b/apps/studio/package.json index 68d43465df2..beec72b34b0 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -63,7 +63,7 @@ "@supabase/mcp-utils": "^0.3.2", "@supabase/pg-meta": "workspace:*", "@supabase/realtime-js": "catalog:", - "@supabase/shared-types": "0.1.83", + "@supabase/shared-types": "0.1.84", "@supabase/sql-to-rest": "^0.1.6", "@supabase/supabase-js": "catalog:", "@tanstack/react-query": "^5.0.0", @@ -84,6 +84,7 @@ "cron-parser": "^4.9.0", "cronstrue": "^2.50.0", "crypto-js": "^4.2.0", + "dev-tools": "workspace:*", "d3-geo": "^3.1.1", "dayjs": "^1.11.10", "dnd-core": "^16.0.1", diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index 3156d1c20c0..5875b41e480 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -23,22 +23,11 @@ import { loader } from '@monaco-editor/react' import * as Sentry from '@sentry/nextjs' import { HydrationBoundary, QueryClientProvider } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' -import dayjs from 'dayjs' -import customParseFormat from 'dayjs/plugin/customParseFormat' -import duration from 'dayjs/plugin/duration' -import relativeTime from 'dayjs/plugin/relativeTime' -import timezone from 'dayjs/plugin/timezone' -import utc from 'dayjs/plugin/utc' -import Head from 'next/head' -import { NuqsAdapter } from 'nuqs/adapters/next/pages' -import { type ComponentProps, ErrorInfo, useCallback } from 'react' -import { ErrorBoundary } from 'react-error-boundary' - import { FeatureFlagProvider, - getFlags, TelemetryTagManager, ThemeProvider, + getFlags, useThemeSandbox, } from 'common' import MetaFaviconsPagesRouter from 'common/MetaFavicons/pages-router' @@ -49,8 +38,15 @@ import FeaturePreviewModal from 'components/interfaces/App/FeaturePreview/Featur import { MonacoThemeProvider } from 'components/interfaces/App/MonacoThemeProvider' import { RouteValidationWrapper } from 'components/interfaces/App/RouteValidationWrapper' import { MainScrollContainerProvider } from 'components/layouts/MainScrollContainerContext' +import { DevToolbar, DevToolbarProvider } from 'dev-tools' import { GlobalErrorBoundaryState } from 'components/ui/ErrorBoundary/GlobalErrorBoundaryState' import { useRootQueryClient } from 'data/query-client' +import dayjs from 'dayjs' +import customParseFormat from 'dayjs/plugin/customParseFormat' +import duration from 'dayjs/plugin/duration' +import relativeTime from 'dayjs/plugin/relativeTime' +import timezone from 'dayjs/plugin/timezone' +import utc from 'dayjs/plugin/utc' import { customFont, sourceCodePro } from 'fonts' import { useCustomContent } from 'hooks/custom-content/useCustomContent' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' @@ -58,6 +54,10 @@ import { AuthProvider } from 'lib/auth' import { API_URL, BASE_PATH, IS_PLATFORM, useDefaultProvider } from 'lib/constants' import { ProfileProvider } from 'lib/profile' import { Telemetry } from 'lib/telemetry' +import Head from 'next/head' +import { NuqsAdapter } from 'nuqs/adapters/next/pages' +import { type ComponentProps, ErrorInfo, useCallback } from 'react' +import { ErrorBoundary } from 'react-error-boundary' import { AiAssistantStateContextProvider } from 'state/ai-assistant-state' import type { AppPropsWithLayout } from 'types' import { SonnerToaster, TooltipProvider } from 'ui' @@ -173,19 +173,22 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { enableSystem disableTransitionOnChange > - - - - - {getLayout()} - - - - - - - - + + + + + + {getLayout()} + + + + + + + + + + diff --git a/apps/studio/pages/account/tokens.tsx b/apps/studio/pages/account/tokens.tsx index 33ed3334fab..f875a7b55a4 100644 --- a/apps/studio/pages/account/tokens.tsx +++ b/apps/studio/pages/account/tokens.tsx @@ -1,9 +1,9 @@ import { ExternalLink, Search } from 'lucide-react' import { useState } from 'react' - import { AccessTokenList } from 'components/interfaces/Account/AccessTokens/AccessTokenList' -import { NewAccessTokenButton } from 'components/interfaces/Account/AccessTokens/NewAccessTokenButton' -import { NewTokenBanner } from 'components/interfaces/Account/AccessTokens/NewTokenBanner' +import { NewTokenButton } from 'components/interfaces/Account/AccessTokens/Classic/NewTokenButton' +import { AccessTokenNewBanner } from '@/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/AccessTokenNewBanner' +import AccessTokensLayout from 'components/layouts/AccessTokens/AccessTokensLayout' import AccountLayout from 'components/layouts/AccountLayout/AccountLayout' import AppLayout from 'components/layouts/AppLayout/AppLayout' import DefaultLayout from 'components/layouts/DefaultLayout' @@ -13,68 +13,54 @@ import { DOCS_URL } from 'lib/constants' import type { NextPageWithLayout } from 'types' import { Button } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' -import { PageContainer } from 'ui-patterns/PageContainer' -import { - PageHeader, - PageHeaderDescription, - PageHeaderMeta, - PageHeaderSummary, - PageHeaderTitle, -} from 'ui-patterns/PageHeader' const UserAccessTokens: NextPageWithLayout = () => { const [newToken, setNewToken] = useState() const [searchString, setSearchString] = useState('') return ( - <> - - - - Access Tokens - - Create and manage personal access tokens for API authentication. - - - - - -
- {newToken && setNewToken(undefined)} />} -
- } - value={searchString} - onChange={(e: any) => setSearchString(e.target.value)} - name="search" - id="search" - placeholder="Filter tokens" - /> -
- - - -
-
- { - if (id === newToken?.id) setNewToken(undefined) - }} + +
+ {newToken && ( + setNewToken(undefined)} + getTokenValue={(token) => token.token} /> + )} +
+ } + value={searchString} + onChange={(e: React.ChangeEvent) => setSearchString(e.target.value)} + name="search" + id="search" + placeholder="Filter tokens" + /> +
+ + + +
- - + { + if (id === newToken?.id) setNewToken(undefined) + }} + /> +
+
) } diff --git a/apps/studio/pages/account/tokens/scoped.tsx b/apps/studio/pages/account/tokens/scoped.tsx new file mode 100644 index 00000000000..a2a259fa9a1 --- /dev/null +++ b/apps/studio/pages/account/tokens/scoped.tsx @@ -0,0 +1,88 @@ +import { ExternalLink, Search } from 'lucide-react' +import { useState } from 'react' + +import AccessTokensLayout from 'components/layouts/AccessTokens/AccessTokensLayout' +import { NewScopedTokenButton } from 'components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenButton' +import { AccessTokenNewBanner } from '@/components/interfaces/Account/AccessTokens/AccessTokenNewBanner/AccessTokenNewBanner' + +import { ScopedTokenList } from 'components/interfaces/Account/AccessTokens/Scoped/ScopedTokenList' +import AccountLayout from 'components/layouts/AccountLayout/AccountLayout' +import AppLayout from 'components/layouts/AppLayout/AppLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' +import OrganizationLayout from 'components/layouts/OrganizationLayout' +import { NewScopedAccessToken } from 'data/scoped-access-tokens/scoped-access-token-create-mutation' +import type { NextPageWithLayout } from 'types' +import { Button } from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' + +const ScopedTokens: NextPageWithLayout = () => { + const [searchString, setSearchString] = useState('') + const [newToken, setNewToken] = useState() + + return ( + +
+ {newToken && ( + setNewToken(undefined)} + getTokenValue={(token) => token.token} + getTokenPermissions={(token) => token.permissions} + /> + )} +
+ } + value={searchString} + onChange={(e: any) => setSearchString(e.target.value)} + name="search" + id="search" + placeholder="Filter by name" + /> +
+ + + +
+
+ + { + if (id === newToken?.id) setNewToken(undefined) + }} + /> +
+
+ ) +} + +ScopedTokens.getLayout = (page) => ( + + + + {page} + + + +) + +export default ScopedTokens diff --git a/apps/studio/pages/api/v1/projects/[ref]/functions/[slug]/body.ts b/apps/studio/pages/api/v1/projects/[ref]/functions/[slug]/body.ts new file mode 100644 index 00000000000..0ffb91ed316 --- /dev/null +++ b/apps/studio/pages/api/v1/projects/[ref]/functions/[slug]/body.ts @@ -0,0 +1,81 @@ +import { createReadStream } from 'node:fs' +import { pipeline } from 'node:stream/promises' +import { type NextApiRequest, type NextApiResponse } from 'next' + +import apiWrapper from '@/lib/api/apiWrapper' +import { getFunctionsArtifactStore } from '@/lib/api/self-hosted/functions' +import { uuidv4 } from '@/lib/helpers' + +export default function handlerWithErrorCatching(req: NextApiRequest, res: NextApiResponse) { + return apiWrapper(req, res, handler, { withAuth: true }) +} + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { method } = req + + switch (method) { + case 'GET': + return handleGet(req, res) + default: + res.setHeader('Allow', ['GET']) + res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) + } +} + +async function handleGet(req: NextApiRequest, res: NextApiResponse) { + const slugParam = req.query.slug + const slug = Array.isArray(slugParam) ? slugParam[0] : slugParam + if (!slug) { + res.status(404).json({ error: { message: `Missing function 'slug' parameter` } }) + return + } + + const store = getFunctionsArtifactStore() + const fileEntries = await store.getFileEntriesBySlug(slug) + + const boundary = `----FormBoundary${uuidv4().replace(/-/g, '')}` + const totalSize = fileEntries.reduce((sum, entry) => sum + entry.size, 0) + + const metadata = { + // mock id, should be "__" + deployment_id: uuidv4(), + original_size: totalSize, + compressed_size: totalSize, + module_count: fileEntries.length, + } + + res.setHeader('Content-Type', `multipart/form-data; boundary=${boundary}`) + res.status(200) + + // Write metadata part + const metadataJson = JSON.stringify(metadata) + res.write( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="metadata"\r\n` + + `Content-Type: application/json\r\n` + + `\r\n` + + metadataJson + + `\r\n` + ) + + // Stream each file part + for (const entry of fileEntries) { + const safeName = entry.relativePath + .replace(/[\r\n]/g, '') + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + const encodedName = encodeURIComponent(entry.relativePath) + res.write( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="file"; filename="${safeName}"; filename*=UTF-8''${encodedName}\r\n` + + `Content-Type: text/plain\r\n` + + `\r\n` + ) + await pipeline(createReadStream(entry.absolutePath), res, { end: false }) + res.write(`\r\n`) + } + + // Write closing boundary + res.write(`--${boundary}--\r\n`) + res.end() +} diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx index d0026fc5c0c..76cc03bb879 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx @@ -7,7 +7,7 @@ import { toast } from 'sonner' import { formatFunctionBodyToFiles } from '@/components/interfaces/EdgeFunctions/EdgeFunctions.utils' import { FileData } from '@/components/ui/FileExplorerAndEditor/FileExplorerAndEditor.types' import { useLatest } from '@/hooks/misc/useLatest' -import { useParams } from 'common' +import { IS_PLATFORM, useParams } from 'common' import { DeployEdgeFunctionWarningModal } from 'components/interfaces/EdgeFunctions/DeployEdgeFunctionWarningModal' import { DefaultLayout } from 'components/layouts/DefaultLayout' import EdgeFunctionDetailsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout' @@ -214,33 +214,35 @@ const CodePage = () => { orgSlug: org?.slug, }} /> -
- - ) : ( -
- -
- ) - } - tooltip={{ - content: { - side: 'top', - text: !canDeployFunction - ? 'You need additional permissions to update edge functions' - : undefined, - }, - }} - > - Deploy updates -
-
+ {IS_PLATFORM && ( +
+ + ) : ( +
+ +
+ ) + } + tooltip={{ + content: { + side: 'top', + text: !canDeployFunction + ? 'You need additional permissions to update edge functions' + : undefined, + }, + }} + > + Deploy updates +
+
+ )} )} diff --git a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx index 0e83542b46e..4388c94e708 100644 --- a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx +++ b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx @@ -1,12 +1,10 @@ -import { useRouter } from 'next/router' -import { useEffect, useMemo } from 'react' - import { useParams } from 'common' import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants' import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations' import { DefaultLayout } from 'components/layouts/DefaultLayout' import IntegrationsLayout from 'components/layouts/Integrations/layout' -import { useCronJobsEstimatePrefetch } from 'hooks/misc/useCronJobsEstimatePrefetch' +import { useRouter } from 'next/router' +import { useEffect, useMemo } from 'react' import type { NextPageWithLayout } from 'types' import { Admonition } from 'ui-patterns' import { PageContainer } from 'ui-patterns/PageContainer' @@ -24,8 +22,6 @@ const IntegrationPage: NextPageWithLayout = () => { const router = useRouter() const { ref, id, pageId, childId } = useParams() - useCronJobsEstimatePrefetch(id) - const { installedIntegrations: installedIntegrations, isLoading: isIntegrationsLoading } = useInstalledIntegrations() diff --git a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx index 421bb715e9b..ee4fdac3905 100644 --- a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx +++ b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx @@ -1,15 +1,13 @@ -import Link from 'next/link' -import { useRouter } from 'next/router' -import { useEffect, useMemo } from 'react' - import { useFlag, useParams } from 'common' import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants' import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations' import { DefaultLayout } from 'components/layouts/DefaultLayout' import IntegrationsLayout from 'components/layouts/Integrations/layout' import { UnknownInterface } from 'components/ui/UnknownInterface' -import { useCronJobsEstimatePrefetch } from 'hooks/misc/useCronJobsEstimatePrefetch' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useEffect, useMemo } from 'react' import type { NextPageWithLayout } from 'types' import { BreadcrumbItem_Shadcn_ as BreadcrumbItem, @@ -44,8 +42,6 @@ const IntegrationPage: NextPageWithLayout = () => { const { integrationsWrappers } = useIsFeatureEnabled(['integrations:wrappers']) const stripeSyncEnabled = useFlag('enableStripeSyncEngineIntegration') - useCronJobsEstimatePrefetch(id) - const { installedIntegrations: installedIntegrations, isLoading: isIntegrationsLoading } = useInstalledIntegrations() diff --git a/apps/studio/pages/redeem.tsx b/apps/studio/pages/redeem.tsx index 4518951f057..39156310bdf 100644 --- a/apps/studio/pages/redeem.tsx +++ b/apps/studio/pages/redeem.tsx @@ -1,7 +1,8 @@ +import { useFlag } from 'common' import Link from 'next/link' import { useState } from 'react' -import { Button, Loading } from 'ui' -import { ShimmeringLoader } from 'ui-patterns' +import { Button } from 'ui' +import { Admonition, ShimmeringLoader } from 'ui-patterns' import { CreditCodeRedemption } from '@/components/interfaces/Organization/BillingSettings/CreditCodeRedemption' import { OrganizationCard } from '@/components/interfaces/Organization/OrganizationCard' @@ -12,67 +13,85 @@ import { ScaffoldHeader, ScaffoldTitle, } from '@/components/layouts/Scaffold' +import AlertError from '@/components/ui/AlertError' import { useOrganizationsQuery } from '@/data/organizations/organizations-query' import type { NextPageWithLayout } from '@/types' const RedeemCreditsContent = () => { - const { - data: organizations, - isLoading: areOrganizationsLoading, - isFetched: isOrganizationsFetched, - isSuccess: wasOrganizationsRequestSuccessful, - } = useOrganizationsQuery() - + const redeemCodeEnabled = useFlag('redeemCodeEnabled') const [selectedOrg, setSelectedOrg] = useState(null) - if (!isOrganizationsFetched) { - return ( - -
- + const { + data: organizations, + error: errorOrganizations, + isLoading: isLoadingOrganizations, + isError: isErrorOrganizations, + } = useOrganizationsQuery() -
- - -
+ if (isLoadingOrganizations) { + return ( +
+ +
+ +
- +
) } - if (!wasOrganizationsRequestSuccessful) { - return

Error loading redeem credit page. Try again later.

+ if (isErrorOrganizations) { + return ( + + ) } return (
-
-

- To redeem your credits, select one of your organizations. The credits will be applied to - that organization only and cannot be transferred or shared between organizations. -

-

- Want to start fresh? Create a new - organization first. You will have to revisit this link after creating the organization to - redeem the code. -

- +
+
+

To redeem your credits, select one of your organizations.

+

+ The credits will be applied to that organization only and cannot be transferred or + shared between organizations. +

+
+ +
+

Want to start fresh?

+

+ Create a new organization first. You will have to revisit this link after creating the + organization to redeem the code. +

+ +
-
- {organizations?.map((org) => ( -
setSelectedOrg(org.slug)}> - -
- ))} +
+ {redeemCodeEnabled ? ( + organizations?.map((org) => ( + setSelectedOrg(org.slug)} + /> + )) + ) : ( + + )}
{selectedOrg && ( setSelectedOrg(null)} /> )} diff --git a/apps/studio/public/monaco-editor/basic-languages/javascript/javascript.js b/apps/studio/public/monaco-editor/basic-languages/javascript/javascript.js new file mode 100644 index 00000000000..28682989697 --- /dev/null +++ b/apps/studio/public/monaco-editor/basic-languages/javascript/javascript.js @@ -0,0 +1,10 @@ +/*!----------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1) + * Released under the MIT license + * https://github.com/microsoft/monaco-editor/blob/main/LICENSE.txt + *-----------------------------------------------------------------------------*/ +define("vs/basic-languages/javascript/javascript", ["require","require"],(require)=>{ +"use strict";var moduleExports=(()=>{var x=Object.create;var a=Object.defineProperty;var u=Object.getOwnPropertyDescriptor;var f=Object.getOwnPropertyNames;var b=Object.getPrototypeOf,k=Object.prototype.hasOwnProperty;var y=(e=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(e,{get:(t,n)=>(typeof require<"u"?require:t)[n]}):e)(function(e){if(typeof require<"u")return require.apply(this,arguments);throw Error('Dynamic require of "'+e+'" is not supported')});var w=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports),h=(e,t)=>{for(var n in t)a(e,n,{get:t[n],enumerable:!0})},s=(e,t,n,c)=>{if(t&&typeof t=="object"||typeof t=="function")for(let r of f(t))!k.call(e,r)&&r!==n&&a(e,r,{get:()=>t[r],enumerable:!(c=u(t,r))||c.enumerable});return e},g=(e,t,n)=>(s(e,t,"default"),n&&s(n,t,"default")),p=(e,t,n)=>(n=e!=null?x(b(e)):{},s(t||!e||!e.__esModule?a(n,"default",{value:e,enumerable:!0}):n,e)),v=e=>s(a({},"__esModule",{value:!0}),e);var d=w((C,l)=>{var A=p(y("vs/editor/editor.api"));l.exports=A});var _={};h(_,{conf:()=>$,language:()=>T});var i={};g(i,p(d()));var m={wordPattern:/(-?\d*\.\d\w*)|([^\`\~\!\@\#\%\^\&\*\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s]+)/g,comments:{lineComment:"//",blockComment:["/*","*/"]},brackets:[["{","}"],["[","]"],["(",")"]],onEnterRules:[{beforeText:/^\s*\/\*\*(?!\/)([^\*]|\*(?!\/))*$/,afterText:/^\s*\*\/$/,action:{indentAction:i.languages.IndentAction.IndentOutdent,appendText:" * "}},{beforeText:/^\s*\/\*\*(?!\/)([^\*]|\*(?!\/))*$/,action:{indentAction:i.languages.IndentAction.None,appendText:" * "}},{beforeText:/^(\t|(\ \ ))*\ \*(\ ([^\*]|\*(?!\/))*)?$/,action:{indentAction:i.languages.IndentAction.None,appendText:"* "}},{beforeText:/^(\t|(\ \ ))*\ \*\/\s*$/,action:{indentAction:i.languages.IndentAction.None,removeText:1}}],autoClosingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:'"',close:'"',notIn:["string"]},{open:"'",close:"'",notIn:["string","comment"]},{open:"`",close:"`",notIn:["string","comment"]},{open:"/**",close:" */",notIn:["string"]}],folding:{markers:{start:new RegExp("^\\s*//\\s*#?region\\b"),end:new RegExp("^\\s*//\\s*#?endregion\\b")}}},o={defaultToken:"invalid",tokenPostfix:".ts",keywords:["abstract","any","as","asserts","bigint","boolean","break","case","catch","class","continue","const","constructor","debugger","declare","default","delete","do","else","enum","export","extends","false","finally","for","from","function","get","if","implements","import","in","infer","instanceof","interface","is","keyof","let","module","namespace","never","new","null","number","object","out","package","private","protected","public","override","readonly","require","global","return","satisfies","set","static","string","super","switch","symbol","this","throw","true","try","type","typeof","undefined","unique","unknown","var","void","while","with","yield","async","await","of"],operators:["<=",">=","==","!=","===","!==","=>","+","-","**","*","/","%","++","--","<<",">",">>>","&","|","^","!","~","&&","||","??","?",":","=","+=","-=","*=","**=","/=","%=","<<=",">>=",">>>=","&=","|=","^=","@"],symbols:/[=>](?!@symbols)/,"@brackets"],[/!(?=([^=]|$))/,"delimiter"],[/@symbols/,{cases:{"@operators":"delimiter","@default":""}}],[/(@digits)[eE]([\-+]?(@digits))?/,"number.float"],[/(@digits)\.(@digits)([eE][\-+]?(@digits))?/,"number.float"],[/0[xX](@hexdigits)n?/,"number.hex"],[/0[oO]?(@octaldigits)n?/,"number.octal"],[/0[bB](@binarydigits)n?/,"number.binary"],[/(@digits)n?/,"number"],[/[;,.]/,"delimiter"],[/"([^"\\]|\\.)*$/,"string.invalid"],[/'([^'\\]|\\.)*$/,"string.invalid"],[/"/,"string","@string_double"],[/'/,"string","@string_single"],[/`/,"string","@string_backtick"]],whitespace:[[/[ \t\r\n]+/,""],[/\/\*\*(?!\/)/,"comment.doc","@jsdoc"],[/\/\*/,"comment","@comment"],[/\/\/.*$/,"comment"]],comment:[[/[^\/*]+/,"comment"],[/\*\//,"comment","@pop"],[/[\/*]/,"comment"]],jsdoc:[[/[^\/*]+/,"comment.doc"],[/\*\//,"comment.doc","@pop"],[/[\/*]/,"comment.doc"]],regexp:[[/(\{)(\d+(?:,\d*)?)(\})/,["regexp.escape.control","regexp.escape.control","regexp.escape.control"]],[/(\[)(\^?)(?=(?:[^\]\\\/]|\\.)+)/,["regexp.escape.control",{token:"regexp.escape.control",next:"@regexrange"}]],[/(\()(\?:|\?=|\?!)/,["regexp.escape.control","regexp.escape.control"]],[/[()]/,"regexp.escape.control"],[/@regexpctl/,"regexp.escape.control"],[/[^\\\/]/,"regexp"],[/@regexpesc/,"regexp.escape"],[/\\\./,"regexp.invalid"],[/(\/)([dgimsuy]*)/,[{token:"regexp",bracket:"@close",next:"@pop"},"keyword.other"]]],regexrange:[[/-/,"regexp.escape.control"],[/\^/,"regexp.invalid"],[/@regexpesc/,"regexp.escape"],[/[^\]]/,"regexp"],[/\]/,{token:"regexp.escape.control",next:"@pop",bracket:"@close"}]],string_double:[[/[^\\"]+/,"string"],[/@escapes/,"string.escape"],[/\\./,"string.escape.invalid"],[/"/,"string","@pop"]],string_single:[[/[^\\']+/,"string"],[/@escapes/,"string.escape"],[/\\./,"string.escape.invalid"],[/'/,"string","@pop"]],string_backtick:[[/\$\{/,{token:"delimiter.bracket",next:"@bracketCounting"}],[/[^\\`$]+/,"string"],[/@escapes/,"string.escape"],[/\\./,"string.escape.invalid"],[/`/,"string","@pop"]],bracketCounting:[[/\{/,"delimiter.bracket","@bracketCounting"],[/\}/,"delimiter.bracket","@pop"],{include:"common"}]}};var $=m,T={defaultToken:"invalid",tokenPostfix:".js",keywords:["break","case","catch","class","continue","const","constructor","debugger","default","delete","do","else","export","extends","false","finally","for","from","function","get","if","import","in","instanceof","let","new","null","return","set","static","super","switch","symbol","this","throw","true","try","typeof","undefined","var","void","while","with","yield","async","await","of"],typeKeywords:[],operators:o.operators,symbols:o.symbols,escapes:o.escapes,digits:o.digits,octaldigits:o.octaldigits,binarydigits:o.binarydigits,hexdigits:o.hexdigits,regexpctl:o.regexpctl,regexpesc:o.regexpesc,tokenizer:o.tokenizer};return v(_);})(); +return moduleExports; +}); diff --git a/apps/studio/public/monaco-editor/basic-languages/markdown/markdown.js b/apps/studio/public/monaco-editor/basic-languages/markdown/markdown.js new file mode 100644 index 00000000000..b9edf510978 --- /dev/null +++ b/apps/studio/public/monaco-editor/basic-languages/markdown/markdown.js @@ -0,0 +1,10 @@ +/*!----------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1) + * Released under the MIT license + * https://github.com/microsoft/monaco-editor/blob/main/LICENSE.txt + *-----------------------------------------------------------------------------*/ +define("vs/basic-languages/markdown/markdown", ["require","require"],(require)=>{ +"use strict";var moduleExports=(()=>{var s=Object.defineProperty;var r=Object.getOwnPropertyDescriptor;var c=Object.getOwnPropertyNames;var i=Object.prototype.hasOwnProperty;var l=(t,e)=>{for(var o in e)s(t,o,{get:e[o],enumerable:!0})},m=(t,e,o,a)=>{if(e&&typeof e=="object"||typeof e=="function")for(let n of c(e))!i.call(t,n)&&n!==o&&s(t,n,{get:()=>e[n],enumerable:!(a=r(e,n))||a.enumerable});return t};var d=t=>m(s({},"__esModule",{value:!0}),t);var b={};l(b,{conf:()=>p,language:()=>g});var p={comments:{blockComment:[""]},brackets:[["{","}"],["[","]"],["(",")"]],autoClosingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:"<",close:">",notIn:["string"]}],surroundingPairs:[{open:"(",close:")"},{open:"[",close:"]"},{open:"`",close:"`"}],folding:{markers:{start:new RegExp("^\\s*"),end:new RegExp("^\\s*")}}},g={defaultToken:"",tokenPostfix:".md",control:/[\\`*_\[\]{}()#+\-\.!]/,noncontrol:/[^\\`*_\[\]{}()#+\-\.!]/,escapes:/\\(?:@control)/,jsescapes:/\\(?:[btnfr\\"']|[0-7][0-7]?|[0-3][0-7]{2})/,empty:["area","base","basefont","br","col","frame","hr","img","input","isindex","link","meta","param"],tokenizer:{root:[[/^\s*\|/,"@rematch","@table_header"],[/^(\s{0,3})(#+)((?:[^\\#]|@escapes)+)((?:#+)?)/,["white","keyword","keyword","keyword"]],[/^\s*(=+|\-+)\s*$/,"keyword"],[/^\s*((\*[ ]?)+)\s*$/,"meta.separator"],[/^\s*>+/,"comment"],[/^\s*([\*\-+:]|\d+\.)\s/,"keyword"],[/^(\t|[ ]{4})[^ ].*$/,"string"],[/^\s*~~~\s*((?:\w|[\/\-#])+)?\s*$/,{token:"string",next:"@codeblock"}],[/^\s*```\s*((?:\w|[\/\-#])+).*$/,{token:"string",next:"@codeblockgh",nextEmbedded:"$1"}],[/^\s*```\s*$/,{token:"string",next:"@codeblock"}],{include:"@linecontent"}],table_header:[{include:"@table_common"},[/[^\|]+/,"keyword.table.header"]],table_body:[{include:"@table_common"},{include:"@linecontent"}],table_common:[[/\s*[\-:]+\s*/,{token:"keyword",switchTo:"table_body"}],[/^\s*\|/,"keyword.table.left"],[/^\s*[^\|]/,"@rematch","@pop"],[/^\s*$/,"@rematch","@pop"],[/\|/,{cases:{"@eos":"keyword.table.right","@default":"keyword.table.middle"}}]],codeblock:[[/^\s*~~~\s*$/,{token:"string",next:"@pop"}],[/^\s*```\s*$/,{token:"string",next:"@pop"}],[/.*$/,"variable.source"]],codeblockgh:[[/```\s*$/,{token:"string",next:"@pop",nextEmbedded:"@pop"}],[/[^`]+/,"variable.source"]],linecontent:[[/&\w+;/,"string.escape"],[/@escapes/,"escape"],[/\b__([^\\_]|@escapes|_(?!_))+__\b/,"strong"],[/\*\*([^\\*]|@escapes|\*(?!\*))+\*\*/,"strong"],[/\b_[^_]+_\b/,"emphasis"],[/\*([^\\*]|@escapes)+\*/,"emphasis"],[/`([^\\`]|@escapes)+`/,"variable"],[/\{+[^}]+\}+/,"string.target"],[/(!?\[)((?:[^\]\\]|@escapes)*)(\]\([^\)]+\))/,["string.link","","string.link"]],[/(!?\[)((?:[^\]\\]|@escapes)*)(\])/,"string.link"],{include:"html"}],html:[[/<(\w+)\/>/,"tag"],[/<(\w+)(\-|\w)*/,{cases:{"@empty":{token:"tag",next:"@tag.$1"},"@default":{token:"tag",next:"@tag.$1"}}}],[/<\/(\w+)(\-|\w)*\s*>/,{token:"tag"}],[//,"comment","@pop"],[/