diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 1a08ecc0efe..eb2e7ccacb6 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -1183,6 +1183,20 @@ export const integrations: NavMenuConstant = { url: '/guides/integrations', items: [ { name: 'Overview', url: '/guides/integrations/integrations' }, + { + name: 'OAuth Apps (Beta)', + url: undefined, + items: [ + { + name: 'Publish an OAuth App', + url: '/guides/integrations/oauth-apps/publish-an-oauth-app', + }, + { + name: 'Authorize an OAuth App', + url: '/guides/integrations/oauth-apps/authorize-an-oauth-app', + }, + ], + }, { name: 'Auth', url: undefined, diff --git a/apps/docs/pages/guides/integrations/oauth-apps/authorize-an-oauth-app.mdx b/apps/docs/pages/guides/integrations/oauth-apps/authorize-an-oauth-app.mdx new file mode 100644 index 00000000000..7ef2bd1b168 --- /dev/null +++ b/apps/docs/pages/guides/integrations/oauth-apps/authorize-an-oauth-app.mdx @@ -0,0 +1,54 @@ +import Layout from '~/layouts/DefaultGuideLayout' + +export const meta = { + id: 'authorize-an-oauth-app', + title: 'Authorize an OAuth App (Beta)', + description: 'Authorize an OAuth App', +} + +## Overview + +This guide steps through implementing **Sign-in with Supabase**. Once you've added OAuth2.0 support to your main application, you will receive an access and refresh token from your users that choose to **Sign in with Supabase** on your application. + +The access token returned will grant your application full access to the [Management API](https://supabase.com/docs/reference/api/introduction) on behalf of the user. + +## Implement the OAuth 2.0 flow + +Once you've published your OAuth App on Supabase, you can use the OAuth 2.0 protocol to seek consent from Supabase users to access their organization or project. + +1. Within your app's UI, redirect the user to [`https://api.supabase.com/v1/oauth/authorize`](). Make sure to include all required query parameters such as: + +- `client_id` A UUID uniquely identifying your OAuth app in Supabase. +- `redirect_uri` The URL where Supabase will redirect the user after providing consent. +- `scope` The only scope supported is `all`. Scoped access is not available at this time. +- `response_type` The value `code`. +- `state` Information about the state of your app. Note that `redirect_uri` and `state` cannot both exceed 4kB in size. +- We strongly recommend using the PKCE flow for increased security. Generate a random value before taking the user to the authorize endpoint. This value is called code verifier. Hash it with SHA256 and include it as the `code_challenge` parameter, while setting `code_challenge_method` to `s256`. In the next step, you would need to provide the code verifier to get the first access and refresh token. + +2. Once the user consents to providing API access to your OAuth App, Supabase will redirect the user to the `redirect_uri` endpoint you provided in the previous step. The URL will contain these query parameters: + +- `code` An authorization code you should exchange with Supabase to get the access and refresh token. +- `state` The value you provided in the previous step, to help you associate the request with the user. The `state` property returned here should be compared to the `state` you sent previously. + +3. Exchange the authorization code for an access and refresh token by calling `POST https://api.supabase.com/v1/oauth/token` and including the following query parameters: + +- `grant_type` The value `authorization_code`. +- `code` The `code` returned in the previous step. +- `client_id` The unique client ID identifying your OAuth App. +- `client_secret` The secret that authenticates your OAuth App to Supabase. +- `redirect_uri` This must be exactly the same URL used in the first step. +- If you used the PKCE flow in the first step, include the code verifier as `code_verifier`. + +## Refresh an access token + +You can use the [`POST /v1/oauth/token`]() endpoint to refresh an access token using the refresh token returned at the end of the previous section. + +If the user has revoked access to your application, you will not be able to refresh a token. Furthermore, access tokens will stop working. Make sure you handle HTTP Unauthorized errors when calling any Supabase API. + +## Access the Management API using the access token + +Refer to [this section](/docs/reference/api/introduction#authentication) to learn more about authentication with the Management API. + +export const Page = ({ children }) => + +export default Page diff --git a/apps/docs/pages/guides/integrations/oauth-apps/publish-an-oauth-app.mdx b/apps/docs/pages/guides/integrations/oauth-apps/publish-an-oauth-app.mdx new file mode 100644 index 00000000000..734cea523f3 --- /dev/null +++ b/apps/docs/pages/guides/integrations/oauth-apps/publish-an-oauth-app.mdx @@ -0,0 +1,25 @@ +import Layout from '~/layouts/DefaultGuideLayout' + +export const meta = { + id: 'publish-an-oauth-app', + title: 'Publish an OAuth App (Beta)', + description: 'Publish an OAuth App', +} + +## Overview + +This guide steps through publishing an OAuth app under your organization. + +## Publishing an OAuth App + +1. In your organization's settings, navigate to the [**OAuth Apps**](/dashboard/org/_/apps) tab. +2. In the upper-right section of the page, click **Add application**. +3. In "Application name", fill in the name of your OAuth app. +4. In "Website URL", fill in the URL of the app that's using the OAuth app. +5. Upload a logo for your OAuth app. +6. In "Authorization callback URLs", add the callback URL of your app. +7. At the bottom of the page, click **Confirm**. + +export const Page = ({ children }) => + +export default Page diff --git a/package-lock.json b/package-lock.json index 1b914615e5d..320a08ec69f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42859,6 +42859,7 @@ "eslint-config-next": "^13.3.0", "eslint-config-prettier": "^8.3.0", "eslint-plugin-react": "7.28.0", + "mini-svg-data-uri": "^1.4.3", "tailwindcss-radix": "^2.0.0" } }, diff --git a/packages/common/hooks/useParams.ts b/packages/common/hooks/useParams.ts index cac30143bd9..879d3f4e50e 100644 --- a/packages/common/hooks/useParams.ts +++ b/packages/common/hooks/useParams.ts @@ -1,15 +1,46 @@ import { useRouter } from 'next/router' import { useMemo } from 'react' +/** + * Helper to convert kebab case to camel case + */ +function convertToCamelCase(key: string): string { + if (!key.includes('-')) { + return key + } + + const parts = key.split('-') + const capitalizedParts = parts.map((part, index) => { + if (index === 0) { + return part + } + return part.charAt(0).toUpperCase() + part.slice(1) + }) + return capitalizedParts.join('') +} + export function useParams(): { [k: string]: string | undefined } { const { query } = useRouter() + const modifiedQuery = { + ...query, + } + + // Convert kebab case keys to camel case + Object.keys(modifiedQuery).forEach((key) => { + const modifiedKey = convertToCamelCase(key) + if (modifiedKey !== key) { + modifiedQuery[modifiedKey] = modifiedQuery[key] + delete modifiedQuery[key] + } + }) + return useMemo( () => Object.fromEntries( - Object.entries(query).map(([key, value]) => { + Object.entries(modifiedQuery).map(([key, value]) => { if (Array.isArray(value)) { return [key, value[0]] } else { diff --git a/packages/config/package.json b/packages/config/package.json index 45901f19411..b9fb4a06dab 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -15,6 +15,7 @@ "eslint-config-prettier": "^8.3.0", "eslint-plugin-react": "7.28.0", "tailwindcss-radix": "^2.0.0", - "@tailwindcss/typography": "^0.5.9" + "@tailwindcss/typography": "^0.5.9", + "mini-svg-data-uri": "^1.4.3" } } diff --git a/packages/config/ui.config.js b/packages/config/ui.config.js index 96cf6ae71a7..856f8ff436a 100644 --- a/packages/config/ui.config.js +++ b/packages/config/ui.config.js @@ -3,6 +3,7 @@ const forms = require('@tailwindcss/forms') const plugin = require('tailwindcss/plugin') const radixUiColors = require('@radix-ui/colors') const brandColors = require('./default-colors') +const svgToDataUri = require('mini-svg-data-uri') const { default: flattenColorPalette } = require('tailwindcss/lib/util/flattenColorPalette') @@ -371,18 +372,28 @@ const uiConfig = { highlight: (value) => ({ boxShadow: `inset 0 1px 0 0 ${value}` }), }, { values: flattenColorPalette(theme('backgroundColor')), type: 'color' } - ), - matchUtilities( - { - subhighlight: (value) => ({ - boxShadow: `inset 0 -1px 0 0 ${value}`, - }), - }, - { - values: flattenColorPalette(theme('backgroundColor')), - type: 'color', - } - ) + ) + matchUtilities( + { + subhighlight: (value) => ({ + boxShadow: `inset 0 -1px 0 0 ${value}`, + }), + }, + { + values: flattenColorPalette(theme('backgroundColor')), + type: 'color', + } + ) + matchUtilities( + { + 'bg-grid': (value) => ({ + backgroundImage: `url("${svgToDataUri( + `` + )}")`, + }), + }, + { values: flattenColorPalette(theme('backgroundColor')), type: 'color' } + ) }, require('tailwindcss-radix')(), forms, diff --git a/packages/ui/index.tsx b/packages/ui/index.tsx index 30d9ad7d52e..f5009a16d8d 100644 --- a/packages/ui/index.tsx +++ b/packages/ui/index.tsx @@ -63,6 +63,7 @@ export * from './src/components/Form' export * from './src/components/Command' // layout +export * from './src/components/LoadingLine' // banners export * from './src/layout/banners' diff --git a/packages/ui/src/components/Command/Command.utils.tsx b/packages/ui/src/components/Command/Command.utils.tsx index ee020395eb9..a7a44b0cc8b 100644 --- a/packages/ui/src/components/Command/Command.utils.tsx +++ b/packages/ui/src/components/Command/Command.utils.tsx @@ -7,7 +7,7 @@ import { DetailedHTMLProps, HTMLAttributes, KeyboardEventHandler } from 'react' import { Modal } from '../Modal' import { ModalProps } from '../Modal/Modal' import { useCommandMenu } from './CommandMenuProvider' -import { LoadingLine } from './LoadingLine' +import { LoadingLine } from '../LoadingLine/LoadingLine' type CommandPrimitiveElement = React.ElementRef type CommandPrimitiveProps = React.ComponentPropsWithoutRef diff --git a/packages/ui/src/components/Command/LoadingLine.tsx b/packages/ui/src/components/LoadingLine/LoadingLine.tsx similarity index 93% rename from packages/ui/src/components/Command/LoadingLine.tsx rename to packages/ui/src/components/LoadingLine/LoadingLine.tsx index 1e590e092b1..afb8522b653 100644 --- a/packages/ui/src/components/Command/LoadingLine.tsx +++ b/packages/ui/src/components/LoadingLine/LoadingLine.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { cn } from './../../lib/utils' +import { cn } from '../../lib/utils' export const LoadingLine = ({ loading }: { loading: boolean }) => { return ( diff --git a/packages/ui/src/components/LoadingLine/index.tsx b/packages/ui/src/components/LoadingLine/index.tsx new file mode 100644 index 00000000000..16a23c1edfe --- /dev/null +++ b/packages/ui/src/components/LoadingLine/index.tsx @@ -0,0 +1 @@ +export * from './LoadingLine' diff --git a/packages/ui/src/components/Tabs/Tabs.tsx b/packages/ui/src/components/Tabs/Tabs.tsx index 0ee08f3cc3a..3466dc5d262 100644 --- a/packages/ui/src/components/Tabs/Tabs.tsx +++ b/packages/ui/src/components/Tabs/Tabs.tsx @@ -37,8 +37,12 @@ const Tabs: React.FC & TabsSubComponents = ({ addOnBefore, addOnAfter, listClassNames, - children, + children: _children, }) => { + // toArray is used here to filter out invalid children + // another method would be to use React.Children.map + const children = React.Children.toArray(_children) as PanelPropsProps[] + const [activeTab, setActiveTab] = React.useState( defaultActiveId ?? // if no defaultActiveId is set use the first panel @@ -50,13 +54,6 @@ const Tabs: React.FC & TabsSubComponents = ({ let __styles = styleHandler('tabs') - if (children && !Array.isArray(children)) { - children = [children] - } - - // Exclude children that are just conditional elements - children = children.filter((it) => it) - const tabIds = children.map((tab) => tab.props.id) const { groupActiveId, setGroupActiveId } = useTabGroup(tabIds) diff --git a/studio/components/interfaces/Integrations/IntegrationPanels.tsx b/studio/components/interfaces/Integrations/IntegrationPanels.tsx new file mode 100644 index 00000000000..1a49c940689 --- /dev/null +++ b/studio/components/interfaces/Integrations/IntegrationPanels.tsx @@ -0,0 +1,267 @@ +import dayjs from 'dayjs' +import Image from 'next/image' +import React from 'react' + +import { Markdown } from 'components/interfaces/Markdown' +import { Integration, IntegrationProjectConnection } from 'data/integrations/integrations.types' +import { useProjectsQuery } from 'data/projects/projects-query' +import { BASE_PATH } from 'lib/constants' +import { Badge, Button, IconArrowRight, IconGitHub, IconSquare, cn } from 'ui' + +const ICON_STROKE_WIDTH = 2 +const ICON_SIZE = 14 + +export interface IntegrationInstallationProps extends React.RefAttributes { + title: string + integration: Integration +} + +type HandleIconType = Integration['integration']['name'] | 'Supabase' + +const HandleIcon = ({ type, className }: { type: HandleIconType; className?: string }) => { + switch (type) { + case 'GitHub': + return + break + case 'Netlify': + return + break + case 'Vercel': + return ( + + + + ) + + break + case 'Supabase': + return Supabase + break + + default: + return <> + break + } +} + +const Avatar = ({ src }: { src: string | undefined }) => { + return ( +
+ avatar +
+ ) +} + +const IntegrationInstallation = React.forwardRef( + ({ title, integration, ...props }, ref) => { + const IntegrationIconBlock = () => { + return ( +
+ +
+ ) + } + + return ( +
  • +
    +
    +
    + + +
    +
    +
    +
    + + {/* {title} integration connection •{' '} */} + {integration.metadata?.account.name || integration.metadata?.gitHubConnectionOwner} + + + + {integration.metadata?.account.type} + +
    +
    + + Created {dayjs(integration.inserted_at).fromNow()} + + + Added by {integration?.added_by?.primary_email} + +
    +
    +
    + + +
  • + ) + } +) + +export interface IntegrationConnectionProps extends React.HTMLAttributes { + connection: IntegrationProjectConnection + type: Integration['integration']['name'] + actions?: React.ReactNode +} + +const IntegrationConnection = React.forwardRef( + ({ className, connection, type, actions, ...props }, ref) => { + const { data: projects } = useProjectsQuery() + const project = projects?.find((project) => project.ref === connection.supabase_project_ref) + + return ( +
  • +
    +
    +
    +
    + + {project?.name} + + {!connection?.metadata?.framework ? ( +
    + +
    + ) : ( + {`icon`} + )} + {connection.metadata?.name} +
    + +
    + + Connected {dayjs(connection?.inserted_at).fromNow()} + + + Added by {connection?.added_by?.primary_email} + +
    +
    + +
    {actions}
    +
    +
  • + ) + } +) + +const IntegrationConnectionOption = React.forwardRef( + ({ className, connection, type, ...props }, ref) => { + const { data: projects } = useProjectsQuery() + const project = projects?.find((project) => project.ref === connection.supabase_project_ref) + + return ( +
  • +
    +
    + + {project?.name} + + + {connection.metadata.name} +
    + + + Connected {dayjs(connection.inserted_at).fromNow()} + +
    + + +
  • + ) + } +) + +const EmptyIntegrationConnection = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + return ( +
    +
    +
    + +
    +
    + ) +}) + +interface IntegrationConnectionHeader extends React.HTMLAttributes { + name?: string + markdown?: string +} + +const IntegrationConnectionHeader = React.forwardRef( + ({ className, name, markdown = '', ...props }, ref) => { + return ( +
    + +
    + ) + } +) + +IntegrationInstallation.displayName = 'IntegrationInstallation' +IntegrationConnection.displayName = 'IntegrationConnection' +IntegrationConnectionHeader.displayName = 'IntegrationConnectionHeader' +EmptyIntegrationConnection.displayName = 'EmptyIntegrationConnection' +IntegrationConnectionOption.displayName = 'IntegrationConnectionOption' + +export { + EmptyIntegrationConnection, + IntegrationConnection, + IntegrationConnectionHeader, + IntegrationConnectionOption, + IntegrationInstallation, +} diff --git a/studio/components/interfaces/Integrations/Integrations-Vercel.constants.ts b/studio/components/interfaces/Integrations/Integrations-Vercel.constants.ts new file mode 100644 index 00000000000..4513c7a4aed --- /dev/null +++ b/studio/components/interfaces/Integrations/Integrations-Vercel.constants.ts @@ -0,0 +1,52 @@ +export const ENV_VAR_KEYS = { + POSTGRES_URL: { + key: 'POSTGRES_URL', + type: 'encrypted', + }, + POSTGRES_PRISMA_URL: { + key: 'POSTGRES_PRISMA_URL', + type: 'encrypted', + }, + POSTGRES_URL_NON_POOLING: { + key: 'POSTGRES_URL_NON_POOLING', + type: 'encrypted', + }, + POSTGRES_USER: { + key: 'POSTGRES_USER', + type: 'encrypted', + }, + POSTGRES_HOST: { + key: 'POSTGRES_HOST', + type: 'encrypted', + }, + POSTGRES_PASSWORD: { + key: 'POSTGRES_PASSWORD', + type: 'encrypted', + }, + POSTGRES_DATABASE: { + key: 'POSTGRES_DATABASE', + type: 'encrypted', + }, + SUPABASE_ANON_KEY: { + key: 'SUPABASE_ANON_KEY', + type: 'encrypted', + }, + SUPABASE_URL: { + key: 'SUPABASE_URL', + type: 'encrypted', + }, + SUPABASE_SERVICE_ROLE_KEY: { + key: 'SUPABASE_SERVICE_ROLE_KEY', + type: 'encrypted', + }, + NEXT_PUBLIC_SUPABASE_ANON_KEY: { + key: 'NEXT_PUBLIC_SUPABASE_ANON_KEY', + type: 'encrypted', + }, + NEXT_PUBLIC_SUPABASE_URL: { + key: 'NEXT_PUBLIC_SUPABASE_URL', + type: 'encrypted', + }, +} as const + +export const ENV_VAR_RAW_KEYS = Object.values(ENV_VAR_KEYS).map((x) => x.key) diff --git a/studio/components/interfaces/Integrations/ProjectLinker.tsx b/studio/components/interfaces/Integrations/ProjectLinker.tsx new file mode 100644 index 00000000000..0be4ac9d5d4 --- /dev/null +++ b/studio/components/interfaces/Integrations/ProjectLinker.tsx @@ -0,0 +1,228 @@ +import { useState } from 'react' + +import { ENV_VAR_RAW_KEYS } from 'components/interfaces/Integrations/Integrations-Vercel.constants' +import { Markdown } from 'components/interfaces/Markdown' +import { vercelIcon } from 'components/to-be-cleaned/ListIcons' +import { useIntegrationConnectionsCreateMutation } from 'data/integrations/integration-connections-create-mutation' +import { VercelProjectsResponse } from 'data/integrations/integrations-vercel-projects-query' +import { IntegrationProjectConnection } from 'data/integrations/integrations.types' +import { useSelectedOrganization } from 'hooks' +import { BASE_PATH } from 'lib/constants' +import { Button, Listbox, cn } from 'ui' + +interface Project { + id: string + name: string + ref: string +} + +export interface ProjectLinkerProps { + organizationIntegrationId: string | undefined + foreignProjects: VercelProjectsResponse[] + supabaseProjects: Project[] + onCreateConnections?: () => void + installedConnections: IntegrationProjectConnection[] | undefined + setLoading?: (x: boolean) => void +} + +const UNDEFINED_SELECT_VALUE = 'undefined' + +const ProjectLinker = ({ + organizationIntegrationId, + foreignProjects, + supabaseProjects, + onCreateConnections: _onCreateConnections, + installedConnections = [], + setLoading, +}: ProjectLinkerProps) => { + const selectedOrganization = useSelectedOrganization() + + const [supabaseProjectRef, setSupabaseProjectRef] = useState(UNDEFINED_SELECT_VALUE) + const [vercelProjectId, setVercelProjectId] = useState(UNDEFINED_SELECT_VALUE) + + const { mutate: createConnections, isLoading } = useIntegrationConnectionsCreateMutation({ + onSuccess() { + _onCreateConnections?.() + }, + + onSettled() { + if (setLoading) setLoading(false) + }, + }) + + function onCreateConnections() { + const projectDetails = foreignProjects.filter((x) => x.id === vercelProjectId)[0] + + if (!organizationIntegrationId) { + console.error('No integration ID set') + return + } + + if (setLoading) setLoading(true) + + createConnections({ + organizationIntegrationId, + connection: { + foreign_project_id: vercelProjectId, + supabase_project_ref: supabaseProjectRef, + metadata: { + ...projectDetails, + supabaseConfig: { + projectEnvVars: { + write: true, + }, + }, + }, + }, + orgSlug: selectedOrganization?.slug, + }) + } + + const Panel = ({ children, className, ...props }: React.HTMLAttributes) => { + return ( +
    + {children} +
    + ) + } + + // create a flat array of foreign project ids. ie, ["prj_MlkO6AiLG5ofS9ojKrkS3PhhlY3f", ..] + const flatInstalledConnectionsIds = new Set(installedConnections.map((x) => x.foreign_project_id)) + + // check that vercel project is not already installed + const filteredForeignProjects: VercelProjectsResponse[] = foreignProjects.filter( + (foreignProject) => { + return !flatInstalledConnectionsIds.has(foreignProject.id) + } + ) + + return ( +
    +
    +
    +
    + +
    + Supabase +
    + setSupabaseProjectRef(e)} + > + + Choose project + + {supabaseProjects.map((project) => ( + { + return ( + <> +
    + Supabase +
    + + ) + }} + > + {project.name} +
    + ))} +
    +
    +
    + +
    + {/* integration icon */} + + + +
    + setVercelProjectId(e)} + > + + Choose Vercel project + + {filteredForeignProjects.map((project) => { + return ( + { + return ( + <> + {!project?.framework ? ( + vercelIcon + ) : ( + {`icon`} + )} + + ) + }} + > + {project.name} + + ) + })} + +
    +
    +
    +
    + +
    + { + return ` + \n + - \`${x}\` +` +})} +`} + /> +
    + ) +} + +export default ProjectLinker diff --git a/studio/components/interfaces/Markdown.tsx b/studio/components/interfaces/Markdown.tsx new file mode 100644 index 00000000000..1755f86c843 --- /dev/null +++ b/studio/components/interfaces/Markdown.tsx @@ -0,0 +1,18 @@ +import { ReactMarkdownProps } from 'react-markdown/lib/complex-types' +import { ReactMarkdown } from 'react-markdown/lib/react-markdown' +import { cn } from 'ui' + +interface Props extends Omit { + className?: string + content: string +} + +const Markdown = ({ className, content, ...props }: Props) => { + return ( + + {content} + + ) +} + +export { Markdown } diff --git a/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx b/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx index fb96137ff51..c18d3297dfd 100644 --- a/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx +++ b/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx @@ -13,6 +13,7 @@ import PaymentMethods from './PaymentMethods' import ProjectsSummary from './ProjectsSummary' import TaxID from './TaxID/TaxID' import OrganizationBillingMigrationPanel from '../GeneralSettings/OrganizationBillingMigrationPanel' +import { ScaffoldContainer, ScaffoldContainerLegacy } from 'components/layouts/Scaffold' const BillingSettings = () => { const { ui } = useStore() @@ -101,36 +102,34 @@ const BillingSettings = () => { }, [slug]) return ( -
    -
    - {orgBillingMigrationEnabled && canMigrateOrganization && !subscription_id && ( - - )} - - - getPaymentMethods()} - onPaymentMethodAdded={() => getPaymentMethods()} - /> + + {orgBillingMigrationEnabled && canMigrateOrganization && !subscription_id && ( + + )} + + + getPaymentMethods()} + onPaymentMethodAdded={() => getPaymentMethods()} + /> - + - setCustomer({ ...customer, address })} - /> - setTaxIds(ids)} - /> -
    -
    + setCustomer({ ...customer, address })} + /> + setTaxIds(ids)} + /> + ) } diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/BillingSettings.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/BillingSettings.tsx index e42a8eceed6..ec6f077faa4 100644 --- a/studio/components/interfaces/Organization/BillingSettingsV2/BillingSettings.tsx +++ b/studio/components/interfaces/Organization/BillingSettingsV2/BillingSettings.tsx @@ -1,39 +1,54 @@ +import { ScaffoldContainer, ScaffoldDivider } from 'components/layouts/Scaffold' +import BillingAddress from './BillingAddress/BillingAddress' import BillingEmail from './BillingEmail' import CostControl from './CostControl/CostControl' -import Subscription from './Subscription/Subscription' import CreditBalance from './CreditBalance' -import { LayoutWrapper } from './Scaffold' -import BillingAddress from './BillingAddress/BillingAddress' -import TaxID from './TaxID/TaxID' import PaymentMethods from './PaymentMethods/PaymentMethods' - -// [Joshen TODO] Last one: payment method details management -// Either as part of the subscription plan component like project, or its own section like the old design +import Subscription from './Subscription/Subscription' +import TaxID from './TaxID/TaxID' const BillingSettings = () => { return ( <> - + - - + + + + + - - + + + + + - - + + + + + - - + + + + + - - + + + + + - - + + + + + - + ) } diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/Scaffold.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/Scaffold.tsx deleted file mode 100644 index 38ef45cf8f9..00000000000 --- a/studio/components/interfaces/Organization/BillingSettingsV2/Scaffold.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { PropsWithChildren } from 'react' - -export const LayoutWrapper = ({ children }: PropsWithChildren<{}>) => ( -
    {children}
    -) diff --git a/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx b/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx index 67c39a663b8..435adc38493 100644 --- a/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx +++ b/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx @@ -19,6 +19,7 @@ import { useCheckPermissions, useFlag, useSelectedOrganization, useStore } from import { patch } from 'lib/common/fetch' import { API_URL } from 'lib/constants' import OrganizationDeletePanel from './OrganizationDeletePanel' +import { ScaffoldContainerLegacy } from 'components/layouts/Scaffold' const GeneralSettings = () => { const queryClient = useQueryClient() @@ -69,7 +70,7 @@ const GeneralSettings = () => { } return ( -
    +
    {({ isSubmitting, handleReset, values, initialValues, resetForm }: any) => { const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues) @@ -190,7 +191,7 @@ const GeneralSettings = () => {
    {canDeleteOrganization && } -
    + ) } diff --git a/studio/components/interfaces/Organization/IntegrationSettings/Integration.tsx b/studio/components/interfaces/Organization/IntegrationSettings/Integration.tsx new file mode 100644 index 00000000000..f67e692fefc --- /dev/null +++ b/studio/components/interfaces/Organization/IntegrationSettings/Integration.tsx @@ -0,0 +1,160 @@ +import { + EmptyIntegrationConnection, + IntegrationConnection, + IntegrationConnectionHeader, + IntegrationInstallation, +} from 'components/interfaces/Integrations/IntegrationPanels' +import { Markdown } from 'components/interfaces/Markdown' +import { + ScaffoldContainer, + ScaffoldSection, + ScaffoldSectionContent, + ScaffoldSectionDetail, +} from 'components/layouts/Scaffold' +import { useIntegrationsVercelInstalledConnectionDeleteMutation } from 'data/integrations/integrations-vercel-installed-connection-delete-mutation' +import { + IntegrationProjectConnection, + Integration as TIntegration, +} from 'data/integrations/integrations.types' +import { useSelectedOrganization } from 'hooks' +import { BASE_PATH } from 'lib/constants' +import { pluralize } from 'lib/helpers' +import { EMPTY_ARR } from 'lib/void' +import { useGithubConnectionConfigPanelSnapshot } from 'state/github-connection-config-panel' +import { Button, Dropdown, IconChevronDown, IconRefreshCcw, IconTrash } from 'ui' + +export interface IntegrationProps { + title: string + orgName?: string + description?: string + note?: string + detail?: string + integrations?: TIntegration[] +} + +const Integration = ({ + title, + orgName, + description, + note, + detail, + integrations = EMPTY_ARR, +}: IntegrationProps) => { + const snapshot = useGithubConnectionConfigPanelSnapshot() + + const selectedOrganization = useSelectedOrganization() + + const { mutate: deleteMutate, isLoading: isDeleteLoading } = + useIntegrationsVercelInstalledConnectionDeleteMutation() + + const ConnectionHeading = ({ integration }: { integration: TIntegration }) => { + return ( + + ) + } + + // Call the mutation when needed + const handleDelete = (organizationIntegrationId: string, projectIntegrationId: string) => { + const variables = { + organization_integration_id: organizationIntegrationId, + id: projectIntegrationId, + orgSlug: selectedOrganization?.slug, + } + + deleteMutate(variables) + } + + return ( + <> + + + + {detail && } + cover + + + +
    + {integrations.length > 0 && + integrations.map((integration, i) => { + return ( +
    + + {integration.connections.length > 0 ? ( + <> + + +
      + {integration.connections.map( + (connection: IntegrationProjectConnection, i) => ( + + } + onSelect={() => + handleDelete(integration.id, connection.id) + } + > + Delete + + + } + > + + + } + /> + ) + )} +
    + + ) : ( + + )} + { + snapshot.setOrganizationIntegrationId(integration.id) + snapshot.setOpen(true) + }} + > + Add new project connection + +
    + ) + })} +
    + +
    +
    +
    + + ) +} + +export default Integration diff --git a/studio/components/interfaces/Organization/IntegrationSettings/IntegrationSettings.tsx b/studio/components/interfaces/Organization/IntegrationSettings/IntegrationSettings.tsx new file mode 100644 index 00000000000..96d9e1d7653 --- /dev/null +++ b/studio/components/interfaces/Organization/IntegrationSettings/IntegrationSettings.tsx @@ -0,0 +1,79 @@ +import { ScaffoldDivider } from 'components/layouts/Scaffold' +import { useOrgIntegrationsQuery } from 'data/integrations/integrations-query-org-only' +import { useSelectedOrganization } from 'hooks' +import Integration from './Integration' +import SidePanelVercelProjectLinker from './SidePanelVercelProjectLinker' + +const IntegrationSettings = () => { + const org = useSelectedOrganization() + const { data } = useOrgIntegrationsQuery({ orgSlug: org?.slug }) + + const vercelIntegrations = data?.filter( + (integration) => integration.integration.name === 'Vercel' + ) // vercel + const githubIntegrations = data?.filter( + (integration) => integration.integration.name === 'GitHub' + ) // github + + vercelIntegrations?.map((x) => { + let data: any = { ...x } + + const avatarSrc = data.metadata.account.avatar + ? `https://vercel.com/api/www/avatar/${data.metadata.account.avatar}?s=48` + : `https://vercel.com/api/www/avatar?teamId=${data.metadata.account.team_id}&s=48` + data['metadata']['account']['avatar'] = avatarSrc + + return data + }) + + return ( + <> + + + + + + ) +} + +export default IntegrationSettings diff --git a/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoSelection.tsx b/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoSelection.tsx new file mode 100644 index 00000000000..4b978dd0ae2 --- /dev/null +++ b/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoSelection.tsx @@ -0,0 +1,58 @@ +import { useParams } from 'common' +import { Markdown } from 'components/interfaces/Markdown' +// import { useIntegrationsQuery } from 'data/integrations/integrations-query-org-only' +import { useStore } from 'hooks' +import { observer } from 'mobx-react-lite' +import { useGithubConnectionConfigPanelSnapshot } from 'state/github-connection-config-panel' +import { IconGitHub, IconSearch, Input, Select, SidePanel } from 'ui' + +// TO DO + +const GitHubRepoSelection = () => { + const { ui } = useStore() + const { slug } = useParams() + // const { data } = useIntegrationsQuery({ orgSlug: slug }) + + const githubConnectionConfigPanelSnapshot = useGithubConnectionConfigPanelSnapshot() + + return ( + githubConnectionConfigPanelSnapshot.setVisible(false)} + > +
    + + + + + +
    + + } className="w-full" placeholder="Search..." /> +
    +
    + +
      +
    • + {/* */} +
    • +
    +
    +
    +
    + ) +} + +export default observer(GitHubRepoSelection) diff --git a/studio/components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx b/studio/components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx new file mode 100644 index 00000000000..272a0026911 --- /dev/null +++ b/studio/components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx @@ -0,0 +1,103 @@ +import ProjectLinker from 'components/interfaces/Integrations/ProjectLinker' +import { Markdown } from 'components/interfaces/Markdown' +import { useOrgIntegrationsQuery } from 'data/integrations/integrations-query-org-only' +import { useVercelProjectConnectionsQuery } from 'data/integrations/integrations-vercel-installed-connections-query' +import { useVercelProjectsQuery } from 'data/integrations/integrations-vercel-projects-query' +import { useProjectsQuery } from 'data/projects/projects-query' +import { useSelectedOrganization } from 'hooks' +import { EMPTY_ARR } from 'lib/void' +import { useMemo } from 'react' +import { useGithubConnectionConfigPanelSnapshot } from 'state/github-connection-config-panel' +import { SidePanel } from 'ui' + +const SidePanelVercelProjectLinker = () => { + const selectedOrganization = useSelectedOrganization() + + const { data: integrationData } = useOrgIntegrationsQuery({ + orgSlug: selectedOrganization?.slug, + }) + const vercelIntegrations = integrationData?.filter( + (integration) => integration.integration.name === 'Vercel' + ) // vercel + + const snapshot = useGithubConnectionConfigPanelSnapshot() + + /** + * Find the right integration + * + * we use the snapshot.organizationIntegrationId which should be set whenever this sidepanel is opened + */ + const selectedIntegration = vercelIntegrations?.find( + (x) => x.id === snapshot.organizationIntegrationId + ) + + const organizationIntegrationId = snapshot.organizationIntegrationId + const open = snapshot.open + + /** + * Supabase projects available + */ + const { data: supabaseProjectsData } = useProjectsQuery({ + enabled: organizationIntegrationId !== null, + }) + + const supabaseProjects = useMemo( + () => + supabaseProjectsData + ?.filter((project) => project.organization_id === selectedOrganization?.id) + .map((project) => ({ id: project.id.toString(), name: project.name, ref: project.ref })) ?? + EMPTY_ARR, + [selectedOrganization?.id, supabaseProjectsData] + ) + + const { data: vercelProjectsData } = useVercelProjectsQuery( + { + organization_integration_id: organizationIntegrationId, + }, + { enabled: organizationIntegrationId !== null } + ) + + const vercelProjects = useMemo(() => vercelProjectsData ?? EMPTY_ARR, [vercelProjectsData]) + + return ( + snapshot.setOpen(false)} + > +
    + + + + + { + snapshot.setOpen(false) + }} + installedConnections={selectedIntegration?.connections} + /> + + +
      +
    • + {/* */} +
    • +
    +
    +
    +
    + ) +} + +export default SidePanelVercelProjectLinker diff --git a/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx b/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx index 79c58e9a894..904d335208b 100644 --- a/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx +++ b/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx @@ -10,6 +10,7 @@ import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks' import { get, head } from 'lib/common/fetch' import { API_URL } from 'lib/constants' import { Button, IconChevronLeft, IconChevronRight, IconDownload, IconFileText, Loading } from 'ui' +import { ScaffoldContainerLegacy } from 'components/layouts/Scaffold' const PAGE_LIMIT = 10 @@ -93,7 +94,7 @@ const InvoicesSettings = () => { } return ( -
    + { } /> - + ) } diff --git a/studio/components/interfaces/Organization/OAuthApps/OAuthApps.tsx b/studio/components/interfaces/Organization/OAuthApps/OAuthApps.tsx index 416679f43e1..79919216744 100644 --- a/studio/components/interfaces/Organization/OAuthApps/OAuthApps.tsx +++ b/studio/components/interfaces/Organization/OAuthApps/OAuthApps.tsx @@ -13,6 +13,7 @@ import PublishAppModal from './PublishAppSidePanel' import { AuthorizedApp, useAuthorizedAppsQuery } from 'data/oauth/authorized-apps-query' import AuthorizedAppRow from './AuthorizedAppRow' import RevokeAppModal from './RevokeAppModal' +import { ScaffoldContainerLegacy } from 'components/layouts/Scaffold' // [Joshen] Note on nav UX // Kang Ming mentioned that it might be better to split Published Apps and Authorized Apps into 2 separate tabs @@ -51,7 +52,7 @@ const OAuthApps = () => { return ( <> -
    +
    @@ -65,95 +66,93 @@ const OAuthApps = () => {
    -
    - {isLoadingPublishedApps && ( -
    - - - + {isLoadingPublishedApps && ( +
    + + + +
    + )} + + {isErrorPublishedApps && ( + + )} + + {createdApp !== undefined && ( + +
    +
    - )} - - {isErrorPublishedApps && ( - - )} - - {createdApp !== undefined && ( - -
    -
    , - Name, - Client ID, - Client Secret, - Created at, - , - ]} - body={ - sortedPublishedApps?.map((app) => ( - { - setShowPublishModal(true) - setSelectedAppToUpdate(app) - }} - onSelectDelete={() => setSelectedAppToDelete(app)} - /> - )) ?? [] - } - /> - )} - - )} - + {isSuccessPublishedApps && ( + <> + {(publishedApps?.length ?? 0) === 0 ? ( +
    +

    You do not have any published applications yet

    +
    + ) : ( +
    , + Name, + Client ID, + Client Secret, + Created at, + , + ]} + body={ + sortedPublishedApps?.map((app) => ( + { + setShowPublishModal(true) + setSelectedAppToUpdate(app) + }} + onSelectDelete={() => setSelectedAppToDelete(app)} + /> + )) ?? [] + } + /> + )} + + )}
    @@ -204,7 +203,7 @@ const OAuthApps = () => { )}
    - + - doPermissionsCheck(allPermissions, PermissionAction.CREATE, 'user_invites', { - resource: { role_id }, - }) + doPermissionsCheck( + allPermissions, + PermissionAction.CREATE, + 'user_invites', + { + resource: { role_id }, + }, + orgId + ) ) const initialValues = { email: '', role: '' } diff --git a/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx b/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx index 1f1335bc84d..1a15465fb53 100644 --- a/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx +++ b/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx @@ -111,7 +111,7 @@ const MembersView = ({ searchString }: MembersViewProps) => { return ( <> -
    +
    { const { ui } = useStore() @@ -70,9 +76,9 @@ const TeamSettings = () => { } return ( - <> -
    -
    + + + } size="small" @@ -82,10 +88,11 @@ const TeamSettings = () => { id="email" placeholder="Filter members" /> -
    - {canAddMembers && profile !== undefined && ( + + {canAddMembers && profile !== undefined && selectedOrganization !== undefined && (
    { )}
    -
    -
    -
    -
    + + -
    - + + ) } diff --git a/studio/components/interfaces/Organization/index.ts b/studio/components/interfaces/Organization/index.ts index 2f0f370861b..bab5d57c0c7 100644 --- a/studio/components/interfaces/Organization/index.ts +++ b/studio/components/interfaces/Organization/index.ts @@ -1,20 +1,21 @@ -import GeneralSettings from './GeneralSettings/GeneralSettings' -import TeamSettings from './TeamSettings/TeamSettings' import BillingSettings from './BillingSettings/BillingSettings' +import BillingSettingsV2 from './BillingSettingsV2/BillingSettings' +import GeneralSettings from './GeneralSettings/GeneralSettings' +import IntegrationSettings from './IntegrationSettings/IntegrationSettings' import InvoicesSettings from './InvoicesSettings/InvoicesSettings' import NewOrgForm from './NewOrg/NewOrgForm' import Usage from './Usage/Usage' import OAuthApps from './OAuthApps/OAuthApps' - -import BillingSettingsV2 from './BillingSettingsV2/BillingSettings' +import TeamSettings from './TeamSettings/TeamSettings' export { - GeneralSettings, - TeamSettings, BillingSettings, BillingSettingsV2, + GeneralSettings, + IntegrationSettings, InvoicesSettings, NewOrgForm, - Usage, OAuthApps, + TeamSettings, + Usage, } diff --git a/studio/components/layouts/IntegrationWindowLayout.tsx b/studio/components/layouts/IntegrationWindowLayout.tsx new file mode 100644 index 00000000000..123e42f205c --- /dev/null +++ b/studio/components/layouts/IntegrationWindowLayout.tsx @@ -0,0 +1,78 @@ +import { FC } from 'react' +import Link from 'next/link' +import { IconChevronRight, IconX, cn } from 'ui' +import { withAuth, useFlag } from 'hooks' +import { observer } from 'mobx-react-lite' +import { BASE_PATH } from 'lib/constants' +import React from 'react' +import { ScaffoldContainer } from './Scaffold' +import { useParams } from 'common' + +const IntegrationWindowLayout: FC = ({ organization, project, children }) => { + const ongoingIncident = useFlag('ongoingIncident') + const maxHeight = ongoingIncident ? 'calc(100vh - 44px)' : '100vh' + + return ( +
    +
    + {children} +
    + ) +} + +const INTEGRATION_LAYOUT_MAX_WIDTH = '' // 'max-w-[720px]' + +export default withAuth(observer(IntegrationWindowLayout)) + +export const IntegrationWindowLayoutWithoutAuth = observer(IntegrationWindowLayout) + +const Header: FC = ({ organization, project }) => { + let stepNumber = organization ? 1 : project ? 2 : 0 + const { externalId } = useParams() + + const title = externalId + ? 'Supabase + Vercel Deploy Button' + : 'Supabase + Vercel Integration Marketplace Connector' + + return ( +
    + +
    +
    +
    + Supabase +
    + +
    + + + +
    +
    + + {title} + +
    +
    +
    + ) +} + +const maxWidthClasses = 'mx-auto w-full max-w-[1600px]' +const paddingClasses = 'px-6 lg:px-14 xl:px-28 2xl:px-32' + +const IntegrationScaffoldContainer = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + return
    +}) + +IntegrationScaffoldContainer.displayName = 'IntegrationScaffoldContainer' + +export { IntegrationScaffoldContainer } diff --git a/studio/components/layouts/OrganizationLayout.tsx b/studio/components/layouts/OrganizationLayout.tsx index c4479bcf785..18dbb620a47 100644 --- a/studio/components/layouts/OrganizationLayout.tsx +++ b/studio/components/layouts/OrganizationLayout.tsx @@ -1,15 +1,54 @@ -import { useSelectedOrganization } from 'hooks' +import { useParams } from 'common' +import { useFlag, useSelectedOrganization } from 'hooks' +import { useRouter } from 'next/router' import { PropsWithChildren } from 'react' -import AccountLayout from './AccountLayout/AccountLayout' +import { Tabs } from 'ui' +import { AccountLayout } from './' +import { ScaffoldContainer, ScaffoldDivider, ScaffoldHeader, ScaffoldTitle } from './Scaffold' const OrganizationLayout = ({ children }: PropsWithChildren<{}>) => { const selectedOrganization = useSelectedOrganization() + const router = useRouter() + const { slug } = useParams() + const id = router.asPath.split('/').at(-1) + const showOAuthApps = useFlag('oauthApps') + const showIntegrationsV2 = useFlag('integrationsV2') + const isOrgBilling = !!selectedOrganization?.subscription_id return ( + + + {selectedOrganization?.name ?? 'Organization'} settings + + + + + + {children} ) diff --git a/studio/components/layouts/Scaffold.tsx b/studio/components/layouts/Scaffold.tsx new file mode 100644 index 00000000000..92c695d0e85 --- /dev/null +++ b/studio/components/layouts/Scaffold.tsx @@ -0,0 +1,129 @@ +import React from 'react' +import { cn } from 'ui' + +const maxWidthClasses = 'mx-auto w-full max-w-[1600px]' +const paddingClasses = 'px-6 lg:px-14 xl:px-28 2xl:px-32' + +// lg: pt - 8 + +const ScaffoldHeader = React.forwardRef>( + ({ className, ...props }, ref) => { + return ( +
    + ) + } +) + +const ScaffoldTitle = React.forwardRef< + HTMLHeadingElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + return

    +}) + +const ScaffoldContainer = React.forwardRef>( + ({ className, ...props }, ref) => { + return
    + } +) + +const ScaffoldDivider = React.forwardRef>( + ({ className, ...props }, ref) => { + return
    + } +) + +const ScaffoldSection = React.forwardRef>( + ({ className, ...props }, ref) => { + return ( +
    + ) + } +) + +const ScaffoldSectionDetail = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + return
    +}) + +const ScaffoldSectionContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + return ( +
    + ) +}) + +// Table and filters +const ScaffoldFilterAndContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + return
    +}) + +// Actions Group +const ScaffoldActionsContainer = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + return
    +}) + +// Actions Group +const ScaffoldActionsGroup = React.forwardRef>( + ({ className, ...props }, ref) => { + return
    + } +) + +// For older layouts +const ScaffoldContainerLegacy = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + return ( +
    + ) +}) + +ScaffoldHeader.displayName = 'ScaffoldHeader' +ScaffoldTitle.displayName = 'ScaffoldTitle' +ScaffoldContainer.displayName = 'ScaffoldContainer' +ScaffoldDivider.displayName = 'ScaffoldDivider' +ScaffoldSection.displayName = 'ScaffoldSection' +ScaffoldSectionDetail.displayName = 'ScaffoldSectionDetail' +ScaffoldSectionContent.displayName = 'ScaffoldSectionContent' +ScaffoldFilterAndContent.displayName = 'ScaffoldFilterAndContent' +ScaffoldActionsContainer.displayName = 'ScaffoldActionsContainer' +ScaffoldActionsGroup.displayName = 'ScaffoldActionsGroup' +ScaffoldContainerLegacy.displayName = 'ScaffoldContainerLegacy' + +export { + ScaffoldHeader, + ScaffoldTitle, + ScaffoldContainer, + ScaffoldDivider, + ScaffoldSection, + ScaffoldSectionDetail, + ScaffoldSectionContent, + ScaffoldFilterAndContent, + ScaffoldActionsContainer, + ScaffoldActionsGroup, + ScaffoldContainerLegacy, +} diff --git a/studio/components/to-be-cleaned/ListIcons.tsx b/studio/components/to-be-cleaned/ListIcons.tsx index 8d38c8feecc..28430ead532 100644 --- a/studio/components/to-be-cleaned/ListIcons.tsx +++ b/studio/components/to-be-cleaned/ListIcons.tsx @@ -2,9 +2,9 @@ import { IconDatabase } from 'ui' import SVG from 'react-inlinesvg' export const vercelIcon = ( -
    +
    { +const AlertError = ({ ref, subject, error, className }: AlertErrorProps) => { const subjectString = subject?.replace(/ /g, '%20') let href = `/support/new?category=dashboard_bug` @@ -21,6 +22,7 @@ const AlertError = ({ ref, subject, error }: AlertErrorProps) => { return ( void + integrationName: IntegrationName +} + +const OrganizationPicker = ({ + label = 'Choose an organization', + onSelectedOrgChange, + integrationName, +}: OrganizationPickerProps) => { + const [selectedOrg, setSelectedOrg] = useState(null) + + /** + * array of integrations installed on all + */ + const { data: integrationData, isLoading: integrationDataLoading } = useIntegrationsQuery() + + /** + * filter integrations to match integrationName + */ + const vercelIntegrationsInstalled = integrationData?.filter( + (integration) => integration.integration.name === 'Vercel' + ) + + const { data, isLoading } = useOrganizationsQuery({ + onSuccess(organizations) { + const firstOrg = organizations?.[0] + if (firstOrg && selectedOrg === null) { + setSelectedOrg(firstOrg) + onSelectedOrgChange?.(firstOrg) + } + }, + }) + + function _onSelectedOrgChange(slug: string) { + const org = data?.find((org) => org.slug === slug) + + if (org) { + setSelectedOrg(org) + onSelectedOrgChange?.(org) + } + } + + if (isLoading || integrationDataLoading) { + return ( + + + Loading... + + + ) + } + + /** + * Organization type with `installationInstalled` added + */ + interface OrganizationsResponseWithInstalledData extends Organization { + installationInstalled?: boolean + } + + /** + * A flat array of org slugs that have integration installed + */ + const flatInstalledConnectionsIds = + integrationData && integrationData.length > 0 + ? integrationData?.map((x) => x.organization.slug) + : [] + + /** + * Organizations with extra `installationInstalled` attribute + * Used to show label/badge and allow/disallow installing + */ + const organizationsWithInstalledData: OrganizationsResponseWithInstalledData[] = data + ? data.map((org) => { + return { + ...org, + installationInstalled: !flatInstalledConnectionsIds.includes(org.slug) ? true : false, + } + }) + : [] + + return ( + + {organizationsWithInstalledData?.map((org) => { + const label = ( +
    + {org.name} + {org.installationInstalled && Integration Installed} +
    + ) + return ( + } + > + {label} + + ) + })} + + ) +} + +export default OrganizationPicker diff --git a/studio/data/integrations/integration-connections-create-mutation.ts b/studio/data/integrations/integration-connections-create-mutation.ts new file mode 100644 index 00000000000..9431e94aafb --- /dev/null +++ b/studio/data/integrations/integration-connections-create-mutation.ts @@ -0,0 +1,65 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { post } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { integrationKeys } from './keys' + +export type IntegrationConnectionsCreateVariables = { + organizationIntegrationId: string + connection: { + foreign_project_id: string + supabase_project_ref: string + metadata: any + } + orgSlug: string | undefined +} + +export async function createIntegrationConnections({ + organizationIntegrationId, + connection, +}: IntegrationConnectionsCreateVariables) { + const response = await post(`${API_URL}/integrations/vercel/connections`, { + organization_integration_id: organizationIntegrationId, + connection, + }) + if (response.error) { + throw response.error + } + + return response +} + +type IntegrationConnectionsCreateData = Awaited> + +export const useIntegrationConnectionsCreateMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions< + IntegrationConnectionsCreateData, + unknown, + IntegrationConnectionsCreateVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + return useMutation< + IntegrationConnectionsCreateData, + unknown, + IntegrationConnectionsCreateVariables + >((vars) => createIntegrationConnections(vars), { + async onSuccess(data, variables, context) { + await Promise.all([ + queryClient.invalidateQueries(integrationKeys.integrationsList()), + queryClient.invalidateQueries(integrationKeys.integrationsListWithOrg(variables.orgSlug)), + queryClient.invalidateQueries( + integrationKeys.vercelProjectList(variables.organizationIntegrationId) + ), + queryClient.invalidateQueries( + integrationKeys.vercelConnectionsList(variables.organizationIntegrationId) + ), + ]) + await onSuccess?.(data, variables, context) + }, + ...options, + }) +} diff --git a/studio/data/integrations/integrations-github-repos-query.ts b/studio/data/integrations/integrations-github-repos-query.ts new file mode 100644 index 00000000000..4b1141df100 --- /dev/null +++ b/studio/data/integrations/integrations-github-repos-query.ts @@ -0,0 +1,137 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { integrationKeys } from './keys' + +// repos fetch +export type IntegrationsVariables = { + orgSlug?: string +} + +export type IntegrationProjectConnection = { + id: string + createdAt: string + from: { + name: string + } + to: { + name: string + } +} + +export type Integration = { + id: string + type: 'VERCEL' | 'NETLIFY' | 'GITHUB' + createdBy: string + createdAt: string + connections: IntegrationProjectConnection[] + metadata?: { + vercelTeam?: string + gitHubConnectionOwner?: string + } +} + +export type IntegrationsResponse = Integration[] + +export async function getIntegrations({ orgSlug }: IntegrationsVariables, signal?: AbortSignal) { + if (!orgSlug) { + throw new Error('orgSlug is required') + } + + const response: IntegrationsResponse = [ + { + id: '1', + type: 'VERCEL', + createdBy: 'alaister@supabase.io', + createdAt: '2023-06-05T06:56:25.565Z', + metadata: { + vercelTeam: 'alaister-team', + }, + connections: [ + { + id: '1.0', + createdAt: '2023-06-05T01:56:25.565Z', + to: { + name: 'www prod', + }, + from: { + name: 'alaister project prod', + }, + }, + { + id: '1.1', + createdAt: '2023-06-14T06:56:25.565Z', + to: { + name: 'www staging', + }, + from: { + name: 'alaister project staging', + }, + }, + { + id: '1.1', + createdAt: '2023-06-02T21:56:25.565Z', + to: { + name: 'bees knees', + }, + from: { + name: 'jonny bee project', + }, + }, + ], + }, + { + id: '2', + type: 'VERCEL', + createdBy: 'alaister@supabase.io', + createdAt: '2023-06-05T06:56:25.565Z', + metadata: { + vercelTeam: 'alaister-team', + }, + connections: [], + }, + { + id: '3', + type: 'GITHUB', + createdBy: 'Alaister', + createdAt: '2023-06-05T06:56:25.565Z', + metadata: { + gitHubConnectionOwner: 'alaister', + }, + connections: [ + { + id: '1.2', + createdAt: '2023-06-05T06:56:25.565Z', + to: { + name: 'supabase/supabase', + }, + from: { + name: 'alaister project', + }, + }, + ], + }, + ] + + // const response = await get(`${API_URL}/projects/${orgSlug}/integrations`, { + // signal, + // }) + // if (response.error) { + // throw response.error + // } + + return response as IntegrationsResponse +} + +export type IntegrationsData = Awaited> +export type IntegrationsError = unknown + +export const useIntegrationsGitHubReposQuery = ( + { orgSlug }: IntegrationsVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => + useQuery( + integrationKeys.integrationsList(), // TODO: make a unique key for github repos + ({ signal }) => getIntegrations({ orgSlug }, signal), + { enabled: enabled && typeof orgSlug !== 'undefined', ...options } + ) diff --git a/studio/data/integrations/integrations-query-org-only.ts b/studio/data/integrations/integrations-query-org-only.ts new file mode 100644 index 00000000000..664f4aced96 --- /dev/null +++ b/studio/data/integrations/integrations-query-org-only.ts @@ -0,0 +1,35 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { integrationKeys } from './keys' +import { IntegrationsVariables, Integration } from './integrations.types' + +export type IntegrationsResponse = Integration[] + +export async function getIntegrations({ orgSlug }: IntegrationsVariables, signal?: AbortSignal) { + if (!orgSlug) { + throw new Error('orgSlug is required') + } + const response = await get(`${API_URL}/integrations/${orgSlug}?expand=true`, { + signal, + }) + if (response.error) { + throw response.error + } + + return response as IntegrationsResponse +} + +export type IntegrationsData = Awaited> +export type ProjectIntegrationConnectionsData = Awaited> +export type IntegrationsError = unknown + +export const useOrgIntegrationsQuery = ( + { orgSlug }: IntegrationsVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => + useQuery( + integrationKeys.integrationsListWithOrg(orgSlug), + ({ signal }) => getIntegrations({ orgSlug }, signal), + { enabled: enabled && typeof orgSlug !== 'undefined', ...options } + ) diff --git a/studio/data/integrations/integrations-query.ts b/studio/data/integrations/integrations-query.ts new file mode 100644 index 00000000000..a522135fa2a --- /dev/null +++ b/studio/data/integrations/integrations-query.ts @@ -0,0 +1,32 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { integrationKeys } from './keys' +import { IntegrationsVariables, Integration } from './integrations.types' + +export type IntegrationsResponse = Integration[] + +export async function getIntegrations({}: {}, signal?: AbortSignal) { + const response = await get(`${API_URL}/integrations`, { + signal, + }) + if (response.error) { + throw response.error + } + + return response as IntegrationsResponse +} + +export type IntegrationsData = Awaited> +export type ProjectIntegrationConnectionsData = Awaited> +export type IntegrationsError = unknown + +export const useIntegrationsQuery = ({ + enabled = true, + ...options +}: UseQueryOptions = {}) => + useQuery( + integrationKeys.integrationsList(), + ({ signal }) => getIntegrations({}, signal), + { enabled: enabled, ...options } + ) diff --git a/studio/data/integrations/integrations-vercel-installed-connection-delete-mutation.ts b/studio/data/integrations/integrations-vercel-installed-connection-delete-mutation.ts new file mode 100644 index 00000000000..58ab5c10fee --- /dev/null +++ b/studio/data/integrations/integrations-vercel-installed-connection-delete-mutation.ts @@ -0,0 +1,58 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { delete_ } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { UserContent } from 'types' +import { integrationKeys } from './keys' + +type DeleteVariables = { + id: string + organization_integration_id: string + orgSlug: string | undefined +} + +export async function deleteConnection( + { organization_integration_id, id }: DeleteVariables, + signal?: AbortSignal +) { + if (!organization_integration_id) { + throw new Error('organization_integration_id is required') + } + + const response = await delete_( + `${API_URL}/integrations/vercel/connections/${id}`, + { organization_integration_id }, + { signal } + ) + + if (response.error) throw response.error + return response +} + +type DeleteContentData = Awaited> + +export const useIntegrationsVercelInstalledConnectionDeleteMutation = ({ + onSuccess, + ...options +}: Omit, 'mutationFn'> = {}) => { + const queryClient = useQueryClient() + return useMutation( + (args) => deleteConnection(args), + { + async onSuccess(data, variables, context) { + console.log('variables in mutate delete onSuccess', variables) + await Promise.all([ + queryClient.invalidateQueries(integrationKeys.integrationsList()), + queryClient.invalidateQueries(integrationKeys.integrationsListWithOrg(variables.orgSlug)), + queryClient.invalidateQueries( + integrationKeys.vercelProjectList(variables.organization_integration_id) + ), + queryClient.invalidateQueries( + integrationKeys.vercelConnectionsList(variables.organization_integration_id) + ), + ]) + await onSuccess?.(data, variables, context) + }, + ...options, + } + ) +} diff --git a/studio/data/integrations/integrations-vercel-installed-connections-query.ts b/studio/data/integrations/integrations-vercel-installed-connections-query.ts new file mode 100644 index 00000000000..f2d4bae400f --- /dev/null +++ b/studio/data/integrations/integrations-vercel-installed-connections-query.ts @@ -0,0 +1,90 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { integrationKeys } from './keys' + +export type VercelProjectsVariables = { + organization_integration_id?: string +} + +export type VercelProjectsResponse = { + id: string + name: string + metadata: { + id: string + name: string + framework: string + link: string + } +} + +export async function getVercelInstalledConnections( + { organization_integration_id }: VercelProjectsVariables, + signal?: AbortSignal +) { + if (!organization_integration_id) { + throw new Error('organization_integration_id is required') + } + + const response = await get( + `${API_URL}/integrations/vercel/connections/${organization_integration_id}`, + { + signal, + } + ) + if (response.error) { + throw response.error + } + + return response as VercelProjectsResponse[] +} + +export type VercelProjectsData = Awaited> +export type VercelProjectsError = unknown + +export const useVercelProjectConnectionsQuery = ( + { organization_integration_id }: VercelProjectsVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + integrationKeys.vercelConnectionsList(organization_integration_id), + ({ signal }) => getVercelInstalledConnections({ organization_integration_id }, signal), + { + enabled: + enabled && + typeof organization_integration_id !== 'undefined' && + typeof organization_integration_id !== 'undefined', + ...options, + } + ) + +/** + * useVercelProjectsPrefetch is used for prefetching data. For example, starting a query loading before a page is navigated to. + * + * @example + * const prefetch = useVercelProjectsPrefetch({ orgSlug }) + * + * return ( + * prefetch()}> + * Start loading on hover + * + * ) + */ +export const useVercelProjectsPrefetch = ({ + organization_integration_id, +}: VercelProjectsVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (organization_integration_id) { + client.prefetchQuery( + integrationKeys.vercelConnectionsList(organization_integration_id), + ({ signal }) => getVercelInstalledConnections({ organization_integration_id }, signal) + ) + } + }, [organization_integration_id]) +} diff --git a/studio/data/integrations/integrations-vercel-projects-query.ts b/studio/data/integrations/integrations-vercel-projects-query.ts new file mode 100644 index 00000000000..5c6556c283f --- /dev/null +++ b/studio/data/integrations/integrations-vercel-projects-query.ts @@ -0,0 +1,91 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { integrationKeys } from './keys' +import { VercelFramework } from './integrations.types' + +export type VercelProjectsVariables = { + organization_integration_id: string | undefined +} + +export type VercelProjectsResponse = { + id: string + name: string + framework: VercelFramework + metadata: { + id: string + name: string + framework: string + link: string + } +} + +export async function getVercelProjects( + { organization_integration_id }: VercelProjectsVariables, + signal?: AbortSignal +) { + if (!organization_integration_id) { + throw new Error('organization_integration_id is required') + } + + const response = await get( + `${API_URL}/integrations/vercel/projects/${organization_integration_id}`, + { + signal, + } + ) + if (response.error) { + throw response.error + } + + return response as VercelProjectsResponse[] +} + +export type VercelProjectsData = Awaited> +export type VercelProjectsError = unknown + +export const useVercelProjectsQuery = ( + { organization_integration_id }: VercelProjectsVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + integrationKeys.vercelProjectList(organization_integration_id), + ({ signal }) => getVercelProjects({ organization_integration_id }, signal), + { + enabled: enabled && typeof organization_integration_id !== 'undefined', + ...options, + } + ) + +/** + * useVercelProjectsPrefetch is used for prefetching data. For example, starting a query loading before a page is navigated to. + * + * @example + * const prefetch = useVercelProjectsPrefetch({ orgSlug }) + * + * return ( + * prefetch()}> + * Start loading on hover + * + * ) + */ +export const useVercelProjectsPrefetch = ({ + // orgSlug, + // orgId, + organization_integration_id, +}: VercelProjectsVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (organization_integration_id) { + client.prefetchQuery( + integrationKeys.vercelProjectList(organization_integration_id), + ({ signal }) => getVercelProjects({ organization_integration_id }, signal) + ) + } + }, [organization_integration_id]) +} diff --git a/studio/data/integrations/integrations.types.ts b/studio/data/integrations/integrations.types.ts new file mode 100644 index 00000000000..e0a84f9fa78 --- /dev/null +++ b/studio/data/integrations/integrations.types.ts @@ -0,0 +1,187 @@ +export type VercelFramework = + | ( + | 'blitzjs' + | 'nextjs' + | 'gatsby' + | 'remix' + | 'astro' + | 'hexo' + | 'eleventy' + | 'docusaurus-2' + | 'docusaurus' + | 'preact' + | 'solidstart' + | 'dojo' + | 'ember' + | 'vue' + | 'scully' + | 'ionic-angular' + | 'angular' + | 'polymer' + | 'svelte' + | 'sveltekit' + | 'sveltekit-1' + | 'ionic-react' + | 'create-react-app' + | 'gridsome' + | 'umijs' + | 'sapper' + | 'saber' + | 'stencil' + | 'nuxtjs' + | 'redwoodjs' + | 'hugo' + | 'jekyll' + | 'brunch' + | 'middleman' + | 'zola' + | 'hydrogen' + | 'vite' + | 'vitepress' + | 'vuepress' + | 'parcel' + | 'sanity' + | 'storybook' + ) + | null + +export type VercelGitLink = + | { + /** + * GitHub link + */ + org?: string + repo?: string + repoId?: number + type?: 'github' + createdAt?: number + deployHooks: { + createdAt?: number + id: string + name: string + ref: string + url: string + }[] + gitCredentialId?: string + updatedAt?: number + sourceless?: boolean + productionBranch?: string + } + | { + /** + * GitLab link + */ + projectId?: string + projectName?: string + projectNameWithNamespace?: string + projectNamespace?: string + projectUrl?: string + type?: 'gitlab' + createdAt?: number + deployHooks: { + createdAt?: number + id: string + name: string + ref: string + url: string + }[] + gitCredentialId?: string + updatedAt?: number + sourceless?: boolean + productionBranch?: string + } + | { + /** + * Bitbucket link + */ + name?: string + slug?: string + owner?: string + type?: 'bitbucket' + uuid?: string + workspaceUuid?: string + createdAt?: number + deployHooks: { + createdAt?: number + id: string + name: string + ref: string + url: string + }[] + gitCredentialId?: string + updatedAt?: number + sourceless?: boolean + productionBranch?: string + } + +export type Imetadata = { + id: string + supabaseConfig: { + projectEnvVars: { + write: boolean + } + } + link?: VercelGitLink + name: string + framework: VercelFramework +} + +export type IntegrationProjectConnection = { + id: string + inserted_at: string + updated_at: string + added_by: addedBy + supabase_project_ref: string + foreign_project_id: string + organization_integration_id: string + metadata: Imetadata +} + +export type IntegrationsVariables = { + orgSlug?: string +} + +export type IntegrationProjectConnectionPayload = { + foreignProjectId: string + supabaseProjectId: string + integrationId: string + metadata: Imetadata +} + +export type userDetails = { + username: string + id: string + primary_email: string +} +type addedBy = userDetails +type updatedBy = userDetails + +export type IntegrationName = 'Vercel' | 'Netlify' | 'GitHub' +export type VercelAcccountType = 'team' | 'personal' +export type VercelSource = 'marketplace' | 'deploy button' + +export type Integration = { + id: string + integration: { + name: IntegrationName + } + added_by: addedBy + inserted_at: string + updated_at: string + connections: IntegrationProjectConnection[] | [] + organization: { + slug: string + } + metadata?: { + vercelTeam?: string + gitHubConnectionOwner?: string + account: { + name: string + type: VercelAcccountType + avatar: string + source: VercelSource + owner_id: string + } + configuration_id: string + } +} diff --git a/studio/data/integrations/keys.ts b/studio/data/integrations/keys.ts new file mode 100644 index 00000000000..c099d9d7783 --- /dev/null +++ b/studio/data/integrations/keys.ts @@ -0,0 +1,9 @@ +export const integrationKeys = { + integrationsListWithOrg: (orgSlug: string | undefined) => + ['organizations', orgSlug, 'integrations'] as const, + integrationsList: () => ['organizations', 'integrations'] as const, + vercelProjectList: (organization_integration_id: string | undefined) => + ['organizations', organization_integration_id, 'vercel-projects'] as const, + vercelConnectionsList: (organization_integration_id: string | undefined) => + ['organizations', organization_integration_id, 'vercel-connections'] as const, +} diff --git a/studio/data/integrations/vercel-integration-create-mutation.ts b/studio/data/integrations/vercel-integration-create-mutation.ts new file mode 100644 index 00000000000..ae99a6e5785 --- /dev/null +++ b/studio/data/integrations/vercel-integration-create-mutation.ts @@ -0,0 +1,65 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { post } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { integrationKeys } from './keys' + +export type VercelIntegrationCreateVariables = { + code: string + configurationId: string + orgSlug: string + metadata: { [key: string]: string } + source: string + // teamId is only present when a team is being installed + // personal accounts (hobby) will not have this value defined + teamId?: string +} + +export async function createVercelIntegration({ + code, + configurationId, + orgSlug, + metadata, + source, + teamId, +}: VercelIntegrationCreateVariables) { + const response = await post(`${API_URL}/integrations/vercel`, { + code, + configuration_id: configurationId, + organization_slug: orgSlug, + metadata, + source, + teamId, + }) + if (response.error) { + throw response.error + } + + return response +} + +type VercelIntegrationCreateData = Awaited> + +export const useVercelIntegrationCreateMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + console.log('mutating') + return useMutation( + (vars) => createVercelIntegration(vars), + { + async onSuccess(data, variables, context) { + await Promise.all([ + queryClient.invalidateQueries(integrationKeys.integrationsList()), + queryClient.invalidateQueries(integrationKeys.integrationsListWithOrg(variables.orgSlug)), + queryClient.invalidateQueries(integrationKeys.vercelProjectList(data.id)), + ]) + await onSuccess?.(data, variables, context) + }, + ...options, + } + ) +} diff --git a/studio/data/projects/project-create-mutation.ts b/studio/data/projects/project-create-mutation.ts new file mode 100644 index 00000000000..f355ee965f4 --- /dev/null +++ b/studio/data/projects/project-create-mutation.ts @@ -0,0 +1,63 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { post } from 'lib/common/fetch' +import { API_URL, PRICING_TIER_PRODUCT_IDS, PROVIDERS } from 'lib/constants' +import { ProjectBase } from 'types' +import { projectKeys } from './keys' + +export type ProjectCreateVariables = { + name: string + organizationId: number + dbPass: string + dbRegion: string + dbSql?: string + configurationId?: string +} + +export async function createProject({ + name, + organizationId, + dbPass, + dbRegion, + dbSql, + configurationId, +}: ProjectCreateVariables) { + const response = await post(`${API_URL}/projects`, { + cloud_provider: PROVIDERS.AWS.id, // hardcoded for DB instances to be under AWS + org_id: organizationId, + name, + db_pass: dbPass, + db_region: dbRegion, + db_sql: dbSql, + db_pricing_tier_id: PRICING_TIER_PRODUCT_IDS.FREE, + // auth_site_url: _store.selectedVercelProjectUrl, + vercel_configuration_id: configurationId, + }) + if (response.error) { + throw response.error + } + + return response as ProjectBase +} + +type ProjectCreateData = Awaited> + +export const useProjectCreateMutation = ({ + onSuccess, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => createProject(vars), + { + async onSuccess(data, variables, context) { + await queryClient.invalidateQueries(projectKeys.list()), + await onSuccess?.(data, variables, context) + }, + ...options, + } + ) +} diff --git a/studio/hooks/misc/useAutoAuthRedirect.tsx b/studio/hooks/misc/useAutoAuthRedirect.tsx index 3a63cae6976..ca97fe8d486 100644 --- a/studio/hooks/misc/useAutoAuthRedirect.tsx +++ b/studio/hooks/misc/useAutoAuthRedirect.tsx @@ -19,9 +19,7 @@ export function usePushNext() { // vercel integration const isVercelIntegration = next.includes('https://vercel.com') if (isVercelIntegration) { - searchParams.delete('returnTo') - - return await router.push(`/vercel/integrate?${searchParams.toString()}`) + return } // database.new integration diff --git a/studio/lib/helpers.ts b/studio/lib/helpers.ts index b5d09d62ab9..767599d3cfa 100644 --- a/studio/lib/helpers.ts +++ b/studio/lib/helpers.ts @@ -247,6 +247,13 @@ export const detectOS = () => { } } +/** + * Pluralize a word based on a count + */ +export function pluralize(count: number, singular: string, plural?: string) { + return count === 1 ? singular : plural || singular + 's' +} + export const isValidHttpUrl = (value: string) => { let url: URL try { diff --git a/studio/lib/integration-utils.ts b/studio/lib/integration-utils.ts new file mode 100644 index 00000000000..63f9a93969f --- /dev/null +++ b/studio/lib/integration-utils.ts @@ -0,0 +1,115 @@ +import { ResponseFailure } from 'types' + +type SupaResponse = T | ResponseFailure + +async function fetchGitHub(url: string, responseJson = true): Promise> { + const response = await fetch(url) + if (!response.ok) { + return { + error: { + code: response.status, + message: response.statusText, + requestId: '', + }, + } + } + try { + return (responseJson ? await response.json() : await response.text()) as T + } catch (error: any) { + return { + error: { + message: error.message, + code: 500, + requestId: '', + }, + } + } +} + +export type File = { + name: string + download_url: string +} + +/** + * Returns the initial migration SQL from a GitHub repo. + * @param externalId An external GitHub URL for example: https://github.com/vercel/next.js/tree/canary/examples/with-supabase + */ +export async function getInitialMigrationSQLFromGitHubRepo( + externalId?: string +): Promise { + if (!externalId) return null + + const [, , , owner, repo, , branch, ...pathSegments] = externalId?.split('/') ?? [] + const path = pathSegments.join('/') + + const baseGitHubUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${path}` + const supabaseFolderUrl = `${baseGitHubUrl}/supabase?ref=${branch}` + const supabaseMigrationsPath = `supabase/migrations` // TODO: read this from the `supabase/config.toml` file + const migrationsFolderUrl = `${baseGitHubUrl}/${supabaseMigrationsPath}?ref=${branch}` + + const [supabaseFilesResponse, migrationFilesResponse] = await Promise.all([ + fetchGitHub(supabaseFolderUrl), + fetchGitHub(migrationsFolderUrl), + ]) + + if ('error' in supabaseFilesResponse) { + console.warn(`Failed to fetch supabase files from GitHub: ${supabaseFilesResponse.error}`) + return null + } + if ('error' in migrationFilesResponse) { + console.warn(`Failed to fetch migration files from GitHub: ${migrationFilesResponse.error}`) + return null + } + + const seedFileUrl = supabaseFilesResponse.find((file) => file.name === 'seed.sql')?.download_url + const sortedFiles = migrationFilesResponse.sort((a, b) => { + // sort by name ascending + if (a.name < b.name) return -1 + if (a.name > b.name) return 1 + return 0 + }) + const migrationFileDownloadUrlPromises = sortedFiles.map((file) => + fetchGitHub(file.download_url, false) + ) + + const [seedFileResponse, ...migrationFileResponses] = await Promise.all([ + seedFileUrl ? fetchGitHub(seedFileUrl, false) : Promise.resolve(''), + ...migrationFileDownloadUrlPromises, + ]) + + const migrations = migrationFileResponses + .filter((response) => !(typeof response === 'object' && 'error' in response)) + .join(';') + const seed = + typeof seedFileResponse === 'object' && 'error' in seedFileResponse ? '' : seedFileResponse + + const migrationsTableSql = /* SQL */ ` + create schema if not exists supabase_migrations; + create table if not exists supabase_migrations.schema_migrations ( + version text not null primary key, + statements text[], + name text + ); + ${sortedFiles.map((file, i) => { + const migration = migrationFileResponses[i] + if (!migration || (typeof migration === 'object' && 'error' in migration)) return '' + + const version = file.name.split('_')[0] + const statements = JSON.stringify( + migration + .split(';') + .map((statement) => statement.trim()) + .filter(Boolean) + ) + + return /* SQL */ ` + insert into supabase_migrations.schema_migrations (version, statements, name) + select '${version}', array_agg(jsonb_statements)::text[], '${file.name}' + from jsonb_array_elements_text($statements$${statements}$statements$::jsonb) as jsonb_statements; + ` + })} + ` + + return `${migrations};${migrationsTableSql};${seed}` +} diff --git a/studio/lib/void.ts b/studio/lib/void.ts new file mode 100644 index 00000000000..edd1d1fe647 --- /dev/null +++ b/studio/lib/void.ts @@ -0,0 +1,7 @@ +// Because {} !== {} in JS, we need a shared object instances for comparison + +export const EMPTY_OBJ = {} + +export const EMPTY_ARR = [] + +export function noop() {} diff --git a/studio/next.config.js b/studio/next.config.js index 482be47c169..d95d252877e 100644 --- a/studio/next.config.js +++ b/studio/next.config.js @@ -201,7 +201,7 @@ const nextConfig = { ] }, images: { - domains: ['github.com'], + domains: ['github.com', 'api-frameworks.vercel.sh', 'vercel.com'], }, // Ref: https://nextjs.org/docs/advanced-features/output-file-tracing#caveats experimental: { diff --git a/studio/pages/integrations/vercel.tsx b/studio/pages/integrations/vercel.tsx new file mode 100644 index 00000000000..9b2a5ac0c5a --- /dev/null +++ b/studio/pages/integrations/vercel.tsx @@ -0,0 +1,19 @@ +import { useEffect } from 'react' + +import IntegrationWindowLayout from 'components/layouts/IntegrationWindowLayout' +import { useRouter } from 'next/router' +import { NextPageWithLayout } from 'types' + +const VercelIntegration: NextPageWithLayout = () => { + const router = useRouter() + + useEffect(() => { + router.push({ pathname: '/integrations/vercel/install', query: router.query }) + }, [router]) + + return <> +} + +VercelIntegration.getLayout = (page) => {page} + +export default VercelIntegration diff --git a/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx b/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx new file mode 100644 index 00000000000..dd89d74d6d4 --- /dev/null +++ b/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx @@ -0,0 +1,380 @@ +import { debounce } from 'lodash' +import { useRouter } from 'next/router' +import { ChangeEvent, useRef, useState } from 'react' + +import { useParams } from 'common' +import { Markdown } from 'components/interfaces/Markdown' +import IntegrationWindowLayout from 'components/layouts/IntegrationWindowLayout' +import { ScaffoldContainer, ScaffoldDivider } from 'components/layouts/Scaffold' +import PasswordStrengthBar from 'components/ui/PasswordStrengthBar' +import { useProjectApiQuery } from 'data/config/project-api-query' +import { useIntegrationConnectionsCreateMutation } from 'data/integrations/integration-connections-create-mutation' +import { useIntegrationsQuery } from 'data/integrations/integrations-query' +import { useVercelProjectsQuery } from 'data/integrations/integrations-vercel-projects-query' +import { Integration } from 'data/integrations/integrations.types' +import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import { useProjectCreateMutation } from 'data/projects/project-create-mutation' +import generator from 'generate-password' +import { useSelectedOrganization, useStore } from 'hooks' +import { AWS_REGIONS, DEFAULT_MINIMUM_PASSWORD_STRENGTH, PROVIDERS } from 'lib/constants' +import { passwordStrength } from 'lib/helpers' +import { getInitialMigrationSQLFromGitHubRepo } from 'lib/integration-utils' +import { NextPageWithLayout, ProjectBase } from 'types' +import { Alert, Button, Checkbox, IconBook, IconLifeBuoy, Input, Listbox, LoadingLine } from 'ui' + +const VercelIntegration: NextPageWithLayout = () => { + const [loading, setLoading] = useState(false) + + return ( + <> +
    + + + <> + +

    New project

    + <> + + + +
    + + + + + + + + +
    + +
    + Docs +
    +
    + Support +
    +
    + + ) +} + +VercelIntegration.getLayout = (page) => {page} + +const CreateProject = ({ + loading, + setLoading, +}: { + loading: boolean + setLoading: (e: boolean) => void +}) => { + const router = useRouter() + const { ui } = useStore() + const selectedOrganization = useSelectedOrganization() + const [projectName, setProjectName] = useState('') + const [dbPass, setDbPass] = useState('') + const [passwordStrengthMessage, setPasswordStrengthMessage] = useState('') + const [passwordStrengthScore, setPasswordStrengthScore] = useState(-1) + const [shouldRunMigrations, setShouldRunMigrations] = useState(true) + const [dbRegion, setDbRegion] = useState(PROVIDERS.AWS.default_region) + // const [loading, setLoading] = useState(false) + const delayedCheckPasswordStrength = useRef( + debounce((value: string) => checkPasswordStrength(value), 300) + ).current + + const { + slug, + configurationId, + next, + currentProjectId: foreignProjectId, + externalId, + } = useParams() + + const { mutateAsync: createConnections, isLoading: isLoadingCreateConnections } = + useIntegrationConnectionsCreateMutation({}) + + const { data: organizationData, isLoading: isLoadingOrganizationsQuery } = useOrganizationsQuery() + + const organization = organizationData?.find((x) => x.slug === slug) + + /** + * array of integrations installed + */ + const { data: integrationData, isLoading: integrationDataLoading } = useIntegrationsQuery() + + /** + * the vercel integration installed for organization chosen + */ + const organizationIntegration: Integration | undefined = integrationData?.find( + (x) => x.organization.slug === slug + ) + + /** + * Vercel projects available for this integration + */ + const { data: vercelProjects } = useVercelProjectsQuery( + { + organization_integration_id: organizationIntegration?.id, + }, + { enabled: organizationIntegration !== undefined } + ) + + const canSubmit = + projectName != '' && + passwordStrengthScore >= DEFAULT_MINIMUM_PASSWORD_STRENGTH && + dbRegion != undefined + + function onProjectNameChange(e: ChangeEvent) { + e.target.value = e.target.value.replace(/\./g, '') + setProjectName(e.target.value) + } + + function onDbPassChange(e: ChangeEvent) { + const value = e.target.value + setDbPass(value) + if (value == '') { + setPasswordStrengthScore(-1) + setPasswordStrengthMessage('') + } else delayedCheckPasswordStrength(value) + } + + async function checkPasswordStrength(value: string) { + const { message, strength } = await passwordStrength(value) + setPasswordStrengthScore(strength) + setPasswordStrengthMessage(message) + } + + function generateStrongPassword() { + const password = generator.generate({ + length: 16, + numbers: true, + uppercase: true, + }) + + setDbPass(password) + delayedCheckPasswordStrength(password) + } + + const { mutateAsync: createProject } = useProjectCreateMutation() + + const [newProjectRef, setNewProjectRef] = useState(undefined) + async function onCreateProject() { + if (!organizationIntegration) { + console.error('No organization installation details found') + } + + if (!organizationIntegration?.id) { + console.error('No organization installation ID found') + return + } + + if (!foreignProjectId) { + console.error('No foreignProjectId ID set') + return + } + + if (!configurationId) { + console.error('No configurationId ID set') + return + } + + setLoading(true) + + try { + if (!organization) { + throw new Error('No organization set') + } + + let dbSql: string | undefined + if (shouldRunMigrations) { + const id = ui.setNotification({ + category: 'info', + message: `Fetching initial migrations from GitHub repo`, + }) + + dbSql = (await getInitialMigrationSQLFromGitHubRepo(externalId)) ?? undefined + + ui.setNotification({ + id, + category: 'success', + message: `Done fetching initial migrations`, + }) + } + + let project: ProjectBase + + try { + project = await createProject({ + organizationId: organization.id, + name: projectName, + dbPass, + dbRegion, + dbSql, + configurationId, + }) + + setNewProjectRef(project.ref) + } catch (error: any) { + setLoading(false) + ui.setNotification({ + category: 'error', + message: `Failed to create project: ${error.message}`, + }) + return + } + } catch (error) { + console.error('Error', error) + setLoading(false) + } + } + const isInstallingRef = useRef(false) + + // Wait for the new project to be created before creating the connection + useProjectApiQuery( + { projectRef: newProjectRef }, + { + enabled: newProjectRef !== undefined, + // refetch until the project is created + refetchInterval: (data) => { + return (data?.autoApiService.service_api_keys.length ?? 0) > 0 ? false : 1000 + }, + async onSuccess(data) { + const isReady = data.autoApiService.service_api_keys.length > 0 + + if ( + !isReady || + !organizationIntegration || + !foreignProjectId || + !newProjectRef || + isInstallingRef.current + ) { + return + } + isInstallingRef.current = true + + const projectDetails = vercelProjects?.find((x) => x.id === foreignProjectId) + + // Wrap the createConnections function call in a try-catch block + try { + await createConnections({ + organizationIntegrationId: organizationIntegration?.id, + connection: { + foreign_project_id: foreignProjectId, + supabase_project_ref: newProjectRef, + metadata: { + ...projectDetails, + supabaseConfig: { + projectEnvVars: { + write: true, + }, + }, + }, + }, + orgSlug: selectedOrganization?.slug, + }) + } catch (error) { + console.error('An error occurred during createConnections:', error) + return + } + + setLoading(false) + + if (next) { + window.location.href = next + } + }, + } + ) + + return ( +
    +

    Project details for integration

    +
    + +
    +
    + 0} + onChange={onDbPassChange} + descriptionText={ + + } + /> +
    +
    +
    + setDbRegion(region)} + descriptionText="Select a region close to your users for the best performance." + > + {Object.keys(AWS_REGIONS).map((option: string, i) => { + const label = Object.values(AWS_REGIONS)[i] + return ( + ( + region icon + )} + > + {label} + + ) + })} + +
    +
    +
    + setShouldRunMigrations(e.target.checked)} + /> +
    +
    + +
    +
    + ) +} + +export default VercelIntegration diff --git a/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx b/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx new file mode 100644 index 00000000000..75a9ae1bece --- /dev/null +++ b/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx @@ -0,0 +1,108 @@ +import { useMemo, useState } from 'react' + +import { useParams } from 'common' +import ProjectLinker from 'components/interfaces/Integrations/ProjectLinker' +import { Markdown } from 'components/interfaces/Markdown' +import IntegrationWindowLayout from 'components/layouts/IntegrationWindowLayout' +import { ScaffoldContainer, ScaffoldDivider } from 'components/layouts/Scaffold' +import { useOrgIntegrationsQuery } from 'data/integrations/integrations-query-org-only' +import { useVercelProjectsQuery } from 'data/integrations/integrations-vercel-projects-query' +import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import { useProjectsQuery } from 'data/projects/projects-query' +import { EMPTY_ARR } from 'lib/void' +import { NextPageWithLayout, Organization } from 'types' +import { IconBook, IconLifeBuoy, LoadingLine } from 'ui' + +const VercelIntegration: NextPageWithLayout = () => { + const { slug, configurationId, next, organizationSlug } = useParams() + + const [loading, setLoading] = useState(false) + + /** + * Fetch the list of organization based integration installations for Vercel. + * + * Array of integrations installed on all + */ + const { data: integrationData, isLoading: integrationDataLoading } = useOrgIntegrationsQuery({ + orgSlug: slug, + }) + + const { data, isLoading: isLoadingOrganizationsQuery } = useOrganizationsQuery({ + enabled: slug !== undefined, + }) + + const organization = data?.find((organization: Organization) => organization.slug === slug) + + const integration = integrationData?.find((x) => x.metadata?.configuration_id === configurationId) + + const { data: supabaseProjectsData } = useProjectsQuery({ + enabled: integration?.id !== undefined, + }) + + const supabaseProjects = useMemo( + () => + supabaseProjectsData + ?.filter((project) => project.organization_id === organization?.id) + .map((project) => ({ id: project.id.toString(), name: project.name, ref: project.ref })) ?? + EMPTY_ARR, + [organizationSlug, supabaseProjectsData] + ) + + const { data: vercelProjectsData } = useVercelProjectsQuery( + { + organization_integration_id: integration?.id, + }, + { enabled: integration?.id !== undefined } + ) + + const vercelProjects = useMemo(() => vercelProjectsData ?? EMPTY_ARR, [vercelProjectsData]) + + return ( + <> +
    + + <> + +
    +

    + Link a Supabase project to a Vercel project +

    + +
    + { + if (next) { + window.location.href = next + } + }} + installedConnections={integration?.connections} + setLoading={setLoading} + /> +
    + + + +
    + +
    + Docs +
    +
    + Support +
    +
    + + ) +} + +VercelIntegration.getLayout = (page) => {page} + +export default VercelIntegration diff --git a/studio/pages/integrations/vercel/install.tsx b/studio/pages/integrations/vercel/install.tsx new file mode 100644 index 00000000000..209884386d6 --- /dev/null +++ b/studio/pages/integrations/vercel/install.tsx @@ -0,0 +1,292 @@ +import { useParams } from 'common' +import { Markdown } from 'components/interfaces/Markdown' +import IntegrationWindowLayout from 'components/layouts/IntegrationWindowLayout' +import { ScaffoldContainer, ScaffoldDivider } from 'components/layouts/Scaffold' +import { useIntegrationsQuery } from 'data/integrations/integrations-query' +import { IntegrationName } from 'data/integrations/integrations.types' +import { useVercelIntegrationCreateMutation } from 'data/integrations/vercel-integration-create-mutation' +import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import { useStore } from 'hooks' +import { useRouter } from 'next/router' +import { useState } from 'react' +import { useVercelIntegrationInstallationState } from 'state/vercel-integration-installation' +import { NextPageWithLayout, Organization } from 'types' +import { Alert, Badge, Button, IconBook, IconHexagon, IconLifeBuoy, Listbox, LoadingLine } from 'ui' + +/** + * Organization type with `installationInstalled` added + */ +interface OrganizationsResponseWithInstalledData extends Organization { + installationInstalled?: boolean +} + +/** + * Variations of the Vercel integration flow. + * They require different UI and logic. + * + * Deploy Button - the flow that starts from the Deploy Button - https://vercel.com/docs/integrations#deploy-button + * Marketplace - the flow that starts from the Marketplace - https://vercel.com/integrations + * + */ +export type VercelIntegrationFlow = 'deploy-button' | 'marketing' + +const VercelIntegration: NextPageWithLayout = () => { + const router = useRouter() + const { ui } = useStore() + const { code, configurationId, next, teamId, source, externalId } = useParams() + const [selectedOrg, setSelectedOrg] = useState(null) + const [organizationIntegrationId, setOrganizationIntegrationId] = useState(null) + + const snapshot = useVercelIntegrationInstallationState() + + const flow: VercelIntegrationFlow = externalId ? 'marketing' : 'deploy-button' + + /** + * Fetch the list of organization based integration installations for Vercel. + * + * Array of integrations installed on all + */ + const { data: integrationData, isLoading: integrationDataLoading } = useIntegrationsQuery() + + const { data: organizationsData, isLoading: isLoadingOrganizationsQuery } = useOrganizationsQuery( + { + onSuccess(organizations) { + const firstOrg = organizations?.[0] + if (firstOrg && selectedOrg === null) { + setSelectedOrg(firstOrg) + snapshot.setSelectedOrganizationSlug(firstOrg.slug) + router.query.organizationSlug = firstOrg.slug + } + }, + } + ) + + /** + * Flat array of org slugs that have integration installed + * + */ + const flatInstalledConnectionsIds: string[] | [] = + integrationData && integrationData.length > 0 + ? integrationData?.map((x) => x.organization.slug) + : [] + + /** + * Organizations with extra `installationInstalled` attribute + * + * Used to show label/badge and allow/disallow installing + * + */ + const organizationsWithInstalledData: OrganizationsResponseWithInstalledData[] = organizationsData + ? organizationsData.map((org) => { + return { + ...org, + installationInstalled: flatInstalledConnectionsIds.includes(org.slug) ? true : false, + } + }) + : [] + + const { mutate, isLoading: isLoadingVercelIntegrationCreateMutation } = + useVercelIntegrationCreateMutation({ + onSuccess({ id }) { + const orgSlug = selectedOrg?.slug + + setOrganizationIntegrationId(id) + + if (externalId) { + router.push({ + pathname: `/integrations/vercel/${orgSlug}/deploy-button/new-project`, + query: router.query, + }) + } else { + router.push({ + pathname: `/integrations/vercel/${orgSlug}/marketplace/choose-project`, + query: router.query, + }) + } + }, + onError(error: any) { + ui.setNotification({ + category: 'error', + message: `Creating Vercel integration failed: ${error.message}`, + }) + }, + }) + + function onInstall() { + const orgSlug = selectedOrg?.slug + + const isIntegrationInstalled = organizationsWithInstalledData.some( + (x) => x.slug === orgSlug && x.installationInstalled + ) + + if (!orgSlug) { + return ui.setNotification({ category: 'error', message: 'Please select an organization' }) + } + + if (!code) { + return ui.setNotification({ category: 'error', message: 'Vercel code missing' }) + } + + if (!configurationId) { + return ui.setNotification({ category: 'error', message: 'Vercel Configuration ID missing' }) + } + + if (!source) { + return ui.setNotification({ + category: 'error', + message: 'Vercel Configuration source missing', + }) + } + + /** + * Only install if instgration hasn't already been installed + */ + if (!isIntegrationInstalled) { + mutate({ + code, + configurationId, + orgSlug, + metadata: {}, + source, + teamId: teamId, + }) + } + } + + const dataLoading = isLoadingVercelIntegrationCreateMutation || isLoadingOrganizationsQuery + + return ( + <> +
    + + {organizationIntegrationId === null && ( + <> + +

    Choose organization

    + <> + + { + router.query.organizationSlug = e.slug + setSelectedOrg(e) + }} + dataLoading={dataLoading} + /> +
    + +
    + +
    + + + + + + + )} + + +
    + +
    + Docs +
    +
    + Support +
    +
    + + ) +} + +VercelIntegration.getLayout = (page) => {page} + +export interface OrganizationPickerProps { + label?: string + onSelectedOrgChange?: (org: Organization) => void + integrationName: IntegrationName + dataLoading: boolean + organizationsWithInstalledData: OrganizationsResponseWithInstalledData[] +} + +const OrganizationPicker = ({ + label = 'Choose an organization', + onSelectedOrgChange, + integrationName, + dataLoading, + organizationsWithInstalledData, +}: OrganizationPickerProps) => { + const { ui } = useStore() + const [selectedOrg, setSelectedOrg] = useState(null) + + const { data, isLoading } = useOrganizationsQuery({ + onSuccess(organizations) { + const firstOrg = organizations?.[0] + if (firstOrg && selectedOrg === null) { + setSelectedOrg(firstOrg) + onSelectedOrgChange?.(firstOrg) + } + }, + }) + + function _onSelectedOrgChange(slug: string) { + const org = data?.find((org) => org.slug === slug) + + if (org) { + setSelectedOrg(org) + onSelectedOrgChange?.(org) + } + } + + if (dataLoading) { + return ( + + + Loading... + + + ) + } + + return ( + + {organizationsWithInstalledData?.map((org) => { + const label = ( +
    + {org.name} + {org.installationInstalled && Integration Installed} +
    + ) + return ( + } + > + {label} + + ) + })} +
    + ) +} + +export default VercelIntegration diff --git a/studio/pages/org/[slug]/apps.tsx b/studio/pages/org/[slug]/apps.tsx index 000ea4d1ec1..c53ee052c42 100644 --- a/studio/pages/org/[slug]/apps.tsx +++ b/studio/pages/org/[slug]/apps.tsx @@ -1,8 +1,3 @@ -import { observer } from 'mobx-react-lite' -import { useRouter } from 'next/router' -import { Tabs } from 'ui' - -import { useParams } from 'common/hooks' import { OAuthApps } from 'components/interfaces/Organization' import { OrganizationLayout } from 'components/layouts' import Loading from 'components/ui/Loading' @@ -11,47 +6,21 @@ import { useSelectedOrganization } from 'hooks' import { NextPageWithLayout } from 'types' const OrgOAuthApps: NextPageWithLayout = () => { - const { slug } = useParams() - const router = useRouter() const selectedOrganization = useSelectedOrganization() - const { isLoading: isLoadingPermissions } = usePermissionsQuery() + // const { isLoading: isLoadingPermissions } = usePermissionsQuery() + const { data: permissions } = usePermissionsQuery() return ( <> - {selectedOrganization === undefined && isLoadingPermissions ? ( + {selectedOrganization === undefined && (permissions ?? []).length === 0 ? ( ) : ( -
    -
    -
    -

    {selectedOrganization?.name ?? 'Organization'} settings

    -
    - -
    - -
    - -
    -
    + )} ) } OrgOAuthApps.getLayout = (page) => {page} -export default observer(OrgOAuthApps) + +export default OrgOAuthApps diff --git a/studio/pages/org/[slug]/billing.tsx b/studio/pages/org/[slug]/billing.tsx index 6ffb53b753b..729090220b5 100644 --- a/studio/pages/org/[slug]/billing.tsx +++ b/studio/pages/org/[slug]/billing.tsx @@ -1,22 +1,13 @@ -import { useRouter } from 'next/router' - -import { useParams } from 'common/hooks' import { BillingSettings, BillingSettingsV2 } from 'components/interfaces/Organization' import { OrganizationLayout } from 'components/layouts' import Loading from 'components/ui/Loading' import { usePermissionsQuery } from 'data/permissions/permissions-query' -import { useFlag, useSelectedOrganization } from 'hooks' +import { useSelectedOrganization } from 'hooks' import { NextPageWithLayout } from 'types' -import { Tabs } from 'ui' const OrgBillingSettings: NextPageWithLayout = () => { - const { slug } = useParams() - const router = useRouter() - - const selectedOrganization = useSelectedOrganization() const { isLoading: isLoadingPermissions } = usePermissionsQuery() - - const showOAuthApps = useFlag('oauthApps') + const selectedOrganization = useSelectedOrganization() const isOrgBilling = !!selectedOrganization?.subscription_id return ( @@ -24,40 +15,15 @@ const OrgBillingSettings: NextPageWithLayout = () => { {selectedOrganization === undefined && isLoadingPermissions ? ( ) : ( -
    -
    -
    -

    {selectedOrganization?.name ?? 'Organization'} settings

    -
    - -
    - -
    - {isOrgBilling ? ( - - ) : ( -
    - -
    - )} -
    -
    + <> + {isOrgBilling ? ( + + ) : ( +
    + +
    + )} + )} ) diff --git a/studio/pages/org/[slug]/general.tsx b/studio/pages/org/[slug]/general.tsx index 6ac6f02f674..a2144d6383f 100644 --- a/studio/pages/org/[slug]/general.tsx +++ b/studio/pages/org/[slug]/general.tsx @@ -1,53 +1,20 @@ -import { useRouter } from 'next/router' - -import { useParams } from 'common/hooks' import { GeneralSettings } from 'components/interfaces/Organization' import { OrganizationLayout } from 'components/layouts' import Loading from 'components/ui/Loading' import { usePermissionsQuery } from 'data/permissions/permissions-query' -import { useFlag, useSelectedOrganization } from 'hooks' +import { useSelectedOrganization } from 'hooks' import { NextPageWithLayout } from 'types' -import { Tabs } from 'ui' const OrgGeneralSettings: NextPageWithLayout = () => { const { data: permissions } = usePermissionsQuery() const selectedOrganization = useSelectedOrganization() - const { slug } = useParams() - const router = useRouter() - const showOAuthApps = useFlag('oauthApps') return ( <> {selectedOrganization === undefined && (permissions ?? []).length === 0 ? ( ) : ( -
    -
    -
    -

    {selectedOrganization?.name ?? 'Organization'} settings

    -
    - -
    - -
    - -
    -
    + )} ) diff --git a/studio/pages/org/[slug]/integrations.tsx b/studio/pages/org/[slug]/integrations.tsx new file mode 100644 index 00000000000..0e54ef2778c --- /dev/null +++ b/studio/pages/org/[slug]/integrations.tsx @@ -0,0 +1,11 @@ +import { NextPageWithLayout } from 'types' +import { OrganizationLayout } from 'components/layouts' +import { IntegrationSettings } from 'components/interfaces/Organization' + +const OrgIntegrationSettings: NextPageWithLayout = () => { + return +} + +OrgIntegrationSettings.getLayout = (page) => {page} + +export default OrgIntegrationSettings diff --git a/studio/pages/org/[slug]/invoices.tsx b/studio/pages/org/[slug]/invoices.tsx index 4219e34c41d..bb21316adbd 100644 --- a/studio/pages/org/[slug]/invoices.tsx +++ b/studio/pages/org/[slug]/invoices.tsx @@ -1,58 +1,25 @@ -import { observer } from 'mobx-react-lite' -import { useRouter } from 'next/router' -import { Tabs } from 'ui' - -import { useParams } from 'common/hooks' import { InvoicesSettings } from 'components/interfaces/Organization' import { OrganizationLayout } from 'components/layouts' import Loading from 'components/ui/Loading' import { usePermissionsQuery } from 'data/permissions/permissions-query' -import { useFlag, useSelectedOrganization } from 'hooks' +import { useSelectedOrganization } from 'hooks' import { NextPageWithLayout } from 'types' const OrgInvoices: NextPageWithLayout = () => { const { data: permissions } = usePermissionsQuery() const selectedOrganization = useSelectedOrganization() - const { slug } = useParams() - const router = useRouter() - const showOAuthApps = useFlag('oauthApps') return ( <> {selectedOrganization === undefined && (permissions ?? []).length === 0 ? ( ) : ( -
    -
    -
    -

    {selectedOrganization?.name ?? 'Organization'} settings

    -
    - -
    - -
    - -
    -
    + )} ) } OrgInvoices.getLayout = (page) => {page} -export default observer(OrgInvoices) + +export default OrgInvoices diff --git a/studio/pages/org/[slug]/team.tsx b/studio/pages/org/[slug]/team.tsx index 8d024a5652f..579249d3001 100644 --- a/studio/pages/org/[slug]/team.tsx +++ b/studio/pages/org/[slug]/team.tsx @@ -1,53 +1,20 @@ -import { useRouter } from 'next/router' - -import { useParams } from 'common/hooks' import { TeamSettings } from 'components/interfaces/Organization' import { OrganizationLayout } from 'components/layouts' import Loading from 'components/ui/Loading' import { usePermissionsQuery } from 'data/permissions/permissions-query' -import { useFlag, useSelectedOrganization } from 'hooks' +import { useSelectedOrganization } from 'hooks' import { NextPageWithLayout } from 'types' -import { Tabs } from 'ui' const OrgTeamSettings: NextPageWithLayout = () => { const { data: permissions } = usePermissionsQuery() const selectedOrganization = useSelectedOrganization() - const { slug } = useParams() - const router = useRouter() - const showOAuthApps = useFlag('oauthApps') return ( <> {selectedOrganization === undefined && (permissions ?? []).length === 0 ? ( ) : ( -
    -
    -
    -

    {selectedOrganization?.name ?? 'Organization'} settings

    -
    - -
    - -
    - -
    -
    + )} ) diff --git a/studio/public/img/icons/frameworks/angular.svg b/studio/public/img/icons/frameworks/angular.svg new file mode 100644 index 00000000000..4bfc7d11a1e --- /dev/null +++ b/studio/public/img/icons/frameworks/angular.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/astro.svg b/studio/public/img/icons/frameworks/astro.svg new file mode 100644 index 00000000000..afbe94eaefa --- /dev/null +++ b/studio/public/img/icons/frameworks/astro.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/studio/public/img/icons/frameworks/blitzjs.svg b/studio/public/img/icons/frameworks/blitzjs.svg new file mode 100644 index 00000000000..b72ad1c56b0 --- /dev/null +++ b/studio/public/img/icons/frameworks/blitzjs.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/studio/public/img/icons/frameworks/brunch.svg b/studio/public/img/icons/frameworks/brunch.svg new file mode 100644 index 00000000000..a2783a02f57 --- /dev/null +++ b/studio/public/img/icons/frameworks/brunch.svg @@ -0,0 +1,3 @@ + + + diff --git a/studio/public/img/icons/frameworks/create-react-app.svg b/studio/public/img/icons/frameworks/create-react-app.svg new file mode 100644 index 00000000000..523333e1a8c --- /dev/null +++ b/studio/public/img/icons/frameworks/create-react-app.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/docusaurus-2.svg b/studio/public/img/icons/frameworks/docusaurus-2.svg new file mode 100644 index 00000000000..1d2a15489c8 --- /dev/null +++ b/studio/public/img/icons/frameworks/docusaurus-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/docusaurus.svg b/studio/public/img/icons/frameworks/docusaurus.svg new file mode 100644 index 00000000000..1d2a15489c8 --- /dev/null +++ b/studio/public/img/icons/frameworks/docusaurus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/dojo.svg b/studio/public/img/icons/frameworks/dojo.svg new file mode 100644 index 00000000000..0bcb896104f --- /dev/null +++ b/studio/public/img/icons/frameworks/dojo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/eleventy.svg b/studio/public/img/icons/frameworks/eleventy.svg new file mode 100644 index 00000000000..3d10e558a63 --- /dev/null +++ b/studio/public/img/icons/frameworks/eleventy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/ember.svg b/studio/public/img/icons/frameworks/ember.svg new file mode 100644 index 00000000000..2b54132e87e --- /dev/null +++ b/studio/public/img/icons/frameworks/ember.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/gatsby.svg b/studio/public/img/icons/frameworks/gatsby.svg new file mode 100644 index 00000000000..eb92b14c5ff --- /dev/null +++ b/studio/public/img/icons/frameworks/gatsby.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/gridsome.svg b/studio/public/img/icons/frameworks/gridsome.svg new file mode 100644 index 00000000000..d1d8e28a525 --- /dev/null +++ b/studio/public/img/icons/frameworks/gridsome.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/hexo.svg b/studio/public/img/icons/frameworks/hexo.svg new file mode 100644 index 00000000000..ce164e536a8 --- /dev/null +++ b/studio/public/img/icons/frameworks/hexo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/hugo.svg b/studio/public/img/icons/frameworks/hugo.svg new file mode 100644 index 00000000000..593a4cb913f --- /dev/null +++ b/studio/public/img/icons/frameworks/hugo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/studio/public/img/icons/frameworks/hydrogen.svg b/studio/public/img/icons/frameworks/hydrogen.svg new file mode 100644 index 00000000000..0476a43d8c3 --- /dev/null +++ b/studio/public/img/icons/frameworks/hydrogen.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/ionic-angular.svg b/studio/public/img/icons/frameworks/ionic-angular.svg new file mode 100644 index 00000000000..639bca02062 --- /dev/null +++ b/studio/public/img/icons/frameworks/ionic-angular.svg @@ -0,0 +1,4 @@ + + + + diff --git a/studio/public/img/icons/frameworks/ionic-react.svg b/studio/public/img/icons/frameworks/ionic-react.svg new file mode 100644 index 00000000000..639bca02062 --- /dev/null +++ b/studio/public/img/icons/frameworks/ionic-react.svg @@ -0,0 +1,4 @@ + + + + diff --git a/studio/public/img/icons/frameworks/jekyll.svg b/studio/public/img/icons/frameworks/jekyll.svg new file mode 100644 index 00000000000..bb804644182 --- /dev/null +++ b/studio/public/img/icons/frameworks/jekyll.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/studio/public/img/icons/frameworks/middleman.svg b/studio/public/img/icons/frameworks/middleman.svg new file mode 100644 index 00000000000..a66ed5bf783 --- /dev/null +++ b/studio/public/img/icons/frameworks/middleman.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/studio/public/img/icons/frameworks/nextjs.svg b/studio/public/img/icons/frameworks/nextjs.svg new file mode 100644 index 00000000000..8a2f50cac01 --- /dev/null +++ b/studio/public/img/icons/frameworks/nextjs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/nuxtjs.svg b/studio/public/img/icons/frameworks/nuxtjs.svg new file mode 100644 index 00000000000..05a0a131921 --- /dev/null +++ b/studio/public/img/icons/frameworks/nuxtjs.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/studio/public/img/icons/frameworks/parcel.svg b/studio/public/img/icons/frameworks/parcel.svg new file mode 100644 index 00000000000..01e10d87175 --- /dev/null +++ b/studio/public/img/icons/frameworks/parcel.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/studio/public/img/icons/frameworks/polymer.svg b/studio/public/img/icons/frameworks/polymer.svg new file mode 100644 index 00000000000..8c1d5dfb9e6 --- /dev/null +++ b/studio/public/img/icons/frameworks/polymer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/preact.svg b/studio/public/img/icons/frameworks/preact.svg new file mode 100644 index 00000000000..33f1d876e9c --- /dev/null +++ b/studio/public/img/icons/frameworks/preact.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/redwoodjs.svg b/studio/public/img/icons/frameworks/redwoodjs.svg new file mode 100644 index 00000000000..d28afcf66dd --- /dev/null +++ b/studio/public/img/icons/frameworks/redwoodjs.svg @@ -0,0 +1 @@ + diff --git a/studio/public/img/icons/frameworks/remix.svg b/studio/public/img/icons/frameworks/remix.svg new file mode 100644 index 00000000000..ffcdf8287ed --- /dev/null +++ b/studio/public/img/icons/frameworks/remix.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/studio/public/img/icons/frameworks/saber.svg b/studio/public/img/icons/frameworks/saber.svg new file mode 100644 index 00000000000..95dc79b0736 --- /dev/null +++ b/studio/public/img/icons/frameworks/saber.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/sanity.svg b/studio/public/img/icons/frameworks/sanity.svg new file mode 100644 index 00000000000..d2b7aaba44a --- /dev/null +++ b/studio/public/img/icons/frameworks/sanity.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/studio/public/img/icons/frameworks/sapper.svg b/studio/public/img/icons/frameworks/sapper.svg new file mode 100644 index 00000000000..40c960773e8 --- /dev/null +++ b/studio/public/img/icons/frameworks/sapper.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/scully.svg b/studio/public/img/icons/frameworks/scully.svg new file mode 100644 index 00000000000..8cd05527a39 --- /dev/null +++ b/studio/public/img/icons/frameworks/scully.svg @@ -0,0 +1,94 @@ + + + + +logo + + + + + + + + + + + + + + + + diff --git a/studio/public/img/icons/frameworks/solidstart.svg b/studio/public/img/icons/frameworks/solidstart.svg new file mode 100644 index 00000000000..a6a1ccf10ae --- /dev/null +++ b/studio/public/img/icons/frameworks/solidstart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/stencil.svg b/studio/public/img/icons/frameworks/stencil.svg new file mode 100644 index 00000000000..136745e5477 --- /dev/null +++ b/studio/public/img/icons/frameworks/stencil.svg @@ -0,0 +1,4 @@ + + + + diff --git a/studio/public/img/icons/frameworks/storybook.svg b/studio/public/img/icons/frameworks/storybook.svg new file mode 100644 index 00000000000..d94c0ebd176 --- /dev/null +++ b/studio/public/img/icons/frameworks/storybook.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/studio/public/img/icons/frameworks/svelte.svg b/studio/public/img/icons/frameworks/svelte.svg new file mode 100644 index 00000000000..40c960773e8 --- /dev/null +++ b/studio/public/img/icons/frameworks/svelte.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/sveltekit-1.svg b/studio/public/img/icons/frameworks/sveltekit-1.svg new file mode 100644 index 00000000000..40c960773e8 --- /dev/null +++ b/studio/public/img/icons/frameworks/sveltekit-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/sveltekit.svg b/studio/public/img/icons/frameworks/sveltekit.svg new file mode 100644 index 00000000000..40c960773e8 --- /dev/null +++ b/studio/public/img/icons/frameworks/sveltekit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/umijs.svg b/studio/public/img/icons/frameworks/umijs.svg new file mode 100644 index 00000000000..283dbcbae5f --- /dev/null +++ b/studio/public/img/icons/frameworks/umijs.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/vite.svg b/studio/public/img/icons/frameworks/vite.svg new file mode 100644 index 00000000000..de4aeddc12b --- /dev/null +++ b/studio/public/img/icons/frameworks/vite.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/studio/public/img/icons/frameworks/vitepress.svg b/studio/public/img/icons/frameworks/vitepress.svg new file mode 100644 index 00000000000..de4aeddc12b --- /dev/null +++ b/studio/public/img/icons/frameworks/vitepress.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/studio/public/img/icons/frameworks/vue.svg b/studio/public/img/icons/frameworks/vue.svg new file mode 100644 index 00000000000..e4111af7183 --- /dev/null +++ b/studio/public/img/icons/frameworks/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/vuepress.svg b/studio/public/img/icons/frameworks/vuepress.svg new file mode 100644 index 00000000000..e4111af7183 --- /dev/null +++ b/studio/public/img/icons/frameworks/vuepress.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/studio/public/img/icons/frameworks/zola.svg b/studio/public/img/icons/frameworks/zola.svg new file mode 100644 index 00000000000..6d7413f01d9 --- /dev/null +++ b/studio/public/img/icons/frameworks/zola.svg @@ -0,0 +1,4 @@ + + + + diff --git a/studio/public/img/integrations/covers/github-cover.png b/studio/public/img/integrations/covers/github-cover.png new file mode 100644 index 00000000000..853f5322db5 Binary files /dev/null and b/studio/public/img/integrations/covers/github-cover.png differ diff --git a/studio/public/img/integrations/covers/vercel-cover.png b/studio/public/img/integrations/covers/vercel-cover.png new file mode 100644 index 00000000000..d2965eefdc6 Binary files /dev/null and b/studio/public/img/integrations/covers/vercel-cover.png differ diff --git a/studio/state/github-connection-config-panel.ts b/studio/state/github-connection-config-panel.ts new file mode 100644 index 00000000000..915c6ec5fe8 --- /dev/null +++ b/studio/state/github-connection-config-panel.ts @@ -0,0 +1,19 @@ +import { proxy, snapshot, useSnapshot } from 'valtio' + +export const githubConnectionConfigPanelState = proxy({ + open: false as boolean, + setOpen: (open: boolean) => { + githubConnectionConfigPanelState.open = open + }, + organizationIntegrationId: '' as string, + setOrganizationIntegrationId: (organizationInterationId: string) => { + githubConnectionConfigPanelState.organizationIntegrationId = organizationInterationId + }, +}) + +export const getGithubConnectionConfigPanelSnapshot = () => + snapshot(githubConnectionConfigPanelState) + +export const useGithubConnectionConfigPanelSnapshot = ( + options?: Parameters[1] +) => useSnapshot(githubConnectionConfigPanelState, options) diff --git a/studio/state/vercel-integration-installation.ts b/studio/state/vercel-integration-installation.ts new file mode 100644 index 00000000000..737879f6fe1 --- /dev/null +++ b/studio/state/vercel-integration-installation.ts @@ -0,0 +1,15 @@ +import { proxy, snapshot, useSnapshot } from 'valtio' + +export const vercelIntegrationInstallationState = proxy({ + selectedOrganizationSlug: '' as string, + setSelectedOrganizationSlug: (selectedOrganizationSlug: string) => { + vercelIntegrationInstallationState.selectedOrganizationSlug = selectedOrganizationSlug + }, +}) + +export const getVercelIntegrationInstallationState = () => + snapshot(vercelIntegrationInstallationState) + +export const useVercelIntegrationInstallationState = ( + options?: Parameters[1] +) => useSnapshot(vercelIntegrationInstallationState, options)