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 + +
+
+
+
+
+
+ Supabase + Supabase Logo +
+
+
+
+
+ +
+ {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={ +
+
+ + +
+
+ } + > +
+ {/* API Authorization requester details */} + +
+
+
+
+

{requester?.name[0]}

+
+
+
+

+ {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)