Merge branch 'master' into feat/side-panel-toolbar

This commit is contained in:
Ali Waseem authored and GitHub committed 2026-02-09 07:38:50 -07:00
commit ca643db078
146 files changed
+57943 -2036

No files matched your search

+2 -1
View File
@@ -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
/packages/shared-data/compute-disk-limits.ts @supabase/infra
+1 -1
View File
@@ -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",
@@ -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 = () => {
</div>
<div className="flex gap-2 items-center">
<DevToolbarTrigger />
<CommandMenuTriggerInput
placeholder={
<>
@@ -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
<Admonition type="note">
Disabled when using project-scoped mode (`project_ref` parameter).
</Admonition>
- `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)
<Admonition type="note">
Requires a paid plan.
</Admonition>
- `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=<id>` | Scope to a specific project (disables account tools) | `?project_ref=abc123` |
| `features=<groups>` | 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`
<Admonition type="tip">
When using [Supabase CLI](/docs/guides/cli) for local development, the MCP server is available at `http://localhost:54321/mcp`.
</Admonition>
## 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
+20 -16
View File
@@ -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) {
<QueryClientProvider>
<AuthContainer>
<FeatureFlagProvider API_URL={API_URL} enabled={IS_PLATFORM}>
<PageTelemetry />
<ScrollRestoration />
<ThemeProvider defaultTheme="system" enableSystem disableTransitionOnChange>
<TooltipProvider delayDuration={0}>
<DocsCommandProvider>
<div className="flex flex-col">
<SiteLayout>
{children}
<DocsCommandMenu />
</SiteLayout>
<ThemeSandbox />
</div>
</DocsCommandProvider>
<SonnerToaster position="top-right" />
</TooltipProvider>
</ThemeProvider>
<DevToolbarProvider apiUrl={API_URL}>
<PageTelemetry />
<ScrollRestoration />
<ThemeProvider defaultTheme="system" enableSystem disableTransitionOnChange>
<TooltipProvider delayDuration={0}>
<DocsCommandProvider>
<div className="flex flex-col">
<SiteLayout>
{children}
<DocsCommandMenu />
</SiteLayout>
<ThemeSandbox />
</div>
</DocsCommandProvider>
<SonnerToaster position="top-right" />
<DevToolbar />
</TooltipProvider>
</ThemeProvider>
</DevToolbarProvider>
</FeatureFlagProvider>
</AuthContainer>
</QueryClientProvider>
+1
View File
@@ -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",
+10 -15
View File
@@ -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,
+1 -1
View File
@@ -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'
@@ -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<string>('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 = (
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.2 }}
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50"
<div
className="fixed bottom-12 z-50 transform-gpu will-change-transform"
style={{ left: leftPosition, transform: 'translateX(-50%)' }}
>
<div className="flex items-center gap-8 px-4 py-3 bg-surface-100 border rounded-lg shadow-lg">
<span className="text-sm text-foreground">
{operationCount} pending change{operationCount !== 1 ? 's' : ''}
</span>
<div className="flex items-center gap-3">
<button
onClick={() => snap.toggleViewOperationQueue()}
className="text-foreground-light hover:text-foreground transition-colors flex items-center"
aria-label="View Details"
>
<Eye size={14} />
<span className="text-foreground-lighter text-[10px] ml-1">{`${modKey}.`}</span>
</button>
<Button
size="tiny"
type="primary"
onClick={handleSave}
disabled={isSaving}
loading={isSaving}
>
Save
<span className="text-foreground-lighter text-[10px] ml-1">{`${modKey}S`}</span>
</Button>
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 16 }}
transition={{
type: 'spring',
stiffness: 420,
damping: 30,
mass: 0.4,
}}
>
<div className="flex items-center gap-8 pl-4 pr-2 py-2 bg-surface-100 border rounded-lg shadow-lg">
<span className="text-xs text-foreground-light max-w-40 truncate">
{operationCount} pending change{operationCount !== 1 ? 's' : ''}
</span>
<div className="flex items-center gap-2">
<Button type="default" size="tiny" onClick={() => snap.toggleViewOperationQueue()}>
Review{' '}
<span className="text-[10px] text-foreground/40 ml-1.5">{`${modKey}.`}</span>
</Button>
<Button
size="tiny"
type="primary"
onClick={handleSave}
disabled={isSaving}
loading={isSaving}
>
Save{operationCount > 1 && ' all'}
<span className="text-[10px] text-foreground/40 ml-1.5">{`${modKey}S`}</span>
</Button>
</div>
</div>
</div>
</motion.div>
</motion.div>
</div>
)}
</AnimatePresence>
)
@@ -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' } }}
/>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ align="end" className="p-0 w-96">
<PopoverContent_Shadcn_
align="end"
className="p-0 w-96"
onDoubleClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
>
<ReferenceRecordPeek
table={targetTable}
column={relationship.target_column_name}
@@ -1,7 +1,4 @@
import { PostgresTable } from '@supabase/postgres-meta'
import { Key } from 'lucide-react'
import DataGrid, { Column } from 'react-data-grid'
import { keepPreviousData } from '@tanstack/react-query'
import { useParams } from 'common'
import { COLUMN_MIN_WIDTH } from 'components/grid/constants'
@@ -9,13 +6,20 @@ import {
ESTIMATED_CHARACTER_PIXEL_WIDTH,
getColumnDefaultWidth,
} from 'components/grid/utils/gridColumns'
import { convertByteaToHex } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils'
import { isArrayColumn, isBinaryColumn, isJsonColumn } from 'components/grid/utils/types'
import { EditorTablePageLink } from 'data/prefetchers/project.$ref.editor.$id'
import { useTableRowsQuery } from 'data/table-rows/table-rows-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { Key } from 'lucide-react'
import { useMemo } from 'react'
import DataGrid, { Column } from 'react-data-grid'
import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { BinaryFormatter } from './BinaryFormatter'
import { DefaultFormatter } from './DefaultFormatter'
import { JsonFormatter } from './JsonFormatter'
interface ReferenceRecordPeekProps {
table: PostgresTable
column: string
@@ -44,59 +48,52 @@ export const ReferenceRecordPeek = ({ table, column, value }: ReferenceRecordPee
{ placeholderData: keepPreviousData }
)
const primaryKeys = table.primary_keys.map((x) => 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<any> = {
key: column.name,
name: column.name,
resizable: false,
draggable: false,
sortable: false,
width: columnWidth,
minWidth: COLUMN_MIN_WIDTH,
headerCellClass: 'outline-none !shadow-none',
renderHeaderCell: () => (
<div className="flex h-full items-center justify-center gap-x-2">
{isPrimaryKey && (
<Tooltip>
<TooltipTrigger>
<Key size={14} strokeWidth={2} className="text-brand rotate-45" />
</TooltipTrigger>
<TooltipContent side="bottom">Primary key</TooltipContent>
</Tooltip>
)}
<span className="text-xs truncate">{column.name}</span>
<span className="text-xs text-foreground-light font-normal">{column.format}</span>
</div>
),
renderCell: ({ column: col, row }) => {
const value = row[col.name as any]
const formattedValue = column.format === 'bytea' ? convertByteaToHex(value) : value
return (
<div
className={cn(
'flex items-center h-full w-full whitespace-pre',
formattedValue === null && 'text-foreground-lighter'
const res: Column<any> = {
key: column.name,
name: column.name,
resizable: false,
draggable: false,
sortable: false,
width: columnWidth,
minWidth: COLUMN_MIN_WIDTH,
headerCellClass: 'outline-none !shadow-none',
renderHeaderCell: () => (
<div className="flex h-full items-center justify-center gap-x-2">
{isPrimaryKey && (
<Tooltip>
<TooltipTrigger>
<Key size={14} strokeWidth={2} className="text-brand rotate-45" />
</TooltipTrigger>
<TooltipContent side="bottom">Primary key</TooltipContent>
</Tooltip>
)}
>
{formattedValue === null ? 'NULL' : formattedValue}
<span className="text-xs truncate">{column.name}</span>
<span className="text-xs text-foreground-light font-normal">{column.format}</span>
</div>
)
},
}
return res
})
),
renderCell: isBinaryColumn(column.data_type)
? BinaryFormatter
: isJsonColumn(column.data_type) && !isArrayColumn(column.data_type)
? JsonFormatter
: DefaultFormatter,
}
return res
})
}, [table?.columns, primaryKeys])
return (
<>
@@ -237,7 +237,7 @@ export const Grid = memo(
{(rows ?? []).length === 0 && (
<div
className={cn(
'absolute top-9 p-2 w-full z-[1]',
'absolute w-full inset-0 flex flex-col items-center justify-center p-2 z-[1]',
isTableEmpty && isDraggedOver && 'border-2 border-dashed',
isValidFileDraggedOver ? 'border-brand-600' : 'border-destructive-600'
)}
@@ -245,14 +245,16 @@ export const Grid = memo(
onDragLeave={onDragOver}
onDrop={onFileDrop}
>
{isLoading && !isDisabled && <GenericSkeletonLoader />}
{isLoading && !isDisabled && (
<GenericSkeletonLoader className="w-full top-9 absolute p-2" />
)}
{isError && <GridError error={error} />}
{isSuccess && (
<>
{page > 1 ? (
<div className="flex flex-col items-center justify-center col-span-full h-full">
<div className="flex flex-col items-center justify-center">
<p className="text-sm text-light">This page does not have any data</p>
<div className="flex items-center space-x-2 mt-4">
<Button
@@ -265,7 +267,7 @@ export const Grid = memo(
</div>
</div>
) : (filters ?? []).length === 0 ? (
<div className="flex flex-col items-center justify-center col-span-full h-full">
<div className="flex flex-col items-center justify-center">
<p className="text-sm text-light pointer-events-auto">
{isDraggedOver ? (
isValidFileDraggedOver ? (
@@ -312,7 +314,7 @@ export const Grid = memo(
)}
</div>
) : (
<div className="flex flex-col items-center justify-center col-span-full h-full">
<div className="flex flex-col items-center justify-center">
<p className="text-sm text-light pointer-events-auto">
The filters applied have returned no results from this table
</p>
@@ -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<string, { resource: string; title: string; actions: string[] }>()
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())
})()
@@ -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<typeof TokenSchema>
@@ -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
}
@@ -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 = <T extends BaseToken>(
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<string, ScopedAccessTokenPermission[]> => {
const [scope, resource] = resourceKey.split(':')
const result: Record<string, ScopedAccessTokenPermission[]> = { '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
}
}
@@ -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 = () => (
<TableRow>
<TableCell>
<Skeleton className="w-40 max-w-40 h-4 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="w-60 max-w-60 h-4 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="max-w-32 h-4 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="max-w-32 h-4 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="w-4 h-4 rounded-md" />
</TableCell>
</TableRow>
)
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) => (
<Card className="w-full overflow-hidden">
<CardContent className="p-0">
<Table className="p-5 table-auto">
<TableHeader>
<TableRow className="bg-200">
<TableHead className={tableHeaderClass}>Token</TableHead>
<TableHead className={tableHeaderClass}>
<TableHeadSort column="last_used_at" currentSort={sort} onSortChange={onSortChange}>
Last used
</TableHeadSort>
</TableHead>
<TableHead className={tableHeaderClass}>
<TableHeadSort column="expires_at" currentSort={sort} onSortChange={onSortChange}>
Expires
</TableHeadSort>
</TableHead>
<TableHead className={cn(tableHeaderClass, '!text-right')} />
</TableRow>
</TableHeader>
<TableBody>{children}</TableBody>
</Table>
</CardContent>
</Card>
)
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 (
<TableContainer sort={sort} onSortChange={handleSortChange}>
<TableContainer sort={sort} onSortChange={onSortChange}>
<TableRow>
<TableCell colSpan={4} className="p-0">
<AlertError
@@ -201,7 +80,7 @@ export const AccessTokenList = ({ searchString = '', onDeleteSuccess }: AccessTo
if (isLoading) {
return (
<TableContainer sort={sort} onSortChange={handleSortChange}>
<TableContainer sort={sort} onSortChange={onSortChange}>
<RowLoading />
<RowLoading />
</TableContainer>
@@ -210,7 +89,7 @@ export const AccessTokenList = ({ searchString = '', onDeleteSuccess }: AccessTo
if (empty) {
return (
<TableContainer sort={sort} onSortChange={handleSortChange}>
<TableContainer sort={sort} onSortChange={onSortChange}>
<TableRow>
<TableCell colSpan={4} className="py-12">
<p className="text-sm text-center text-foreground">No access tokens found</p>
@@ -225,93 +104,51 @@ export const AccessTokenList = ({ searchString = '', onDeleteSuccess }: AccessTo
return (
<>
<TableContainer sort={sort} onSortChange={handleSortChange}>
{filteredTokens?.map((x) => {
return (
<TableRow key={x.token_alias}>
<TableCell className="w-auto max-w-96">
<p className="truncate" title={x.name}>
{x.name}
</p>
<p
className="font-mono text-foreground-lighter truncate text-xs mt-1 max-w-32 sm:max-w-48 lg:max-w-full"
title={x.token_alias}
>
{x.token_alias}
</p>
</TableCell>
<TableCell className="text-foreground-light min-w-28">
{x.last_used_at ? (
<TimestampInfo
utcTimestamp={x.last_used_at}
label={dayjs(x.last_used_at).fromNow()}
className="text-sm"
/>
) : (
<p className="text-foreground-light text-sm">Never used</p>
)}
</TableCell>
<TableCell className="min-w-28 text-foreground-light">
{x.expires_at ? (
dayjs(x.expires_at).isBefore(dayjs()) ? (
<TimestampInfo
utcTimestamp={x.expires_at}
label="Expired"
className="text-sm"
<TableContainer sort={sort} onSortChange={onSortChange}>
{filteredTokens?.map((x) => (
<TableRow key={x.token_alias}>
<TokenNameCell name={x.name} tokenAlias={x.token_alias} />
<LastUsedCell lastUsedAt={x.last_used_at} />
<ExpiresCell expiresAt={x.expires_at} />
<TableCell>
<div className="flex items-center justify-end gap-x-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="default"
title="More options"
className="w-7"
icon={<MoreVertical />}
/>
) : (
<TimestampInfo
utcTimestamp={x.expires_at}
label={dayjs(x.expires_at).format('DD MMM YYYY')}
className="text-sm"
/>
)
) : (
<p className="text-foreground-light text-sm">Never</p>
)}
</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-x-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="default"
title="More options"
className="px-1"
disabled={isLoading}
loading={isLoading}
icon={<MoreVertical />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-40">
<DropdownMenuItem
className="gap-x-2"
onClick={() => {
setToken(x)
setIsOpen(true)
}}
>
<Trash size={12} />
<p>Delete token</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
)
})}
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-40">
<DropdownMenuItem
className="gap-x-2"
onClick={() => {
setToken(x)
setIsOpen(true)
}}
>
<Trash size={12} />
<p>Delete token</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
))}
</TableContainer>
<ConfirmationModal
visible={isOpen}
variant={'destructive'}
variant="destructive"
title="Confirm to delete"
confirmLabel="Delete"
confirmLabelLoading="Deleting"
onCancel={() => setIsOpen(false)}
onConfirm={() => {
if (token) onDeleteToken(token.id)
if (token) deleteToken({ id: token.id })
}}
>
<p className="py-4 text-sm text-foreground-light">
@@ -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<T> {
token: T
onClose: () => void
getTokenValue: (token: T) => string
getTokenPermissions?: (token: T) => string[] | undefined
title?: string
description?: string
}
export const AccessTokenNewBanner = <T,>({
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<T>) => {
const tokenPermissions = getTokenPermissions?.(token)
const { groupedPermissions, totalCount } = useGroupedPermissions(tokenPermissions)
return (
<Admonition
type="tip"
title={title}
className="mb-6 relative"
actions={
<Button
type="text"
icon={<X />}
className="w-7 h-7 absolute top-2.5 right-2.5"
onClick={onClose}
/>
}
>
<div className="space-y-4">
<p className="text-sm text-foreground-light">{description}</p>
<div className="w-full pb-2">
<Input
copy
readOnly
size="small"
className="w-full input-mono"
id="access-token-value"
value={getTokenValue(token)}
onChange={() => {}}
onCopy={() => toast.success('Token copied to clipboard')}
/>
</div>
{tokenPermissions && tokenPermissions.length > 0 && (
<TokenPermissionsSection
groupedPermissions={groupedPermissions}
totalCount={totalCount}
/>
)}
</div>
</Admonition>
)
}
@@ -0,0 +1,28 @@
interface PermissionsListProps {
groupedPermissions: Record<string, string[]>
}
export const PermissionsList = ({ groupedPermissions }: PermissionsListProps) => {
return (
<div className="gap-2 flex flex-col">
{Object.entries(groupedPermissions).map(([accessLevel, resources]) => (
<div key={accessLevel} className="flex flex-wrap gap-1.5">
<span className="text-xs text-foreground-lighter font-mono uppercase tracking-wide">
{accessLevel}:
</span>
<div className="flex flex-wrap gap-1.5">
{resources.map((resource, index) => (
<span
key={`${accessLevel}-${resource}`}
className="text-xs text-foreground capitalize"
>
{resource}
{index < resources.length - 1 ? ',' : '.'}
</span>
))}
</div>
</div>
))}
</div>
)
}
@@ -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<string, string[]>
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 (
<div className="pt-4 border-t border-default">
{shouldCollapse ? (
<Collapsible open={permissionsOpen} onOpenChange={setPermissionsOpen}>
<CollapsibleTrigger asChild>
<Button
type="text"
size="tiny"
className="w-full justify-start px-0.5 h-auto text-sm font-medium text-foreground-light hover:text-foreground"
>
<div className="flex items-center gap-1.5">
{permissionsOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span>Permissions assigned to this token ({totalCount})</span>
</div>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-3 transition-all data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down">
<PermissionsList groupedPermissions={groupedPermissions} />
</CollapsibleContent>
</Collapsible>
) : (
<>
<h3 className="text-sm font-medium mb-3">Permissions assigned to this token:</h3>
<PermissionsList groupedPermissions={groupedPermissions} />
</>
)}
</div>
)
}
@@ -0,0 +1,22 @@
import { Skeleton } from 'ui'
import { TableCell, TableRow } from 'ui/src/components/shadcn/ui/table'
export const RowLoading = () => (
<TableRow>
<TableCell>
<Skeleton className="w-40 max-w-40 h-4 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="w-60 max-w-60 h-4 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="max-w-32 h-4 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="max-w-32 h-4 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="w-4 h-4 rounded-md" />
</TableCell>
</TableRow>
)
@@ -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) => (
<Card className="w-full overflow-hidden">
<CardContent className="p-0">
<Table className="p-5 table-auto">
<TableHeader>
<TableRow className="bg-200">
<TableHead className={tableHeaderClass}>Token</TableHead>
<TableHead className={tableHeaderClass}>
<TableHeadSort column="last_used_at" currentSort={sort} onSortChange={onSortChange}>
Last used
</TableHeadSort>
</TableHead>
<TableHead className={tableHeaderClass}>
<TableHeadSort column="expires_at" currentSort={sort} onSortChange={onSortChange}>
Expires
</TableHeadSort>
</TableHead>
<TableHead className={cn(tableHeaderClass, '!text-right')} />
</TableRow>
</TableHeader>
<TableBody>{children}</TableBody>
</Table>
</CardContent>
</Card>
)
@@ -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) => (
<TableCell className="w-auto max-w-96">
<p className="truncate" title={name}>
{name}
</p>
<p
className="font-mono text-foreground-lighter truncate text-xs mt-1 max-w-32 sm:max-w-48 lg:max-w-full"
title={tokenAlias}
>
{tokenAlias}
</p>
</TableCell>
)
interface LastUsedCellProps {
lastUsedAt: string | null | undefined
}
export const LastUsedCell = ({ lastUsedAt }: LastUsedCellProps) => (
<TableCell className="text-foreground-light min-w-28">
{lastUsedAt ? (
<TimestampInfo
utcTimestamp={lastUsedAt}
label={dayjs(lastUsedAt).fromNow()}
className="text-sm"
/>
) : (
<p className="text-foreground-light text-sm">Never used</p>
)}
</TableCell>
)
interface ExpiresCellProps {
expiresAt: string | null | undefined
}
export const ExpiresCell = ({ expiresAt }: ExpiresCellProps) => (
<TableCell className="min-w-28 text-foreground-light">
{expiresAt ? (
dayjs(expiresAt).isBefore(dayjs()) ? (
<TimestampInfo utcTimestamp={expiresAt} label="Expired" className="text-sm" />
) : (
<TimestampInfo
utcTimestamp={expiresAt}
label={dayjs(expiresAt).format('DD MMM YYYY')}
className="text-sm"
/>
)
) : (
<p className="text-foreground-light text-sm">Never</p>
)}
</TableCell>
)
@@ -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<string, { value: string; label: string }> = {
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',
},
}
@@ -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
</DropdownMenu>
</div>
<NewAccessTokenDialog
<NewTokenDialog
open={visible}
onOpenChange={setVisible}
tokenScope={tokenScope}
@@ -7,7 +7,10 @@ import { toast } from 'sonner'
import { z } from 'zod'
import { DatePicker } from 'components/ui/DatePicker'
import { useAccessTokenCreateMutation } from 'data/access-tokens/access-tokens-create-mutation'
import {
useAccessTokenCreateMutation,
type NewAccessToken,
} from 'data/access-tokens/access-tokens-create-mutation'
import {
Button,
Dialog,
@@ -32,9 +35,10 @@ import { Admonition } from 'ui-patterns'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import {
CUSTOM_EXPIRY_VALUE,
ExpiresAtOptions,
EXPIRES_AT_OPTIONS,
NON_EXPIRING_TOKEN_VALUE,
} from './AccessTokens.constants'
} from '../AccessToken.constants'
import { getExpirationDate } from '../AccessToken.utils'
const formId = 'new-access-token-form'
@@ -50,10 +54,10 @@ export interface NewAccessTokenDialogProps {
open: boolean
tokenScope: 'V0' | undefined
onOpenChange: (open: boolean) => 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<z.infer<typeof TokenSchema>>({
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<z.infer<typeof TokenSchema>> = 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 = ({
<SelectValue_Shadcn_ placeholder="Expires at" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{Object.values(ExpiresAtOptions).map((option) => (
<SelectItem_Shadcn_ key={option.value} value={option.value}>
{option.label}
</SelectItem_Shadcn_>
))}
{Object.values(EXPIRES_AT_OPTIONS).map(
(option: { value: string; label: string }) => (
<SelectItem_Shadcn_ key={option.value} value={option.value}>
{option.label}
</SelectItem_Shadcn_>
)
)}
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
@@ -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(<NewAccessTokenButton onCreateToken={onCreateToken} />)
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(<NewAccessTokenButton onCreateToken={onCreateToken} />)
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(<NewAccessTokenButton onCreateToken={vi.fn()} />)
// 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(<NewAccessTokenButton onCreateToken={onCreateToken} />)
// 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()
// })
})
@@ -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 (
<Admonition
type="default"
className="relative mb-6"
title="Successfully generated a new token!"
description={
<div className="w-full space-y-2">
<p className="text-sm">
Do copy this access token and store it in a secure place - you will not be able to see
it again.
</p>
<div className="max-w-xl">
<Input
copy
readOnly
size="small"
className="max-w-xl input-mono"
value={token.token}
onChange={() => {}}
onCopy={() => toast.success('Token copied to clipboard')}
/>
</div>
</div>
}
>
<Button
type="text"
icon={<X />}
className="w-7 h-7 absolute top-3 right-3"
onClick={onClose}
/>
</Admonition>
)
}
@@ -0,0 +1,144 @@
import dayjs from 'dayjs'
import { useEffect, useState } from 'react'
import { Control, ControllerRenderProps } from 'react-hook-form'
import { DatePicker } from 'components/ui/DatePicker'
import {
FormControl_Shadcn_,
FormField_Shadcn_,
Input_Shadcn_,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
WarningIcon,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import {
CUSTOM_EXPIRY_VALUE,
EXPIRES_AT_OPTIONS,
NON_EXPIRING_TOKEN_VALUE,
} from '../../AccessToken.constants'
import { type TokenFormValues } from '../../AccessToken.schemas'
interface BasicInfoProps {
control: Control<TokenFormValues>
expirationDate: string
onCustomDateChange?: (date: { date: string } | undefined) => void
onCustomExpiryChange?: (isCustom: boolean) => void
}
export const BasicInfo = ({
control,
expirationDate,
onCustomDateChange,
onCustomExpiryChange,
}: BasicInfoProps) => {
const [customDate, setCustomDate] = useState<Date>()
const [isCustomSelected, setIsCustomSelected] = useState(false)
useEffect(() => {
const isCustom = expirationDate === CUSTOM_EXPIRY_VALUE
setIsCustomSelected(isCustom)
onCustomExpiryChange?.(isCustom)
}, [expirationDate, onCustomExpiryChange])
const handleCustomDateChange = (date: Date | undefined) => {
setCustomDate(date)
if (date) {
onCustomDateChange?.({ date: date.toISOString() })
} else {
onCustomDateChange?.(undefined)
}
}
const handleExpiryChange = (
value: string,
field: ControllerRenderProps<TokenFormValues, 'expiresAt'>
) => {
const isCustom = value === CUSTOM_EXPIRY_VALUE
setIsCustomSelected(isCustom)
onCustomExpiryChange?.(isCustom)
field.onChange(value)
}
return (
<div className="space-y-4 px-5 sm:px-6 py-6">
<FormField_Shadcn_
key="tokenName"
name="tokenName"
control={control}
render={({ field }) => (
<FormItemLayout name="tokenName" label="Name">
<FormControl_Shadcn_>
<Input_Shadcn_
id="tokenName"
{...field}
placeholder="Provide a name for your token"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
key="expiresAt"
name="expiresAt"
control={control}
render={({ field }) => (
<FormItemLayout name="expiresAt" label="Expires in">
<div className="flex gap-2">
<FormControl_Shadcn_ className="flex-grow">
<Select_Shadcn_
value={field.value}
onValueChange={(value) => handleExpiryChange(value, field)}
>
<SelectTrigger_Shadcn_>
<SelectValue_Shadcn_ placeholder="Expires at" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{Object.values(EXPIRES_AT_OPTIONS).map(
(option: { value: string; label: string }) => (
<SelectItem_Shadcn_ key={option.value} value={option.value}>
{option.label}
</SelectItem_Shadcn_>
)
)}
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
{isCustomSelected && (
<DatePicker
selectsRange={false}
triggerButtonSize="small"
contentSide="top"
minDate={new Date()}
maxDate={dayjs().add(1, 'year').toDate()}
onChange={(date) => {
const selectedDate = date.to || date.from
if (selectedDate) {
handleCustomDateChange(new Date(selectedDate))
} else {
handleCustomDateChange(undefined)
}
}}
>
{customDate ? `${dayjs(customDate).format('DD MMM, HH:mm')}` : 'Select date'}
</DatePicker>
)}
</div>
{field.value === NON_EXPIRING_TOKEN_VALUE && (
<div className="w-full flex gap-x-2 items-center mt-3 mx-0.5">
<WarningIcon />
<span className="text-xs text-left text-foreground-lighter">
Make sure to keep your non-expiring token safe and secure.
</span>
</div>
)}
</FormItemLayout>
)}
/>
</div>
)
}
@@ -0,0 +1,89 @@
import { Path, PathValue } from 'react-hook-form'
import {
Button,
Popover_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
Command_Shadcn_,
CommandEmpty_Shadcn_,
CommandInput_Shadcn_,
CommandGroup_Shadcn_,
CommandItem_Shadcn_,
CommandList_Shadcn_,
Checkbox_Shadcn_,
} from 'ui'
import { Plus, Key } from 'lucide-react'
import { ACCESS_TOKEN_RESOURCES } from '../../../AccessToken.constants'
import {
PermissionResource,
PermissionRow,
PermissionsFormValues,
PermissionResourceSelectorProps,
} from './Permissions.types'
import { togglePermissionResource } from './Permissions.utils'
export const PermissionResourceSelector = <TFormValues extends PermissionsFormValues>({
open,
onOpenChange,
permissionRows,
setValue,
align = 'center',
}: PermissionResourceSelectorProps<TFormValues>) => {
const handleToggleResource = (resource: PermissionResource) => {
const newRows = togglePermissionResource(permissionRows, resource)
setValue(
'permissionRows' as Path<TFormValues>,
newRows as PathValue<TFormValues, Path<TFormValues>>
)
}
return (
<Popover_Shadcn_ open={open} onOpenChange={onOpenChange} modal={true}>
<PopoverTrigger_Shadcn_ asChild>
<Button type="default" size="tiny" icon={<Plus className="h-4 w-4" />}>
Add permission
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="w-[400px] p-0" align={align}>
<Command_Shadcn_>
<CommandInput_Shadcn_ placeholder="Search resources..." />
<CommandList_Shadcn_>
<CommandEmpty_Shadcn_>No resources found.</CommandEmpty_Shadcn_>
<CommandGroup_Shadcn_ className="[&>div]:text-left">
<div className="max-h-[210px] overflow-y-auto">
{ACCESS_TOKEN_RESOURCES.map((resource) => {
const isChecked = permissionRows.some(
(row: PermissionRow) => row.resource === resource.resource
)
return (
<CommandItem_Shadcn_
key={resource.resource}
value={`${resource.resource} ${resource.title}`}
onSelect={() => handleToggleResource(resource)}
className="text-foreground"
>
<div className="flex items-center gap-3 w-full">
<Checkbox_Shadcn_
checked={isChecked}
onCheckedChange={() => handleToggleResource(resource)}
onClick={(e) => e.stopPropagation()}
/>
<Key size={12} className="text-foreground-lighter" />
<div className="flex flex-col text-left flex-1">
<span className="font-medium text-foreground capitalize">
{resource.title}
</span>
</div>
</div>
</CommandItem_Shadcn_>
)
})}
</div>
</CommandGroup_Shadcn_>
</CommandList_Shadcn_>
</Command_Shadcn_>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)
}
@@ -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 = <TFormValues extends PermissionsFormValues = PermissionsFormValues>({
setValue,
watch,
resourceSearchOpen,
setResourceSearchOpen,
}: PermissionsProps<TFormValues>) => {
const permissionRows = (watch('permissionRows' as Path<TFormValues>) || []) as PermissionRow[]
return (
<div className="space-y-4 px-5 sm:px-6 py-6">
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm">Configure permissions</span>
<div className="flex items-center gap-2">
{permissionRows.length > 0 && (
<ButtonTooltip
type="default"
size="tiny"
className="p-1"
onClick={() => {
setValue(
'permissionRows' as Path<TFormValues>,
[] as PathValue<TFormValues, Path<TFormValues>>
)
}}
icon={<RotateCcw size={16} />}
tooltip={{
content: {
side: 'top',
align: 'center',
alignOffset: -10,
text: 'Reset all permissions',
},
}}
/>
)}
<PermissionResourceSelector
open={resourceSearchOpen}
onOpenChange={setResourceSearchOpen}
permissionRows={permissionRows}
setValue={setValue}
align="end"
/>
</div>
</div>
{permissionRows.length === 0 ? (
<div className="text-center py-8 border border-dashed border-border rounded-lg">
<p className="text-sm text-foreground-light">No permissions configured yet.</p>
</div>
) : (
<div className="border border-border rounded-lg">
{permissionRows.map((row, index) => {
const selectedResource = ACCESS_TOKEN_RESOURCES.find(
(r) => r.resource === row.resource
)
return (
<div key={row.resource ?? `resource-${index}`}>
<div className="flex items-center gap-3 p-3">
<div className="flex-1">
<div className="flex items-center gap-2">
<div className="flex flex-col">
<span className="text-sm font-medium truncate max-w-[36ch] capitalize">
{selectedResource?.title}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{selectedResource && (
<Popover_Shadcn_>
<PopoverTrigger_Shadcn_ asChild>
<Button
type="default"
size="tiny"
className="w-[150px] flex text-sm justify-between h-7 "
iconRight={
<ChevronDown size={14} className="text-foreground-muted" />
}
>
{row.actions.length === 0 ? (
<span className="text-foreground-lighter">Select access</span>
) : row.actions.length === 1 ? (
formatAccessText(row.actions[0])
) : (
`${row.actions.length} selected`
)}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="w-[180px] p-2" align="end">
<div className="space-y-2">
{sortActions(selectedResource.actions).map((action) => (
<label
key={action}
className="flex items-center gap-2 cursor-pointer"
>
<Checkbox_Shadcn_
checked={row.actions.includes(action)}
onCheckedChange={(checked) => {
const newActions = checked
? [...row.actions, action]
: row.actions.filter((a) => a !== action)
const newRows: PermissionRow[] = permissionRows.map((r, i) =>
i === index
? { resource: r.resource, actions: newActions }
: r
)
setValue(
'permissionRows' as Path<TFormValues>,
newRows as PathValue<TFormValues, Path<TFormValues>>,
{ shouldValidate: true, shouldDirty: true }
)
}}
/>
<span className="text-sm">{formatAccessText(action)}</span>
</label>
))}
</div>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)}
<Button
type="text"
size="tiny"
className="p-1"
onClick={() => {
const newRows = permissionRows.filter((_, i) => i !== index)
setValue(
'permissionRows' as Path<TFormValues>,
newRows as PathValue<TFormValues, Path<TFormValues>>,
{ shouldValidate: true, shouldDirty: true }
)
}}
icon={<X size={16} />}
/>
</div>
</div>
{index < permissionRows.length - 1 && <div className="border-t border-border" />}
</div>
)
})}
</div>
)}
</div>
<div className="w-full flex gap-x-2 items-center">
<WarningIcon />
<span className="text-xs text-left text-foreground-lighter">
Once you've set these permissions, you cannot edit them.
</span>
</div>
</div>
)
}
@@ -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<TFormValues>
watch: UseFormWatch<TFormValues>
resourceSearchOpen: boolean
setResourceSearchOpen: (open: boolean) => void
}
export interface PermissionResourceSelectorProps<TFormValues extends PermissionsFormValues> {
open: boolean
onOpenChange: (open: boolean) => void
permissionRows: PermissionRow[]
setValue: UseFormSetValue<TFormValues>
align?: 'center' | 'end' | 'start'
}
@@ -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 }]
}
@@ -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 (
<div className="space-y-4 px-5 sm:px-6 py-6">
<FormField_Shadcn_
key="resourceAccess"
name="resourceAccess"
control={control}
render={({ field }) => (
<FormItemLayout name="resourceAccess" label="Resource access">
<FormControl_Shadcn_>
<div className="space-y-3">
<fieldset className="flex gap-3">
<legend className="sr-only">Resource access level</legend>
{RESOURCE_OPTIONS.map((option) => (
<ResourceOption
key={option.value}
value={option.value}
label={option.label}
isSelected={field.value === option.value}
onChange={() => {
handleResourceAccessChange(option.value, field.value)
field.onChange(option.value)
}}
/>
))}
</fieldset>
<p className="text-foreground-light text-sm">
{RESOURCE_OPTIONS.find((opt) => opt.value === field.value)?.description}
</p>
</div>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
{resourceAccess === 'selected-orgs' && (
<FormField_Shadcn_
key="selectedOrganizations"
name="selectedOrganizations"
control={control}
render={({ field }) => (
<ResourceMultiSelector
field={field}
items={organizations.map((org) => ({ id: org.slug, name: org.name }))}
isLoading={isLoadingOrgs}
fieldName="selectedOrganizations"
label="Select organizations"
loadingMessage="Loading organizations..."
emptyMessage="No organizations available"
/>
)}
/>
)}
{resourceAccess === 'selected-projects' && (
<FormField_Shadcn_
key="selectedProjects"
name="selectedProjects"
control={control}
render={({ field }) => (
<ResourceMultiSelector
field={field}
items={projects.map((project) => ({ id: project.ref, name: project.name }))}
isLoading={isLoadingProjects}
fieldName="selectedProjects"
label="Select projects"
loadingMessage="Loading projects..."
emptyMessage="No projects available"
/>
)}
/>
)}
</div>
)
}
@@ -0,0 +1,30 @@
import { Control, ControllerRenderProps, UseFormSetValue } from 'react-hook-form'
import { TokenFormValues } from '../../../AccessToken.schemas'
export interface ResourceAccessProps {
control: Control<TokenFormValues>
resourceAccess: string
setValue: UseFormSetValue<TokenFormValues>
}
export interface ResourceItem {
id: string
name: string
}
export interface ResourceMultiSelectorProps {
field: ControllerRenderProps<any, any>
items: ResourceItem[]
isLoading: boolean
fieldName: string
label: string
loadingMessage: string
emptyMessage: string
}
export interface ResourceOptionProps {
value: string
label: string
isSelected: boolean
onChange: () => void
}
@@ -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 (
<FormItemLayout name={fieldName} label={label}>
<FormControl_Shadcn_ className="overflow-visible">
<MultiSelector values={displayValues} onValuesChange={handleValuesChange}>
<MultiSelectorTrigger
deletableBadge
showIcon={false}
mode="inline-combobox"
label={label}
badgeLimit="wrap"
/>
<MultiSelectorContent className="z-50">
{isLoading ? (
<div className="px-3 py-2 text-sm text-foreground-light">{loadingMessage}</div>
) : items.length === 0 ? (
<div className="px-3 py-2 text-sm text-foreground-light">{emptyMessage}</div>
) : (
<MultiSelectorList>
{items.map((item) => (
<MultiSelectorItem key={item.id} value={item.name}>
{item.name}
</MultiSelectorItem>
))}
</MultiSelectorList>
)}
</MultiSelectorContent>
</MultiSelector>
</FormControl_Shadcn_>
</FormItemLayout>
)
}
@@ -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
}) => (
<label
className={cn(
'border border-default rounded-md bg-surface-200 hover:bg-overlay-hover hover:border-control px-4 py-3 cursor-pointer transition-colors flex-1 flex flex-col',
isSelected && 'border-foreground-muted hover:border-foreground-muted bg-surface-300'
)}
>
<div className="flex justify-between items-start mb-3">
<Box size={16} className="text-foreground-light" />
{isSelected && (
<div className="flex items-center justify-center p-0.5 bg-foreground text-background rounded-full">
<Check size={12} strokeWidth="4" className="text-background" />
</div>
)}
</div>
<span className={cn('text-sm', isSelected ? 'text-foreground' : 'text-foreground-light')}>
{label}
</span>
<input
type="radio"
name="resourceAccess"
value={value}
checked={isSelected}
onChange={onChange}
className="sr-only"
/>
</label>
)
@@ -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 (
<>
<Button
type="primary"
onClick={() => {
setVisible(true)
}}
>
Generate new token
</Button>
<NewScopedTokenSheet
visible={visible}
onOpenChange={setVisible}
tokenScope={undefined}
onCreateToken={onCreateToken}
/>
</>
)
}
@@ -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<TokenFormValues>({
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<TokenFormValues> = 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 (
<Sheet
open={visible}
onOpenChange={(open) => {
if (!open) {
handleClose()
} else {
onOpenChange(open)
}
}}
>
<SheetContent
showClose={false}
size="default"
className="!min-w-[600px] flex flex-col h-full gap-0"
>
<SheetHeader>
<SheetTitle>
{tokenScope === 'V0' ? 'Generate token for experimental API' : 'Generate New Token'}
</SheetTitle>
<SheetDescription className="sr-only">
A form to generate a new scoped access token.
</SheetDescription>
</SheetHeader>
<ScrollArea className="flex-1 max-h-[calc(100vh-116px)]">
<div className="flex flex-col overflow-visible">
{tokenScope === 'V0' && (
<div className="px-4 sm:px-5 py-4 pb-4">
<Admonition
type="warning"
title="The experimental API provides additional endpoints which allows you to manage your organizations and projects."
description={
<>
<p>
These include deleting organizations and projects which cannot be undone. As
such, be very careful when using this API.
</p>
<div className="mt-4">
<Button asChild type="default" icon={<ExternalLink />}>
<Link
href="https://api.supabase.com/api/v0"
target="_blank"
rel="noreferrer"
>
Experimental API documentation
</Link>
</Button>
</div>
</>
}
/>
</div>
)}
<Form_Shadcn_ {...form}>
<div className="flex flex-col gap-0 overflow-visible">
<BasicInfo
control={form.control}
expirationDate={expiresAt || ''}
onCustomDateChange={handleCustomDateChange}
onCustomExpiryChange={handleCustomExpiryChange}
/>
<Separator />
<ResourceAccess
control={form.control}
resourceAccess={resourceAccess}
setValue={form.setValue}
/>
<Separator />
<Permissions
setValue={form.setValue}
watch={form.watch}
resourceSearchOpen={resourceSearchOpen}
setResourceSearchOpen={setResourceSearchOpen}
/>
</div>
</Form_Shadcn_>
</div>
</ScrollArea>
<SheetFooter className="!justify-end w-full mt-auto py-4 border-t">
<div className="flex gap-2">
<Button type="default" disabled={isPending} onClick={handleClose}>
Cancel
</Button>
<Button onClick={form.handleSubmit(onSubmit)} loading={isPending}>
Generate token
</Button>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
@@ -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<ScopedAccessToken | undefined>(undefined)
const [viewToken, setViewToken] = useState<ScopedAccessToken | undefined>(undefined)
const [isViewSheetOpen, setIsViewSheetOpen] = useState(false)
const [sort, setSort] = useQueryState(
'sort',
parseAsStringLiteral<AccessTokenSort>(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 (
<TableContainer sort={sort} onSortChange={onSortChange}>
<TableRow>
<TableCell colSpan={4} className="p-0">
<AlertError
error={error}
subject="Failed to retrieve access tokens"
className="rounded-none border-0"
/>
</TableCell>
</TableRow>
</TableContainer>
)
}
if (isLoading) {
return (
<TableContainer sort={sort} onSortChange={onSortChange}>
<RowLoading />
<RowLoading />
</TableContainer>
)
}
if (empty) {
return (
<TableContainer sort={sort} onSortChange={onSortChange}>
<TableRow>
<TableCell colSpan={4} className="py-12">
<p className="text-sm text-center text-foreground">No scoped access tokens found</p>
<p className="text-sm text-center text-foreground-light">
You do not have any scoped tokens created yet
</p>
</TableCell>
</TableRow>
</TableContainer>
)
}
return (
<>
<TableContainer sort={sort} onSortChange={onSortChange}>
{filteredTokens?.map((x) => (
<TableRow key={x.id}>
<TokenNameCell name={x.name} tokenAlias={x.token_alias} />
<LastUsedCell lastUsedAt={x.last_used_at} />
<ExpiresCell expiresAt={x.expires_at} />
<TableCell>
<div className="flex items-center justify-end gap-x-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="default"
title="More options"
className="w-7"
icon={<MoreVertical />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-40">
<DropdownMenuItem
className="gap-x-2"
onClick={() => {
setViewToken(x)
setIsViewSheetOpen(true)
}}
>
<Key size={12} />
<p>View permissions</p>
</DropdownMenuItem>
<DropdownMenuItem
className="gap-x-2"
onClick={() => {
setToken(x)
setIsOpen(true)
}}
>
<Trash size={12} />
<p>Delete token</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
))}
</TableContainer>
<ConfirmationModal
visible={isOpen}
variant="destructive"
title="Confirm to delete"
confirmLabel="Delete"
confirmLabelLoading="Deleting"
onCancel={() => setIsOpen(false)}
onConfirm={() => {
if (token) deleteToken({ id: token.id as string })
}}
>
<p className="py-4 text-sm text-foreground-light">
This action cannot be undone. Are you sure you want to delete "{token?.name}" token?
</p>
</ConfirmationModal>
<ViewTokenSheet
visible={isViewSheetOpen}
tokenId={viewToken ? String(viewToken.id) : undefined}
onClose={() => {
setIsViewSheetOpen(false)
setViewToken(undefined)
}}
/>
</>
)
}
@@ -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<string, string[]> = {}
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 (
<>
<Sheet open={visible} onOpenChange={() => onClose()}>
<SheetContent
showClose={false}
size="default"
className="!min-w-[600px] flex flex-col h-full gap-0"
>
<SheetHeader
className={cn('flex flex-row justify-between gap-x-4 items-center border-b')}
>
<p className="truncate" title={`Manage access for ${token?.name}`}>
View access for {token?.name}
</p>
<DocsButton href="https://supabase.com/docs/reference/api/introduction" />
</SheetHeader>
<ScrollArea className="flex-1 max-h-[calc(100vh-60px)]">
<div className="space-y-8 px-5 sm:px-6 py-6">
{isTokenLoading && (
<div className="flex items-center justify-center py-8">
<p className="text-foreground-light">Loading token information...</p>
</div>
)}
{tokenError && (
<div className="flex items-center justify-center py-8">
<p className="text-foreground-light text-red-500">
Error loading token information. Please try again.
</p>
</div>
)}
{token && (
<>
<div className="space-y-3">
<h3 className="text-sm font-medium text-foreground">Token Information</h3>
<Card className="w-full overflow-hidden bg-surface-100">
<CardContent className="p-0">
<Table className="p-5 table-auto">
<TableHeader>
<TableRow className="bg-200">
<TableHead className="text-left font-mono uppercase text-xs text-foreground-lighter h-auto py-2 w-[60%]">
Info
</TableHead>
<TableHead className="text-left font-mono uppercase text-xs text-foreground-lighter h-auto py-2">
Date
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>
<p className="truncate text-foreground-light">Created</p>
</TableCell>
<TableCell>
{token?.created_at ? (
<TimestampInfo
utcTimestamp={token.created_at}
label={dayjs(token.created_at).format('DD MMM YYYY')}
className="text-sm"
/>
) : (
<span className="text-foreground">Unknown</span>
)}
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<p className="truncate text-foreground-light">Last used</p>
</TableCell>
<TableCell>
{token?.last_used_at ? (
<TimestampInfo
utcTimestamp={token.last_used_at}
label={dayjs(token.last_used_at).fromNow()}
className="text-sm"
/>
) : (
<span className="text-foreground">Never</span>
)}
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<p className="truncate text-foreground-light">Expires</p>
</TableCell>
<TableCell>
{token?.expires_at ? (
<TimestampInfo
utcTimestamp={token.expires_at}
label={dayjs(token.expires_at).format('DD MMM YYYY')}
className="text-sm"
/>
) : (
<span className="text-foreground">Never</span>
)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
</div>
<div className="space-y-3">
<h3 className="text-sm font-medium text-foreground">Resource Access</h3>
<Card className="w-full overflow-hidden bg-surface-100">
<CardContent className="p-0">
<Table className="p-5 table-auto">
<TableHeader>
<TableRow className="bg-200">
<TableHead className="text-left font-mono uppercase text-xs text-foreground-lighter h-auto py-2 w-[60%]">
Resource
</TableHead>
<TableHead className="text-left font-mono uppercase text-xs text-foreground-lighter h-auto py-2">
Type
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{getResourceAccessInfo().length > 0 ? (
getResourceAccessInfo().map((resource, index) => (
<TableRow key={`${resource.type}-${resource.identifier}-${index}`}>
<TableCell>
<p className="truncate text-foreground">{resource.name}</p>
</TableCell>
<TableCell>
<span className="text-foreground-light">{resource.type}</span>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={2}>
<p className="text-foreground-light text-center py-4">
{(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.'}
</p>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
<div className="space-y-3">
<h3 className="text-sm font-medium text-foreground">Permissions</h3>
<Card className="w-full overflow-hidden bg-surface-100">
<CardContent className="p-0">
<Table className="p-5 table-auto">
<TableHeader>
<TableRow className="bg-200">
<TableHead className="text-left font-mono uppercase text-xs text-foreground-lighter h-auto py-2 w-[60%]">
Permission
</TableHead>
<TableHead className="text-left font-mono uppercase text-xs text-foreground-lighter h-auto py-2">
Access
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Object.keys(groupedResourcesByAccess).length === 0 ? (
<TableRow>
<TableCell colSpan={2}>
<p className="text-foreground-light text-center py-4">
No permissions configured for this token.
</p>
</TableCell>
</TableRow>
) : (
Object.entries(groupedResourcesByAccess).map(
([accessLevel, resources]) => {
return resources.map((resource) => (
<TableRow key={`${accessLevel}-${resource}`}>
<TableCell>
<p className="truncate text-foreground capitalize">
{resource}
</p>
</TableCell>
<TableCell>
<span className="text-foreground-light">
{formatAccessText(accessLevel)}
</span>
</TableCell>
</TableRow>
))
}
)
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</>
)}
</div>
</ScrollArea>
</SheetContent>
</Sheet>
</>
)
}
@@ -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<string, string[]> = {}
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 }
}
@@ -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,
}
}
@@ -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 (
<div className="flex flex-col">
<div className="flex-shrink-0">
{ongoingIncident ? <IncidentBanner /> : ongoingMaintenance ? <MaintenanceBanner /> : null}
<StatusPageBanner />
{showNoticeBanner && <NoticeBanner />}
<OrganizationResourceBanner />
{clockSkewBanner && <ClockSkewBanner />}
@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="primary" iconRight={<ChevronDown className="w-4 h-4" strokeWidth={1.5} />}>
<DropdownMenuTrigger asChild disabled={!isProjectActive}>
<ButtonTooltip
type="primary"
disabled={!isProjectActive}
iconRight={<ChevronDown className="w-4 h-4" strokeWidth={1.5} />}
tooltip={{
content: {
side: 'bottom',
text: !isProjectActive
? 'Unable to deploy function as project is inactive'
: undefined,
},
}}
>
Deploy a new function
</Button>
</ButtonTooltip>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<DropdownMenuItem
@@ -136,37 +136,175 @@ app.get(/slug/(.*)/, (req, res) => {
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 <key> 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',
@@ -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 && <PageHeaderAside>{secondaryActions}</PageHeaderAside>}
</PageHeaderMeta>
</PageHeader>
<PreviousRunsTab />
<Sheet open={isEditSheetOpen} onOpenChange={setIsEditSheetOpen}>
@@ -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 = ({
<ContextMenuTrigger_Shadcn_ asChild>
<div className={cn('w-full flex items-center text-xs')}>
{['latest_run', 'next_run'].includes(col.id) ? (
col.id === 'latest_run' && formattedValue === null ? (
!hasValue ? (
<Minus size={14} className="text-foreground-lighter" />
) : col.id === 'latest_run' && formattedValue === null ? (
<p className="text-foreground-lighter">Job has not been run yet</p>
) : col.id === 'next_run' && !formattedValue ? (
<p className="text-foreground-lighter">Unable to parse next run for job</p>
@@ -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 (
<Admonition
type="note"
className="rounded-none border-x-0 border-t-0 py-2 [&>svg]:top-[0.6rem] [&>svg]:left-10 pl-10 pr-10"
layout="horizontal"
actions={<CronJobRunDetailsOverflowDialog {...props} />}
>
<p className="text-xs">Last run for each cron job omitted due to high query cost</p>
</Admonition>
)
}
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 (
<Admonition
type="warning"
title={noticeTitle}
description={noticeDescription}
className="max-w-3xl w-full"
>
<div className="space-y-4 text-sm">
<p>
Remove old run history now, then schedule a cron job that keeps trimming{' '}
<code>cron.job_run_details</code> automatically so the overview remains responsive.
</p>
<Dialog>
<DialogTrigger asChild>
<Button type="default">Learn more</Button>
</DialogTrigger>
<DialogContent aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>Last run for cron jobs omitted for overview</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
<DialogSection className="flex flex-col gap-y-2">
<p className="text-sm">
The dashboard fetches data for the cron jobs overview by running a join between the{' '}
<code className="text-code-inline">cron.job</code> and{' '}
<code className="text-code-inline !break-keep">cron.job_run_details</code> tables to
show each cron job's latest run.
</p>
{/* Step 1: Delete older entries */}
<div className="space-y-2">
<p className="font-medium text-foreground">Step 1: Delete older entries</p>
<p className="text-sm">
However, the join was skipped as the estimated query cost exceeds safety thresholds,
likely due to the size of{' '}
<code className="text-code-inline !break-keep">cron.job_run_details</code> table.
</p>
</DialogSection>
{isDeleting ? (
<DeletionProgress progress={cleanupState.progress} onCancel={onCancelDeletion} />
) : isDeleteSuccess ? (
<DeletionSuccess totalRowsDeleted={cleanupState.totalRowsDeleted} />
) : isDeleteError ? (
<DeletionError error={cleanupState.error} onRetry={onRetryDeletion} />
) : (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="sm:w-64">
<Select_Shadcn_
value={selectedInterval}
onValueChange={onIntervalChange}
disabled={isBusy}
>
<SelectTrigger_Shadcn_ className="w-full">
<SelectValue_Shadcn_ placeholder="Select an interval" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{CLEANUP_INTERVALS.map((option) => (
<SelectItem_Shadcn_ key={option.value} value={option.value}>
{option.label}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
</div>
<Button type="default" disabled={isBusy} onClick={onRunDeleteSql}>
Delete rows now
</Button>
</div>
)}
</div>
<DialogSectionSeparator />
{/* Step 2: Schedule automated cleanup (only available after successful deletion) */}
<div className="space-y-2">
<p className="font-medium text-foreground">Step 2: Schedule an automated cleanup</p>
<DialogSection className="flex flex-col gap-y-4">
<p className="font-mono text-foreground-lighter uppercase tracking-tight text-sm">
Suggested steps
</p>
{!canSchedule ? (
<p className="text-foreground-lighter text-xs">
Complete step 1 to enable scheduling a daily cleanup job.
</p>
) : isScheduleSuccess ? (
<ScheduleSuccess onRefresh={onRefresh} />
) : (
<>
<CodeBlock
hideLineNumbers
language="sql"
value={getScheduleDeleteCronJobRunDetailsSql(selectedInterval)}
className="py-3 px-4 text-xs"
wrapperClassName="max-w-full"
<p className="text-sm">
We recommend removing the old run history now, then scheduling a cron job that keeps
trimming the <code className="text-code-inline">cron.job_run_details</code> table
automatically. This also prevents unnecessary bloat on the database.
</p>
<div className="flex flex-col gap-y-2 text-sm">
<p className="text-foreground">Step 1: Delete older entries</p>
{isDeleting ? (
<DeletionProgress progress={cleanupState.progress} onCancel={cancelDeletion} />
) : isDeleteSuccess ? (
<DeletionSuccess totalRowsDeleted={cleanupState.totalRowsDeleted} />
) : isDeleteError ? (
<DeletionError
error={cleanupState.error}
onRetry={() => runBatchedDeletion(cleanupInterval)}
/>
<Button
type="default"
className="mt-1"
loading={isScheduling}
disabled={isScheduling}
onClick={onRunScheduleSql}
>
Schedule cleanup job
</Button>
</>
)}
</div>
</div>
</Admonition>
) : (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="sm:w-64">
<Select_Shadcn_
disabled={isBusy}
value={cleanupInterval}
onValueChange={setCleanupInterval}
>
<SelectTrigger_Shadcn_ className="w-full">
<SelectValue_Shadcn_ placeholder="Select an interval" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{CLEANUP_INTERVALS.map((option) => (
<SelectItem_Shadcn_ key={option.value} value={option.value}>
{option.label}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
</div>
<Button
type="default"
disabled={isBusy}
onClick={() => runBatchedDeletion(cleanupInterval)}
>
Delete rows now
</Button>
</div>
)}
</div>
<div className="flex flex-col gap-y-2 text-sm">
<p className="text-foreground">Step 2: Schedule an automated cleanup</p>
{!canSchedule ? (
<p className="text-foreground-lighter text-xs">
Complete step 1 to enable scheduling a daily cleanup job.
</p>
) : isScheduleSuccess ? (
<ScheduleSuccess />
) : (
<>
<CodeBlock
hideLineNumbers
language="sql"
value={getScheduleDeleteCronJobRunDetailsSql(cleanupInterval)}
className="py-3 px-4 text-xs"
wrapperClassName="max-w-full"
/>
<Button
block
size="small"
type="default"
className="mt-1"
loading={isScheduling}
disabled={isScheduling}
onClick={async () => {
await scheduleCleanup({
interval: cleanupInterval,
onSuccess: () => refetchJobs(),
})
}}
>
Schedule cleanup job
</Button>
</>
)}
</div>
</DialogSection>
</DialogContent>
</Dialog>
)
}
@@ -205,11 +252,7 @@ const DeletionError = ({ error, onRetry }: DeletionErrorProps) => (
</div>
)
interface ScheduleSuccessProps {
onRefresh: () => void
}
const ScheduleSuccess = ({ onRefresh }: ScheduleSuccessProps) => (
const ScheduleSuccess = () => (
<div className="space-y-2">
<div className="flex items-center gap-2 text-brand">
<CheckCircle2 size={16} />
@@ -217,9 +260,8 @@ const ScheduleSuccess = ({ onRefresh }: ScheduleSuccessProps) => (
</div>
<div className="flex items-center gap-2">
<p className="text-foreground-lighter text-xs">
Refresh to reload the cron jobs and view the new cleanup job.
New cleanup job should now be visible in the cron jobs overview.
</p>
<Button type="default" size="tiny" icon={<RefreshCw size={14} />} onClick={onRefresh} />
</div>
</div>
)
@@ -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<HTMLDivElement>) => void
onRowClick: (row: CronJob, event: MouseEvent<HTMLDivElement>) => void
overlay?: ReactNode
}
export const CronJobsTabDataGrid = ({
@@ -26,16 +25,13 @@ export const CronJobsTabDataGrid = ({
searchQuery,
onScroll,
onRowClick,
overlay,
}: CronJobsTabDataGridProps) => {
const fallbackContent = overlay ? (
<div className="absolute top-20 px-6 w-full flex justify-center">{overlay}</div>
) : isLoading ? (
const fallbackContent = isLoading ? (
<div className="absolute top-12 px-6 w-full">
<GenericSkeletonLoader />
</div>
) : error ? (
<div className="absolute top-28 px-10 flex flex-col items-center justify-center w-full">
<div className="absolute top-14 px-10 flex flex-col items-center justify-center w-full">
<AlertError subject="Failed to retrieve cron jobs" error={error} />
</div>
) : (
@@ -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 (
<CronJobRunDetailsOverflowNotice
mode="confirmed"
estimatedRows={dataStatus.estimatedRows}
selectedInterval={cleanupInterval}
onIntervalChange={setCleanupInterval}
cleanupState={cleanupState}
onRunDeleteSql={() => runBatchedDeletion(cleanupInterval)}
onRunScheduleSql={() => scheduleCleanup(cleanupInterval)}
onCancelDeletion={cancelDeletion}
onRetryDeletion={() => runBatchedDeletion(cleanupInterval)}
onRefresh={grid.refetch}
/>
)
case 'overflow-suspected':
return (
<CronJobRunDetailsOverflowNotice
mode="suspected"
estimatedRows={dataStatus.estimatedRows}
selectedInterval={cleanupInterval}
onIntervalChange={setCleanupInterval}
cleanupState={cleanupState}
onRunDeleteSql={() => runBatchedDeletion(cleanupInterval)}
onRunScheduleSql={() => scheduleCleanup(cleanupInterval)}
onCancelDeletion={cancelDeletion}
onRetryDeletion={() => runBatchedDeletion(cleanupInterval)}
onRefresh={grid.refetch}
/>
)
case 'estimate-error':
return (
<CronJobRunDetailsEstimateErrorNotice
error={dataStatus.error}
isRetrying={dataStatus.isRetrying}
onRetry={dataStatus.retry}
/>
)
case 'loading':
case 'ready':
default:
return undefined
}
}, [
dataStatus,
cleanupInterval,
setCleanupInterval,
cleanupState,
runBatchedDeletion,
scheduleCleanup,
cancelDeletion,
grid.refetch,
])
const deletingCronJobIdRef = useRef<string | null>(null)
const { setValue: setCronJobForEditing, value: cronJobForEditing } = useQueryStateWithSelect({
@@ -184,8 +111,6 @@ export const CronjobsTab = () => {
const xScroll = useRef<number>(0)
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
if (!isQueryEnabled) return
const isScrollingHorizontally = xScroll.current !== event.currentTarget.scrollLeft
xScroll.current = event.currentTarget.scrollLeft
@@ -259,6 +184,7 @@ export const CronjobsTab = () => {
onCreateJob={onOpenCreateJobSheet}
/>
<LoadingLine loading={grid.isLoading || grid.isRefetching || grid.isFetchingNextPage} />
{grid.isMinimal && <CronJobRunDetailsOverflowNoticeV2 refetchJobs={grid.refetch} />}
<CronJobsTabDataGrid
columns={columns}
rows={grid.rows}
@@ -267,7 +193,6 @@ export const CronjobsTab = () => {
searchQuery={searchQuery}
onScroll={handleScroll}
onRowClick={handleRowClick}
overlay={gridOverlay}
/>
<CronJobsFooter count={count} />
</div>
@@ -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,
@@ -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 }
@@ -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<HCaptcha>(null)
const captchaTokenRef = useRef<string | null>(null)
const codeRedemptionDisabled =
!canRedeemCode || !isPermissionsLoaded || isOrgLoading || isCustomerProfileLoading
const form = useForm<CreditCodeRedemptionForm>({
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<HCaptcha>(null)
const captchaTokenRef = useRef<string | null>(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<CreditCodeRedemptionForm> = 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 (
<Dialog open={codeRedemptionModalVisible} onOpenChange={onCodeRedemptionDialogVisibilityChange}>
@@ -191,7 +171,7 @@ export const CreditCodeRedemption = ({
</DialogTrigger>
)}
<DialogContent onInteractOutside={(e) => e.preventDefault()} size={'large'}>
<DialogContent size="medium" onInteractOutside={(e) => e.preventDefault()}>
<HCaptcha
ref={captchaRef}
sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY!}
@@ -212,21 +192,21 @@ export const CreditCodeRedemption = ({
}}
/>
{codeRedemptionResult ? (
{!!codeRedemptionResult ? (
<div className="p-8">
<div className="text-center flex items-center justify-center">
<PartyPopper className="h-20 w-20" />
<PartyPopper strokeWidth={1} className="h-14 w-14" />
</div>
<div className="text-center">
<p className="font-bold text-lg mt-2">Credits Redeemed!</p>
<p className=" text-lg mt-2">Credits redeemed!</p>
</div>
<Separator className="my-4" />
<div className="flex w-full justify-center items-center">
<div className="flex items-center space-x-1">
<h4 className="opacity-50">$</h4>
<h1 className="relative text-2xl">{codeRedemptionResult.amount_cents / 100}</h1>
<h4 className="opacity-50"> credits applied</h4>
<p className="opacity-50 text-sm">$</p>
<p className="text-2xl">{codeRedemptionResult.amount_cents / 100}</p>
<p className="opacity-50 text-sm"> credits applied</p>
</div>
</div>
@@ -234,26 +214,29 @@ export const CreditCodeRedemption = ({
<div className="mt-2 flex items-center justify-center gap-2 text-sm text-muted-foreground bg-muted/50 py-3 px-4 rounded-lg">
<Calendar className="h-4 w-4" />
<span>
{'Expires on '}
{new Date(codeRedemptionResult.credits_expire_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
Expires on{' '}
<TimestampInfo
className="text-sm"
utcTimestamp={codeRedemptionResult.credits_expire_at}
labelFormat="MMMM DD, YYYY"
/>
</span>
</div>
)}
{org?.plan.id === 'free' && (
<div className="mt-4 space-y-4">
<Separator />
<Button className="w-full" size={'medium'} type="primary" asChild>
<Link href={`/org/${org?.slug}/billing?panel=subscriptionPlan`}>
Upgrade Your Organization
</Link>
<div className="mt-4 flex flex-col gap-y-4">
<Separator />
<div className="flex justify-center items-center gap-x-2">
{org?.plan.id === 'free' && (
<UpgradePlanButton plan="Pro" source="code-redeem" slug={org.slug}>
Upgrade organization
</UpgradePlanButton>
)}
<Button asChild type="default">
<Link href={`/org/${org?.slug}`}>Go to organization</Link>
</Button>
</div>
)}
</div>
</div>
) : (
<>
@@ -282,10 +265,10 @@ export const CreditCodeRedemption = ({
control={form.control}
name="code"
render={({ field }) => (
<FormItemLayout label="Code" className="gap-1">
<FormItemLayout label="Code" className="gap-1" layout="horizontal">
<Input_Shadcn_
{...field}
className="uppercase"
className="uppercase w-56 ml-auto"
placeholder="ABCD-1234-EFGH-5678"
/>
</FormItemLayout>
@@ -293,35 +276,34 @@ export const CreditCodeRedemption = ({
/>
{customerProfile && customerProfile.balance < 0 && (
<div className="mt-2 flex w-full justify-between items-center">
<span>Current Balance</span>
<div className="flex items-center space-x-1">
<h4 className="opacity-50">$</h4>
<h1 className="relative">{customerProfile.balance / -100}</h1>
<h4 className="opacity-50">/credits</h4>
<div className="flex w-full justify-between items-center">
<span className="text-sm">Current Balance</span>
<div className="flex items-center gap-x-1">
<p className="opacity-50 text-sm">$</p>
<p className="text-2xl">{customerProfile.balance / -100}</p>
<p className="opacity-50 text-sm">/credits</p>
</div>
</div>
)}
<Alert variant={'default'} className="mt-4">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Potential future charges</AlertTitle>
<AlertDescription>
Credits are applied to <strong>{org?.name}</strong> 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.
</AlertDescription>
</Alert>
<Admonition type="note" title="Potential future charges">
<p>
Credits are applied to <strong>{org?.name}</strong> only and cannot be
shared or transferred to other organizations. Credits are automatically used
toward invoices.
</p>
<p className="mt-2">
When credits run out on a paid plan, your default payment method will be
charged—your plan won't be downgraded automatically.
</p>
</Admonition>
{errorRedeemingCode && (
<Alert_Shadcn_ variant="destructive">
<AlertCircle className="h-4 w-4 text-foreground-light" />
<AlertTitle_Shadcn_>Code cannot be redeemed</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
{errorRedeemingCode?.message}
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
<Admonition
type="warning"
title="Unable to redeem code"
description={errorRedeemingCode?.message}
/>
)}
</DialogSection>
@@ -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={<Boxes size={18} strokeWidth={1} className="text-foreground" />}
title={organization.name}
onClick={onClick}
description={
<div className="flex items-center justify-between text-xs text-foreground-light font-sans">
<div className="flex items-center gap-x-1.5">
@@ -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'
@@ -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'
@@ -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 = () => {
@@ -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'
@@ -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
@@ -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'
@@ -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 (
<div className="border rounded-md overflow-hidden bg-surface-100 border-l-4 border-l-brand-500">
<div className="px-3 py-2 border-b border-default bg-surface-200 flex items-start justify-between gap-2">
<Card className="overflow-hidden">
<CardHeader className="pt-2.5 flex flex-row gap-2">
<div className="min-w-0 flex-1 flex items-start gap-2">
<Plus size={14} className="text-brand-500 mt-0.5 shrink-0" />
<div>
<div className="text-xs text-foreground font-mono">{fullTableName}</div>
<div className="text-sm text-foreground-muted mt-0.5">
<span className="font-medium text-foreground">New row</span>
<div className="min-w-0 flex-1">
<code className="text-code-inline dark:bg-surface-300 dark:border-foreground-muted/50">
{fullTableName}
</code>
<div className="text-xs text-foreground mt-1 ml-0.5">
<span>New row</span>
</div>
</div>
</div>
<Button
<ButtonTooltip
type="text"
size="tiny"
icon={<X size={14} />}
aria-label="Revert change"
className="px-1.5"
icon={<Undo2 />}
onClick={handleDelete}
className="shrink-0"
aria-label="Remove operation"
tooltip={{
content: {
side: 'bottom',
align: 'end',
text: 'Revert change',
},
}}
/>
</div>
</CardHeader>
<div className="px-3 py-2 text-xs font-mono space-y-1 bg-brand-100/30">
<CardContent className="font-mono text-xs space-y-1 text-brand-link">
{previewColumns.map(([key, value]) => (
<div key={key} className="flex items-start gap-2 text-foreground">
<span className="text-foreground-light">{key}:</span>
<span className="truncate" title={formatOperationItemValue(value)}>
<div key={key} className="flex gap-2 py-0.5">
<span className="text-brand-link select-none font-medium">+</span>
<span className="shrink-0">{key}:</span>
<span className="truncate min-w-0" title={formatOperationItemValue(value)}>
{formatOperationItemValue(value)}
</span>
</div>
))}
{remainingCount > 0 && (
<div className="text-foreground-light">+{remainingCount} more column(s)</div>
<div className="flex gap-2 py-0.5">
<span className="text-brand-link select-none font-medium">+</span>
<span>+{remainingCount} more column(s)</span>
</div>
)}
</div>
</div>
</CardContent>
</Card>
)
}
@@ -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 (
<div className="border rounded-md overflow-hidden bg-surface-100 border-l-4 border-l-destructive-500">
<div className="px-3 py-2 border-b border-default bg-surface-200 flex items-start justify-between gap-2">
<Card className="overflow-hidden border-destructive-500 bg-destructive-500/5">
<CardHeader className="pt-2.5 flex flex-row gap-2 border-b border-destructive-500">
<div className="min-w-0 flex-1 flex items-start gap-2">
<Trash2 size={14} className="text-destructive-500 mt-0.5 shrink-0" />
<div>
<div className="text-xs text-foreground font-mono">{fullTableName}</div>
<div className="text-sm text-foreground-muted mt-0.5">
<span className="font-medium text-foreground">Delete row</span>
<span className="text-foreground-muted mx-2">·</span>
<span className="text-foreground text-xs">where {whereClause}</span>
<div className="min-w-0 flex-1">
<code className="text-code-inline dark:bg-surface-300 dark:border-foreground-muted/50">
{fullTableName}
</code>
<div className="text-xs text-foreground mt-1 ml-0.5">
<span>Delete row</span>
<span className="text-foreground-muted mx-1.5">·</span>
<span>where {whereClause}</span>
</div>
</div>
</div>
<Button
<ButtonTooltip
type="text"
size="tiny"
icon={<X size={14} />}
aria-label="Revert change"
className="px-1.5"
icon={<Undo2 />}
onClick={handleDelete}
className="shrink-0"
aria-label="Remove operation"
tooltip={{
content: {
side: 'bottom',
align: 'end',
text: 'Revert change',
},
}}
/>
</div>
</CardHeader>
<div className="px-3 py-2 text-xs font-mono bg-destructive-100/30">
<div className="text-destructive-500 line-through opacity-70">Row will be deleted</div>
</div>
</div>
<CardContent className="font-mono text-xs bg-destructive-100/30">
<div className="text-destructive py-0.5">Row will be deleted</div>
</CardContent>
</Card>
)
}
@@ -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 (
<div className="border rounded-md overflow-hidden bg-surface-100">
<div className="px-3 py-2 border-b border-default bg-surface-200 flex items-start justify-between gap-2">
<Card className="overflow-hidden">
<CardHeader className="pt-2.5 flex flex-row gap-2">
<div className="min-w-0 flex-1">
<div className="text-xs text-foreground font-mono">{fullTableName}</div>
<div className="text-sm text-foreground-muted mt-0.5">
<span className="font-medium text-foreground">{columnName}</span>
<span className="text-foreground-muted mx-2">•</span>
<span className="text-foreground text-xs">where {whereClause}</span>
<code className="text-code-inline dark:bg-surface-300 dark:border-foreground-muted/50">
{fullTableName}
</code>
<div className="text-xs text-foreground mt-1 ml-0.5">
<span>{columnName}</span>
<span className="text-foreground-muted mx-1.5">·</span>
<span>where {whereClause}</span>
</div>
</div>
<ButtonTooltip
type="text"
size="tiny"
icon={<X size={14} />}
aria-label="Revert change"
className="px-1.5"
icon={<Undo2 />}
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',
},
}}
/>
</div>
</CardHeader>
<div className="font-mono text-xs">
<div className="flex items-start gap-2 px-3 py-0.5 bg-red-400/20">
<span className="text-red-900 select-none font-bold">-</span>
<span className="text-red-900 truncate max-w-full" title={formattedOldValue}>
<CardContent className="font-mono text-xs">
<div className="flex gap-2 py-0.5">
<span className="text-destructive select-none font-medium">-</span>
<span className="text-destructive truncate max-w-full" title={formattedOldValue}>
{formattedOldValue}
</span>
</div>
<div className="flex items-start gap-2 px-3 py-0.5 bg-green-400/20">
<span className="text-green-900 select-none font-bold">+</span>
<span className="text-green-900 truncate max-w-full" title={formattedNewValue}>
<div className="flex gap-2 py-0.5">
<span className="text-brand-link select-none font-medium">+</span>
<span className="text-brand-link truncate max-w-full" title={formattedNewValue}>
{formattedNewValue}
</span>
</div>
</div>
</div>
</CardContent>
</Card>
)
}
@@ -15,7 +15,11 @@ interface OperationListProps {
export const OperationList = ({ operations }: OperationListProps) => {
if (operations.length === 0) {
return <p className="text-sm text-foreground-light">No pending changes</p>
return (
<div className="flex flex-col items-center justify-center h-full">
<p className="text-base text-foreground-muted">No pending changes</p>
</div>
)
}
const addOperations = operations.filter(isAddRowOperation)
@@ -23,12 +27,13 @@ export const OperationList = ({ operations }: OperationListProps) => {
const editOperations = operations.filter(isEditCellContentOperation)
return (
<div className="space-y-6">
<div className="space-y-8">
{deleteOperations.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-foreground-light">
Rows to Delete ({deleteOperations.length})
<h3 className="text-xs text-foreground-lighter">
{deleteOperations.length} row deletion{deleteOperations.length !== 1 ? 's' : ''}
</h3>
<div className="space-y-3">
{deleteOperations.map((op) => (
<DeleteRowOperationItem
@@ -44,8 +49,8 @@ export const OperationList = ({ operations }: OperationListProps) => {
{addOperations.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-foreground-light">
Rows to Add ({addOperations.length})
<h3 className="text-xs text-foreground-lighter">
{addOperations.length} row addition{addOperations.length !== 1 ? 's' : ''}
</h3>
<div className="space-y-3">
{addOperations.map((op) => (
@@ -62,9 +67,10 @@ export const OperationList = ({ operations }: OperationListProps) => {
{editOperations.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-foreground-light">
Cell Edits ({editOperations.length})
<h3 className="text-xs text-foreground-lighter">
{editOperations.length} cell edit{editOperations.length !== 1 ? 's' : ''}
</h3>
<div className="space-y-3">
{editOperations.map((op) => (
<OperationItem
@@ -27,11 +27,12 @@ export const OperationQueueSidePanel = ({ visible, closePanel }: OperationQueueS
size="large"
visible={visible}
onCancel={closePanel}
onOpenAutoFocus={(event) => event.preventDefault()} // Prevent focus on first focussable element since it is the revert changes ButtonTooltip
header={
<div className="flex items-center justify-between w-full">
<div className="flex flex-col gap-1">
<span>Pending changes</span>
<span className="text-xs text-foreground-light">
<span className="text-xs text-foreground-lighter">
{operations.length} operation{operations.length !== 1 ? 's' : ''}
</span>
</div>
@@ -41,25 +42,29 @@ export const OperationQueueSidePanel = ({ visible, closePanel }: OperationQueueS
<div className="flex w-full justify-between border-t border-default px-3 py-4">
<Button type="default" onClick={closePanel}>
Close
<span className="text-foreground-lighter text-xs ml-1.5">{modKey}.</span>
<span className="text-foreground/40 text-[10px] ml-1.5">{modKey}.</span>
</Button>
<div className="flex space-x-3">
<Button type="default" onClick={handleCancel} disabled={isSaving}>
Cancel All
<Button
type="default"
onClick={handleCancel}
disabled={isSaving || operations.length === 0}
>
Revert{operations.length > 1 && ' all'}
</Button>
<Button
onClick={handleSave}
disabled={isSaving || operations.length === 0}
loading={isSaving}
>
Save All
<span className="text-foreground-lighter text-xs ml-1.5">{modKey}S</span>
Save{operations.length > 1 && ' all'}
<span className="text-foreground/40 text-[10px] ml-1.5">{modKey}S</span>
</Button>
</div>
</div>
}
>
<SidePanel.Content className="py-4">
<SidePanel.Content className="py-4 h-full">
<OperationList operations={operations} />
</SidePanel.Content>
</SidePanel>
@@ -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 (
<>
<PageHeader size="small">
<PageHeaderMeta>
<PageHeaderSummary>
<PageHeaderTitle>{title}</PageHeaderTitle>
<PageHeaderDescription>{description}</PageHeaderDescription>
</PageHeaderSummary>
</PageHeaderMeta>
{navigationItems.length > 0 && (
<PageHeaderNavigationTabs>
<NavMenu>
{navigationItems.map((item) => {
const isActive = router.asPath.split('?')[0] === item.href
return (
<NavMenuItem key={item.label} active={isActive}>
<Link href={item.href}>{item.label}</Link>
</NavMenuItem>
)
})}
</NavMenu>
</PageHeaderNavigationTabs>
)}
</PageHeader>
<PageContainer size="small" className="pt-8 pb-16">
{children}
</PageContainer>
</>
)
}
export default AccessTokensLayout
@@ -71,7 +71,8 @@ const AccountLayout = ({ children, title }: PropsWithChildren<AccountLayoutProps
key: 'access-tokens',
label: 'Access Tokens',
href: '/account/tokens',
isActive: currentPath === '/account/tokens',
isActive:
currentPath === '/account/tokens' || currentPath === '/account/tokens/scoped',
},
...(showSecuritySettings
? [
@@ -1,58 +0,0 @@
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { PropsWithChildren } from 'react'
import { Button, cn } from 'ui'
import { ScaffoldContainerLegacy } from '../Scaffold'
export const AccountSettingsLayout = ({ children }: 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 (
<ScaffoldContainerLegacy className="flex flex-row py-8 gap-20">
<nav className="py-0">
<ul className="flex flex-col gap-1">
{links.map((link, i) => (
<li key={`${link.key}-${i}`}>
<Link href={link.href}>
<Button
type={link.isActive ? 'default' : 'text'}
className={cn(
'border-0',
link.isActive
? 'bg-selection text-foreground'
: 'hover:bg-overlay-hover text-foreground-lighter'
)}
>
{link.label}
</Button>
</Link>
</li>
))}
</ul>
</nav>
<div className="flex-1 flex-grow">{children}</div>
</ScaffoldContainerLegacy>
)
}
@@ -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 (
<HeaderBanner
variant="warning"
title="We are investigating a technical issue"
description={
<>
Follow the <InlineLink href="https://status.supabase.com">status page</InlineLink> for
updates
</>
}
/>
)
}
@@ -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 (
<HeaderBanner
variant="note"
title="Scheduled maintenance is in progress"
description={
<>
Follow the <InlineLink href="https://status.supabase.com">status page</InlineLink> for
updates
</>
}
onDismiss={() => setDismissed(true)}
/>
)
}
@@ -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 <InlineLink href="https://status.supabase.com">status page</InlineLink> 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 (
<HeaderBanner
variant="warning"
title="We are investigating a technical issue"
description={BANNER_DESCRIPTION}
onDismiss={
showIncidentBannerOverride || !highImpactIncident
? undefined
: () => setDismissedIncident(true)
}
/>
)
}
if (ongoingMaintenance && !dismissedMaintenance) {
return (
<HeaderBanner
variant="note"
title="Scheduled maintenance is in progress"
description={BANNER_DESCRIPTION}
onDismiss={() => setDismissedMaintenance(true)}
/>
)
}
return null
}
@@ -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 = ({
/>
)}
<DocsButton href={`${DOCS_URL}/guides/functions`} />
{IS_PLATFORM && (
<>
<Popover_Shadcn_>
<PopoverTrigger_Shadcn_ asChild>
<Button type="default" icon={<Download />}>
Download
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ align="end" className="p-0">
<Popover_Shadcn_>
<PopoverTrigger_Shadcn_ asChild>
<Button type="default" icon={<Download />}>
Download
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ align="end" className="p-0">
{IS_PLATFORM && (
<>
<div className="p-3 flex flex-col gap-y-2">
<p className="text-xs text-foreground-light">Download via CLI</p>
<Input
@@ -285,18 +285,22 @@ const EdgeFunctionDetailsLayout = ({
/>
</div>
<Separator className="!bg-border-overlay" />
<div className="py-2 px-1">
<Button
type="text"
className="w-min hover:bg-transparent"
icon={<FileArchive />}
onClick={downloadFunction}
>
Download as ZIP
</Button>
</div>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
</>
)}
<div className="py-2 px-1">
<Button
type="text"
className="w-min hover:bg-transparent"
icon={<FileArchive />}
onClick={downloadFunction}
>
Download as ZIP
</Button>
</div>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
{IS_PLATFORM && (
<>
{!!functionSlug && (
<Button
type="default"
@@ -25,7 +25,7 @@ import { ReactNode, useMemo } from 'react'
import { useAppStateSnapshot } from 'state/app-state'
import { Badge, cn } from 'ui'
import { CommandMenuTriggerInput } from 'ui-patterns'
import { DevToolbarTrigger } from 'dev-tools'
import { BreadcrumbsView } from './BreadcrumbsView'
import { FeedbackDropdown } from './FeedbackDropdown/FeedbackDropdown'
import { HelpPopover } from './HelpPopover'
@@ -220,6 +220,7 @@ export const LayoutHeader = ({
{customHeaderComponents && customHeaderComponents}
{IS_PLATFORM ? (
<>
<DevToolbarTrigger />
<FeedbackDropdown />
<div className="flex items-center gap-2">
@@ -1,5 +1,3 @@
import { Blocks, FileText, Lightbulb, List, Settings, Telescope } from 'lucide-react'
import { ICON_SIZE, ICON_STROKE_WIDTH } from 'components/interfaces/Sidebar'
import { generateAuthMenu } from 'components/layouts/AuthLayout/AuthLayout.utils'
import { generateDatabaseMenu } from 'components/layouts/DatabaseLayout/DatabaseMenu.utils'
@@ -9,6 +7,7 @@ import { EditorIndexPageLink } from 'data/prefetchers/project.$ref.editor'
import type { Project } from 'data/projects/project-detail-query'
import { Auth, Database, EdgeFunctions, Realtime, SqlEditor, Storage, TableEditor } from 'icons'
import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants'
import { Blocks, FileText, Lightbulb, List, Settings, Telescope } from 'lucide-react'
export const generateToolRoutes = (ref?: string, project?: Project, features?: {}): Route[] => {
const isProjectBuilding = project?.status === PROJECT_STATUS.COMING_UP
@@ -102,7 +101,7 @@ export const generateProductRoutes = (
key: 'functions',
label: 'Edge Functions',
icon: <EdgeFunctions size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/functions`),
link: ref && `/project/${ref}/functions`,
},
]
: []),
@@ -1,7 +1,4 @@
import { PropsWithChildren } from 'react'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { PROJECT_STATUS } from 'lib/constants'
import { DatabaseSelectorStateContextProvider } from 'state/database-selector'
import { RoleImpersonationStateContextProvider } from 'state/role-impersonation-state'
import { StorageExplorerStateContextProvider } from 'state/storage-explorer'
@@ -30,8 +27,3 @@ export const ProjectContextProvider = ({
</TableEditorStateContextProvider>
)
}
export const useIsProjectActive = () => {
const { data: project } = useSelectedProjectQuery()
return project?.status === PROJECT_STATUS.ACTIVE_HEALTHY
}
@@ -27,6 +27,7 @@ import {
isBinaryFile,
isZipFile,
} from './FileExplorerAndEditor.utils'
import { IS_PLATFORM } from 'common'
interface FileExplorerAndEditorProps {
files: FileData[]
@@ -373,9 +374,11 @@ export const FileExplorerAndEditor = ({
<h3 className="text-sm font-normal font-mono uppercase text-lighter tracking-wide">
Files
</h3>
<Button size="tiny" type="default" icon={<Plus size={14} />} onClick={addNewFile}>
Add File
</Button>
{IS_PLATFORM && (
<Button size="tiny" type="default" icon={<Plus size={14} />} onClick={addNewFile}>
Add File
</Button>
)}
</div>
<div className="flex-1 overflow-y-auto">
<TreeView
@@ -414,7 +417,7 @@ export const FileExplorerAndEditor = ({
icon={<File size={14} className="text-foreground-light shrink-0" />}
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 = ({
/>
</div>
</ContextMenuTrigger_Shadcn_>
<ContextMenuContent_Shadcn_ onCloseAutoFocus={(e) => e.stopPropagation()}>
<ContextMenuItem_Shadcn_
className="gap-x-2"
onSelect={() => {
if (originalId !== null) handleStartRename(originalId)
}}
onFocusCapture={(e) => e.stopPropagation()}
>
<Edit size={14} />
Rename file
</ContextMenuItem_Shadcn_>
{IS_PLATFORM && (
<ContextMenuContent_Shadcn_ onCloseAutoFocus={(e) => e.stopPropagation()}>
<ContextMenuItem_Shadcn_
className="gap-x-2"
onSelect={() => {
if (originalId !== null) handleStartRename(originalId)
}}
onFocusCapture={(e) => e.stopPropagation()}
>
<Edit size={14} />
Rename file
</ContextMenuItem_Shadcn_>
{files.length > 1 && (
<>
<ContextMenuSeparator_Shadcn_ />
<ContextMenuItem_Shadcn_
className="gap-x-2"
onSelect={() => {
if (originalId !== null) {
handleFileDelete(originalId)
}
}}
onFocusCapture={(e) => e.stopPropagation()}
>
<Trash size={14} />
Delete file
</ContextMenuItem_Shadcn_>
</>
)}
</ContextMenuContent_Shadcn_>
{files.length > 1 && (
<>
<ContextMenuSeparator_Shadcn_ />
<ContextMenuItem_Shadcn_
className="gap-x-2"
onSelect={() => {
if (originalId !== null) {
handleFileDelete(originalId)
}
}}
onFocusCapture={(e) => e.stopPropagation()}
>
<Trash size={14} />
Delete file
</ContextMenuItem_Shadcn_>
</>
)}
</ContextMenuContent_Shadcn_>
)}
</ContextMenu_Shadcn_>
)
}}
@@ -509,6 +514,7 @@ export const FileExplorerAndEditor = ({
padding: { top: 20, bottom: 20 },
lineNumbersMinChars: 3,
fixedOverflowWidgets: true,
readOnly: !IS_PLATFORM,
}}
/>
)}
@@ -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(),
@@ -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<UpgradePlanButtonProps>) => {
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`
+1
View File
@@ -1,3 +1,4 @@
export const accessTokenKeys = {
list: () => ['access-tokens'] as const,
detail: (id: string) => ['access-tokens', id] as const,
}
@@ -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<typeof getCronJobRunDetailsEstimate>
>
export type DatabaseCronJobRunDetailsEstimateError = Error
export const useCronJobRunDetailsEstimateQuery = <TData = DatabaseCronJobRunDetailsEstimateData>(
{ projectRef, connectionString }: DatabaseCronJobRunDetailsEstimateVariables,
{
enabled = true,
...options
}: UseCustomQueryOptions<
DatabaseCronJobRunDetailsEstimateData,
DatabaseCronJobRunDetailsEstimateError,
TData
> = {}
) =>
useQuery<DatabaseCronJobRunDetailsEstimateData, DatabaseCronJobRunDetailsEstimateError, TData>({
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 }),
})
}
@@ -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 = <TData = DatabaseCronJobsInfiniteData>(
if (!hasNextPage) return undefined
return page
},
retry: (failureCount, error) => {
if (error.message === COST_THRESHOLD_ERROR) return false
return failureCount < 3
},
...options,
})
@@ -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 = <TData = DatabaseCronJobsMinimalInfiniteData>(
{ projectRef, connectionString, searchTerm }: DatabaseCronJobRunsVariables,
{
enabled = true,
...options
}: UseCustomInfiniteQueryOptions<
DatabaseCronJobsMinimalInfiniteData,
DatabaseCronJobsMinimalInfiniteError,
InfiniteData<TData>,
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,
})
@@ -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) => [
@@ -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<typeof scheduleCronJobRunDetailsCleanup>
>
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,
})
}
@@ -72,7 +72,6 @@ export const useEdgeFunctionBodyQuery = <TData = EdgeFunctionBodyData>(
useQuery<EdgeFunctionBodyData, EdgeFunctionBodyError, TData>({
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,
})
@@ -60,10 +60,13 @@ export const useOrganizationCreditCodeRedemptionMutation = ({
return useMutation<RedeemCodeData, ResponseError, OrganizationCreditCodeRedemptionVariables>({
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) {
@@ -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 = <TData = IncidentStatusData>(
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),
})
@@ -0,0 +1,4 @@
export const scopedAccessTokenKeys = {
list: () => ['scoped-access-tokens'] as const,
detail: (id: string) => ['scoped-access-tokens', id] as const,
}
@@ -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<NewScopedAccessToken, ResponseError, ScopedAccessTokenCreateVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<NewScopedAccessToken, ResponseError, ScopedAccessTokenCreateVariables>({
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,
})
}
@@ -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<ReturnType<typeof getScopedAccessTokens>>
export type ScopedAccessTokensError = ResponseError
export type ScopedAccessToken =
components['schemas']['GetScopedAccessTokensResponse']['tokens'][number]
export type ScopedAccessTokenData = Awaited<ReturnType<typeof getScopedAccessToken>>
export const useScopedAccessTokensQuery = <TData = ScopedAccessTokensData>({
enabled = true,
...options
}: UseCustomQueryOptions<ScopedAccessTokensData, ScopedAccessTokensError, TData> = {}) =>
useQuery<ScopedAccessTokensData, ScopedAccessTokensError, TData>({
queryKey: scopedAccessTokenKeys.list(),
queryFn: ({ signal }) => getScopedAccessTokens(signal),
enabled,
...options,
})
export const useScopedAccessTokenQuery = <TData = ScopedAccessTokenData>(
{ id }: { id: string },
{
enabled = true,
...options
}: UseCustomQueryOptions<ScopedAccessTokenData, ScopedAccessTokensError, TData> = {}
) =>
useQuery<ScopedAccessTokenData, ScopedAccessTokensError, TData>({
queryKey: scopedAccessTokenKeys.detail(id),
queryFn: ({ signal }) => getScopedAccessToken({ id }, signal),
enabled: enabled && typeof id !== 'undefined',
...options,
})
@@ -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<ReturnType<typeof deleteScopedAccessToken>>
export const useScopedAccessTokenDeleteMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<ScopedAccessTokenDeleteData, ResponseError, ScopedAccessTokenDeleteVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<ScopedAccessTokenDeleteData, ResponseError, ScopedAccessTokenDeleteVariables>({
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,
})
}
@@ -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()
@@ -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])
}
+6 -1
View File
@@ -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
}
@@ -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<Array<FunctionFileEntry>> {
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(
@@ -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
}
+2 -1
View File
@@ -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",
+28 -25
View File
@@ -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
>
<AiAssistantStateContextProvider>
<CommandProvider>
<FeaturePreviewContextProvider>
<MainScrollContainerProvider>
{getLayout(<Component {...pageProps} />)}
</MainScrollContainerProvider>
<StudioCommandMenu />
<FeaturePreviewModal />
</FeaturePreviewContextProvider>
<SonnerToaster position="top-right" />
<MonacoThemeProvider />
</CommandProvider>
</AiAssistantStateContextProvider>
<DevToolbarProvider apiUrl={API_URL}>
<AiAssistantStateContextProvider>
<CommandProvider>
<FeaturePreviewContextProvider>
<MainScrollContainerProvider>
{getLayout(<Component {...pageProps} />)}
</MainScrollContainerProvider>
<StudioCommandMenu />
<FeaturePreviewModal />
</FeaturePreviewContextProvider>
<SonnerToaster position="top-right" />
<MonacoThemeProvider />
</CommandProvider>
</AiAssistantStateContextProvider>
<DevToolbar />
</DevToolbarProvider>
</ThemeProvider>
</RouteValidationWrapper>
</TooltipProvider>
+43 -57
View File
@@ -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<NewAccessToken | undefined>()
const [searchString, setSearchString] = useState('')
return (
<>
<PageHeader size="small">
<PageHeaderMeta>
<PageHeaderSummary>
<PageHeaderTitle>Access Tokens</PageHeaderTitle>
<PageHeaderDescription>
Create and manage personal access tokens for API authentication.
</PageHeaderDescription>
</PageHeaderSummary>
</PageHeaderMeta>
</PageHeader>
<PageContainer size="small" className="mt-8">
<div className="space-y-4">
{newToken && <NewTokenBanner token={newToken} onClose={() => setNewToken(undefined)} />}
<div className="flex items-center justify-between gap-x-2 mb-3">
<Input
size="tiny"
autoComplete="off"
icon={<Search size={12} />}
value={searchString}
onChange={(e: any) => setSearchString(e.target.value)}
name="search"
id="search"
placeholder="Filter tokens"
/>
<div className="flex items-center gap-x-2">
<Button asChild type="default" icon={<ExternalLink />}>
<a href={`${DOCS_URL}/reference/api/introduction`} target="_blank" rel="noreferrer">
API Docs
</a>
</Button>
<Button asChild type="default" icon={<ExternalLink />}>
<a href={`${DOCS_URL}/reference/cli/start`} target="_blank" rel="noreferrer">
CLI docs
</a>
</Button>
<NewAccessTokenButton onCreateToken={setNewToken} />
</div>
</div>
<AccessTokenList
searchString={searchString}
onDeleteSuccess={(id) => {
if (id === newToken?.id) setNewToken(undefined)
}}
<AccessTokensLayout>
<div className="space-y-4">
{newToken && (
<AccessTokenNewBanner
token={newToken}
onClose={() => setNewToken(undefined)}
getTokenValue={(token) => token.token}
/>
)}
<div className="flex items-center justify-between gap-x-2 mb-3">
<Input
size="tiny"
autoComplete="off"
icon={<Search size={12} />}
value={searchString}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchString(e.target.value)}
name="search"
id="search"
placeholder="Filter tokens"
/>
<div className="flex items-center gap-x-2">
<Button asChild type="default" icon={<ExternalLink />}>
<a href={`${DOCS_URL}/reference/api/introduction`} target="_blank" rel="noreferrer">
API Docs
</a>
</Button>
<Button asChild type="default" icon={<ExternalLink />}>
<a href={`${DOCS_URL}/reference/cli/start`} target="_blank" rel="noreferrer">
CLI docs
</a>
</Button>
<NewTokenButton onCreateToken={setNewToken} />
</div>
</div>
</PageContainer>
</>
<AccessTokenList
searchString={searchString}
onDeleteSuccess={(id) => {
if (id === newToken?.id) setNewToken(undefined)
}}
/>
</div>
</AccessTokensLayout>
)
}
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff. Show more