mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 20:35:07 +03:00
Merge branch 'master' into feat/side-panel-toolbar
This commit is contained in:
146 files changed
+57943
-2036
No files matched your search
+2
-1
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,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">
|
||||
|
||||
+67
@@ -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>
|
||||
)
|
||||
}
|
||||
+28
@@ -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>
|
||||
)
|
||||
}
|
||||
+55
@@ -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>
|
||||
)
|
||||
}
|
||||
+22
@@ -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>
|
||||
)
|
||||
+44
@@ -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>
|
||||
)
|
||||
+62
@@ -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',
|
||||
},
|
||||
}
|
||||
+5
-4
@@ -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}
|
||||
+20
-14
@@ -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>
|
||||
)
|
||||
}
|
||||
+89
@@ -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_>
|
||||
)
|
||||
}
|
||||
+172
@@ -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>
|
||||
)
|
||||
}
|
||||
+33
@@ -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'
|
||||
}
|
||||
+48
@@ -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 }]
|
||||
}
|
||||
+115
@@ -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>
|
||||
)
|
||||
}
|
||||
+30
@@ -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
|
||||
}
|
||||
+68
@@ -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>
|
||||
)
|
||||
}
|
||||
+42
@@ -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>
|
||||
)
|
||||
+33
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+320
@@ -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>
|
||||
|
||||
+166
-124
@@ -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>
|
||||
|
||||
+25
-30
@@ -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,
|
||||
|
||||
+41
-175
@@ -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 }
|
||||
+95
-113
@@ -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'
|
||||
|
||||
+3
-5
@@ -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'
|
||||
|
||||
|
||||
+9
-7
@@ -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 = () => {
|
||||
|
||||
+10
-8
@@ -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'
|
||||
|
||||
+34
-21
@@ -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>
|
||||
)
|
||||
}
|
||||
+31
-22
@@ -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>
|
||||
)
|
||||
}
|
||||
+31
-22
@@ -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>
|
||||
)
|
||||
}
|
||||
+14
-8
@@ -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
|
||||
|
||||
+12
-7
@@ -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,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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
Reference in new issue
Block a user