diff --git a/studio/components/layouts/APIAuthorizationLayout.tsx b/studio/components/layouts/APIAuthorizationLayout.tsx
new file mode 100644
index 00000000000..143dd4a6526
--- /dev/null
+++ b/studio/components/layouts/APIAuthorizationLayout.tsx
@@ -0,0 +1,48 @@
+import Head from 'next/head'
+import Image from 'next/image'
+import { useTheme } from 'common'
+import { PropsWithChildren } from 'react'
+import { BASE_PATH } from 'lib/constants'
+import Divider from 'components/ui/Divider'
+
+export interface APIAuthorizationLayoutProps {}
+
+const APIAuthorizationLayout = ({ children }: PropsWithChildren) => {
+ const { isDarkMode } = useTheme()
+ return (
+ <>
+
+ Authorize API access | Supabase
+
+
+
+
+
+ {children}
+
+
+ >
+ )
+}
+
+export default APIAuthorizationLayout
diff --git a/studio/data/api-authorization/api-authorization-approve-mutation.ts b/studio/data/api-authorization/api-authorization-approve-mutation.ts
new file mode 100644
index 00000000000..5ac01111adc
--- /dev/null
+++ b/studio/data/api-authorization/api-authorization-approve-mutation.ts
@@ -0,0 +1,42 @@
+import { useMutation, UseMutationOptions } from '@tanstack/react-query'
+import { post } from 'lib/common/fetch'
+import { API_ADMIN_URL } from 'lib/constants'
+
+export type ApiAuthorizationApproveVariables = {
+ id: string
+ organization_id: string
+}
+
+export type ApiAuthorizationApproveResponse = {
+ url: string
+}
+
+export async function approveApiAuthorization({
+ id,
+ organization_id,
+}: ApiAuthorizationApproveVariables) {
+ if (!id) throw new Error('Authorization ID is required')
+ if (!organization_id) throw new Error('Organization slug is required')
+
+ const response = await post(
+ `${API_ADMIN_URL}/oauth/authorizations/${id}?skip_browser_redirect=true`,
+ { organization_id }
+ )
+ if (response.error) throw response.error
+ return response as ApiAuthorizationApproveResponse
+}
+
+type ApiAuthorizationApproveData = Awaited>
+
+export const useApiAuthorizationApproveMutation = ({
+ onSuccess,
+ ...options
+}: Omit<
+ UseMutationOptions,
+ 'mutationFn'
+> = {}) => {
+ return useMutation(
+ (vars) => approveApiAuthorization(vars),
+ options
+ )
+}
diff --git a/studio/data/api-authorization/api-authorization-decline-mutation.ts b/studio/data/api-authorization/api-authorization-decline-mutation.ts
new file mode 100644
index 00000000000..7825bb01688
--- /dev/null
+++ b/studio/data/api-authorization/api-authorization-decline-mutation.ts
@@ -0,0 +1,34 @@
+import { useMutation, UseMutationOptions } from '@tanstack/react-query'
+import { delete_ } from 'lib/common/fetch'
+import { API_ADMIN_URL } from 'lib/constants'
+
+export type ApiAuthorizationDeclineVariables = {
+ id: string
+}
+
+export type ApiAuthorizationDeclineResponse = {
+ id: string
+}
+
+export async function declineApiAuthorization({ id }: ApiAuthorizationDeclineVariables) {
+ if (!id) throw new Error('Authorization ID is required')
+
+ const response = await delete_(`${API_ADMIN_URL}/oauth/authorizations/${id}`, {})
+ if (response.error) throw response.error
+ return response as ApiAuthorizationDeclineResponse
+}
+
+type ApiAuthorizationDeclineData = Awaited>
+
+export const useApiAuthorizationDeclineMutation = ({
+ onSuccess,
+ ...options
+}: Omit<
+ UseMutationOptions,
+ 'mutationFn'
+> = {}) => {
+ return useMutation(
+ (vars) => declineApiAuthorization(vars),
+ options
+ )
+}
diff --git a/studio/data/api-authorization/api-authorization-query.ts b/studio/data/api-authorization/api-authorization-query.ts
new file mode 100644
index 00000000000..652c19c4d5f
--- /dev/null
+++ b/studio/data/api-authorization/api-authorization-query.ts
@@ -0,0 +1,57 @@
+import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query'
+import { get } from 'lib/common/fetch'
+import { API_ADMIN_URL } from 'lib/constants'
+import { useCallback } from 'react'
+import { resourceKeys } from './keys'
+
+export type ApiAuthorizationVariables = {
+ id?: string
+}
+
+export type ApiAuthorizationResponse = {
+ name: string
+ website: string
+ domain: string
+ expires_at: string
+ approved_at: string | null
+ approved_organization_slug?: string
+}
+
+export async function getApiAuthorizationDetails(
+ { id }: ApiAuthorizationVariables,
+ signal?: AbortSignal
+) {
+ if (!id) throw new Error('Authorization ID is required')
+
+ const response = await get(`${API_ADMIN_URL}/oauth/authorizations/${id}`, { signal })
+ if (response.error) throw response.error
+ return response as ApiAuthorizationResponse
+}
+
+export type ResourceData = Awaited>
+export type ResourceError = { errorEventId: string; message: string }
+
+export const useApiAuthorizationQuery = (
+ { id }: ApiAuthorizationVariables,
+ { enabled = true, ...options }: UseQueryOptions = {}
+) =>
+ useQuery(
+ resourceKeys.resource(id),
+ ({ signal }) => getApiAuthorizationDetails({ id }, signal),
+ {
+ enabled: enabled && typeof id !== 'undefined',
+ ...options,
+ }
+ )
+
+export const useApiAuthorizationPrefetch = ({ id }: ApiAuthorizationVariables) => {
+ const client = useQueryClient()
+
+ return useCallback(() => {
+ if (id) {
+ client.prefetchQuery(resourceKeys.resource(id), ({ signal }) =>
+ getApiAuthorizationDetails({ id }, signal)
+ )
+ }
+ }, [id])
+}
diff --git a/studio/data/api-authorization/keys.ts b/studio/data/api-authorization/keys.ts
new file mode 100644
index 00000000000..69660c24f42
--- /dev/null
+++ b/studio/data/api-authorization/keys.ts
@@ -0,0 +1,3 @@
+export const resourceKeys = {
+ resource: (id: string | undefined) => ['api-authorization', id] as const,
+}
diff --git a/studio/data/organizations/keys.ts b/studio/data/organizations/keys.ts
index 5b7250c028d..4aabe753ab3 100644
--- a/studio/data/organizations/keys.ts
+++ b/studio/data/organizations/keys.ts
@@ -1,4 +1,5 @@
export const organizationKeys = {
+ list: () => ['organizations'] as const,
detail: (slug: string | undefined) => ['organizations', slug, 'detail'] as const,
roles: (slug: string | undefined) => ['organizations', slug, 'roles'] as const,
freeProjectLimitCheck: (slug: string | undefined) =>
diff --git a/studio/data/organizations/organizations-query.ts b/studio/data/organizations/organizations-query.ts
new file mode 100644
index 00000000000..598c8b17df4
--- /dev/null
+++ b/studio/data/organizations/organizations-query.ts
@@ -0,0 +1,37 @@
+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 { Organization } from 'types'
+import { organizationKeys } from './keys'
+
+export type OrganizationsResponse = Organization[]
+
+export async function getOrganizations(signal?: AbortSignal) {
+ const data = await get(`${API_URL}/organizations`, { signal })
+ if (data.error) throw data.error
+
+ return data as OrganizationsResponse
+}
+
+export type OrganizationsData = Awaited>
+export type OrganizationsError = unknown
+
+export const useOrganizationsQuery = ({
+ enabled = true,
+ ...options
+}: UseQueryOptions = {}) =>
+ useQuery(
+ organizationKeys.list(),
+ ({ signal }) => getOrganizations(signal),
+ { enabled: enabled, ...options }
+ )
+
+export const useOrganizationsPrefetch = () => {
+ const client = useQueryClient()
+
+ return useCallback(
+ () => client.prefetchQuery(organizationKeys.list(), ({ signal }) => getOrganizations(signal)),
+ []
+ )
+}
diff --git a/studio/pages/authorize.tsx b/studio/pages/authorize.tsx
new file mode 100644
index 00000000000..86559bebe6e
--- /dev/null
+++ b/studio/pages/authorize.tsx
@@ -0,0 +1,232 @@
+import { useParams } from 'common'
+import APIAuthorizationLayout from 'components/layouts/APIAuthorizationLayout'
+import { FormPanel } from 'components/ui/Forms'
+import ShimmeringLoader from 'components/ui/ShimmeringLoader'
+import { useApiAuthorizationApproveMutation } from 'data/api-authorization/api-authorization-approve-mutation'
+import { useApiAuthorizationDeclineMutation } from 'data/api-authorization/api-authorization-decline-mutation'
+import { useApiAuthorizationQuery } from 'data/api-authorization/api-authorization-query'
+import { useOrganizationsQuery } from 'data/organizations/organizations-query'
+import dayjs from 'dayjs'
+import { useStore, withAuth } from 'hooks'
+import { useRouter } from 'next/router'
+import { useEffect, useState } from 'react'
+import { NextPageWithLayout } from 'types'
+import { Alert, Button, Listbox } from 'ui'
+
+// Need to handle if no organizations in account
+// Need to handle if not logged in yet state
+
+const APIAuthorizationPage: NextPageWithLayout = () => {
+ const { ui } = useStore()
+ const router = useRouter()
+ const { auth_id } = useParams()
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [selectedOrg, setSelectedOrg] = useState()
+
+ const { data: organizations, isLoading: isLoadingOrganizations } = useOrganizationsQuery()
+ const { data: requester, isLoading, isError, error } = useApiAuthorizationQuery({ id: auth_id })
+ const isApproved = requester?.approved_at !== null
+ const isExpired = dayjs().isAfter(dayjs(requester?.expires_at))
+
+ const { mutateAsync: approveRequest } = useApiAuthorizationApproveMutation()
+ const { mutateAsync: declineRequest } = useApiAuthorizationDeclineMutation()
+
+ useEffect(() => {
+ if (!isLoadingOrganizations) {
+ setSelectedOrg(organizations?.[0].slug ?? undefined)
+ }
+ }, [isLoadingOrganizations])
+
+ const onApproveRequest = async () => {
+ if (!auth_id) {
+ return ui.setNotification({
+ category: 'error',
+ message: 'Unable to approve request: auth_id is missing ',
+ })
+ }
+ if (!selectedOrg) {
+ return ui.setNotification({
+ category: 'error',
+ message: 'Unable to approve request: No organization selected',
+ })
+ }
+
+ try {
+ setIsSubmitting(true)
+ const res = await approveRequest({ id: auth_id, organization_id: selectedOrg })
+ router.push(res.url)
+ } catch (error: any) {
+ ui.setNotification({
+ category: 'error',
+ message: `Failed to approve request: ${error.message}`,
+ })
+ setIsSubmitting(false)
+ }
+ }
+
+ const onDeclineRequest = async () => {
+ if (!auth_id)
+ return ui.setNotification({
+ category: 'error',
+ message: 'Unable to decline request: auth_id is missing ',
+ })
+
+ try {
+ setIsSubmitting(true)
+ await declineRequest({ id: auth_id })
+ ui.setNotification({ category: 'success', message: 'Declined API authorization request' })
+ router.push('/projects')
+ } catch (error: any) {
+ ui.setNotification({
+ category: 'error',
+ message: `Failed to decline request: ${error.message}`,
+ })
+ setIsSubmitting(false)
+ }
+ }
+
+ if (isLoading) {
+ return (
+ Authorize API access
}>
+
+
+
+
+
+
+ )
+ }
+
+ if (auth_id === undefined) {
+ return (
+ Authorization for API access}>
+
+
+ Please provide a valid authorization ID in the URL
+
+
+
+ )
+ }
+
+ if (isError) {
+ return (
+ Authorize API access}>
+
+
+ Please retry your authorization request from the requesting app
+ {error !== undefined && Error: {error?.message}
}
+
+
+
+ )
+ }
+
+ if (isApproved) {
+ const approvedOrganization = organizations?.find(
+ (org) => org.slug === requester.approved_organization_slug
+ )
+
+ return (
+ Authorize API access for {requester?.name}}>
+
+
+
+ {requester.name} has read and write access to the organization "
+ {approvedOrganization?.name ?? 'Unknown'}" and all of its projects
+
+
+ Approved on: {dayjs(requester.approved_at).format('DD MMM YYYY HH:mm:ss (ZZ)')}
+
+
+
+
+ )
+ }
+
+ return (
+ Authorize API access for {requester?.name}}
+ footer={
+
+
+
+ Decline
+
+
+ Authorize {requester?.name}
+
+
+
+ }
+ >
+
+ {/* API Authorization requester details */}
+
+
+
+
+ {requester?.name} ({requester?.domain}) is requesting API access to an organization. The
+ application will be able to{' '}
+
+ read and write the organization's settings and all of its projects.
+
+
+
+
+ {/* Expiry warning */}
+ {isExpired && (
+
+ Please retry your authorization request from the requesting app
+
+ )}
+
+ {/* Organization selection */}
+ {isLoadingOrganizations ? (
+
+
+
+
+ ) : (
+
+ {(organizations ?? []).map((organization) => (
+
+ {organization.name}
+
+ ))}
+
+ )}
+
+
+ )
+}
+
+APIAuthorizationPage.getLayout = (page) => {page}
+export default withAuth(APIAuthorizationPage)