+
+ >
+ )
+}
+
+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..5654041d540
--- /dev/null
+++ b/studio/data/api-authorization/api-authorization-approve-mutation.ts
@@ -0,0 +1,43 @@
+import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
+import { post } from 'lib/common/fetch'
+import { API_ADMIN_URL } from 'lib/constants'
+// import { resourceKeys } from './keys'
+
+export type ApiAuthorizationApproveVariables = {
+ id: string
+}
+
+export async function approveApiAuthorization({ id }: ApiAuthorizationApproveVariables) {
+ if (!id) throw new Error('Authorization ID is required')
+
+ const response = await post(
+ `${API_ADMIN_URL}/oauth/authorization/${id}?skip_browser_redirect=true`,
+ {}
+ )
+ if (response.error) throw response.error
+ return response
+}
+
+type ApiAuthorizationApproveData = Awaited>
+
+export const useApiAuthorizationApproveMutation = ({
+ onSuccess,
+ ...options
+}: Omit<
+ UseMutationOptions,
+ 'mutationFn'
+> = {}) => {
+ const queryClient = useQueryClient()
+
+ return useMutation(
+ (vars) => approveApiAuthorization(vars),
+ {
+ async onSuccess(data, variables, context) {
+ // const { id } = variables
+ // await queryClient.invalidateQueries(networkRestrictionKeys.list(projectRef))
+ // await onSuccess?.(data, variables, context)
+ },
+ ...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..f623f7d2905
--- /dev/null
+++ b/studio/data/api-authorization/api-authorization-decline-mutation.ts
@@ -0,0 +1,40 @@
+import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
+import { delete_ } from 'lib/common/fetch'
+import { API_ADMIN_URL } from 'lib/constants'
+// import { resourceKeys } from './keys'
+
+export type ApiAuthorizationDeclineVariables = {
+ 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/authorization/${id}`, {})
+ if (response.error) throw response.error
+ return response
+}
+
+type ApiAuthorizationDeclineData = Awaited>
+
+export const useApiAuthorizationDeclineMutation = ({
+ onSuccess,
+ ...options
+}: Omit<
+ UseMutationOptions,
+ 'mutationFn'
+> = {}) => {
+ const queryClient = useQueryClient()
+
+ return useMutation(
+ (vars) => declineApiAuthorization(vars),
+ {
+ async onSuccess(data, variables, context) {
+ // const { id } = variables
+ // await queryClient.invalidateQueries(networkRestrictionKeys.list(projectRef))
+ // await onSuccess?.(data, variables, context)
+ },
+ ...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..f687e49c403
--- /dev/null
+++ b/studio/data/api-authorization/api-authorization-query.ts
@@ -0,0 +1,52 @@
+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 = {
+ id: 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/authorization/${id}`, { signal })
+ if (response.error) throw response.error
+ return response as ApiAuthorizationResponse
+}
+
+export type ResourceData = Awaited>
+export type ResourceError = unknown
+
+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..9d4e3aed96f
--- /dev/null
+++ b/studio/pages/authorize.tsx
@@ -0,0 +1,96 @@
+import { useParams } from 'common'
+
+import { withAuth } from 'hooks'
+import { NextPageWithLayout } from 'types'
+import { FormPanel } from 'components/ui/Forms'
+import APIAuthorizationLayout from 'components/layouts/APIAuthorizationLayout'
+import { Alert, Button, Listbox } from 'ui'
+import { useOrganizationsQuery } from 'data/organizations/organizations-query'
+import ShimmeringLoader from 'components/ui/ShimmeringLoader'
+import { useApiAuthorizationQuery } from 'data/api-authorization/api-authorization-query'
+
+const APIAuthorizationPage: NextPageWithLayout = () => {
+ const { auth_id } = useParams()
+ const { data: organizations, isLoading: isLoadingOrganizations } = useOrganizationsQuery()
+ // const { data: apiAuthDetails, isLoading, isSuccess, isError } = useApiAuthorizationQuery({ id: auth_id })
+
+ // [Joshen] To be replaced with actual API call above
+ const apiAuthDetails = {
+ icon: 'https://cdn-icons-png.flaticon.com/512/5969/5969044.png',
+ host: 'cloudflare.com',
+ name: 'Cloudflare',
+ }
+
+ const isExpired = false
+
+ return (
+
+
+
+
+
+
+ }
+ >
+
+ {/* API Authorization requester details */}
+
+
Authorize {apiAuthDetails.name}
+
+
+
+
+
+
+
+ {apiAuthDetails.name} is requesting API access to an organization. The application
+ will be able to{' '}
+
+ read and write the organization's settings and projects
+
+
- {apiAuthDetails.name} is requesting API access to an organization. The application
- will be able to{' '}
-
- read and write the organization's settings and projects
-
-
+
+ {apiAuthDetails.name} is requesting API access to an organization. The application will
+ be able to{' '}
+
+ read and write the organization's settings and projects
+
+
{/* Expiry warning */}
From 63267c1fa9fe41d5c30aeec2ccccc9b624d9cdbc Mon Sep 17 00:00:00 2001
From: Joshen Lim
Date: Tue, 25 Apr 2023 17:26:41 +0800
Subject: [PATCH 3/7] Scaffold error states to be handled
---
studio/pages/authorize.tsx | 30 ++++++++++++++++++++++++++++++
1 file changed, 30 insertions(+)
diff --git a/studio/pages/authorize.tsx b/studio/pages/authorize.tsx
index c41f134f31f..6161eca20ec 100644
--- a/studio/pages/authorize.tsx
+++ b/studio/pages/authorize.tsx
@@ -21,8 +21,38 @@ const APIAuthorizationPage: NextPageWithLayout = () => {
name: 'Cloudflare',
}
+ // [Joshen] To be replaced with actual API call above
+ const isApiAuthDetailsError = false
const isExpired = false
+ if (auth_id === undefined) {
+ return (
+ Authorization for API access}>
+
+
+ Please provide a valid authorization ID in the URL
+
+
+
+ )
+ }
+
+ if (isApiAuthDetailsError) {
+ return (
+ Authorization for API access}>
+
+
+ Please retry your authorization request from the requesting app
+
+
+
+ )
+ }
+
return (
Authorize API access for {apiAuthDetails.name}}
From ac10f883b6c483a7f406dcc8df5cd7bfc4460827 Mon Sep 17 00:00:00 2001
From: Joshen Lim
Date: Fri, 5 May 2023 17:44:31 +0800
Subject: [PATCH 4/7] Hook up endpoints
---
.../api-authorization-approve-mutation.ts | 31 ++--
.../api-authorization-decline-mutation.ts | 22 +--
.../api-authorization-query.ts | 28 ++-
studio/pages/authorize.tsx | 170 ++++++++++++++----
4 files changed, 186 insertions(+), 65 deletions(-)
diff --git a/studio/data/api-authorization/api-authorization-approve-mutation.ts b/studio/data/api-authorization/api-authorization-approve-mutation.ts
index 5654041d540..5ac01111adc 100644
--- a/studio/data/api-authorization/api-authorization-approve-mutation.ts
+++ b/studio/data/api-authorization/api-authorization-approve-mutation.ts
@@ -1,21 +1,29 @@
-import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
+import { useMutation, UseMutationOptions } from '@tanstack/react-query'
import { post } from 'lib/common/fetch'
import { API_ADMIN_URL } from 'lib/constants'
-// import { resourceKeys } from './keys'
export type ApiAuthorizationApproveVariables = {
id: string
+ organization_id: string
}
-export async function approveApiAuthorization({ id }: ApiAuthorizationApproveVariables) {
+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/authorization/${id}?skip_browser_redirect=true`,
- {}
+ `${API_ADMIN_URL}/oauth/authorizations/${id}?skip_browser_redirect=true`,
+ { organization_id }
)
if (response.error) throw response.error
- return response
+ return response as ApiAuthorizationApproveResponse
}
type ApiAuthorizationApproveData = Awaited>
@@ -27,17 +35,8 @@ export const useApiAuthorizationApproveMutation = ({
UseMutationOptions,
'mutationFn'
> = {}) => {
- const queryClient = useQueryClient()
-
return useMutation(
(vars) => approveApiAuthorization(vars),
- {
- async onSuccess(data, variables, context) {
- // const { id } = variables
- // await queryClient.invalidateQueries(networkRestrictionKeys.list(projectRef))
- // await onSuccess?.(data, variables, context)
- },
- ...options,
- }
+ options
)
}
diff --git a/studio/data/api-authorization/api-authorization-decline-mutation.ts b/studio/data/api-authorization/api-authorization-decline-mutation.ts
index f623f7d2905..7825bb01688 100644
--- a/studio/data/api-authorization/api-authorization-decline-mutation.ts
+++ b/studio/data/api-authorization/api-authorization-decline-mutation.ts
@@ -1,18 +1,21 @@
-import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
+import { useMutation, UseMutationOptions } from '@tanstack/react-query'
import { delete_ } from 'lib/common/fetch'
import { API_ADMIN_URL } from 'lib/constants'
-// import { resourceKeys } from './keys'
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/authorization/${id}`, {})
+ const response = await delete_(`${API_ADMIN_URL}/oauth/authorizations/${id}`, {})
if (response.error) throw response.error
- return response
+ return response as ApiAuthorizationDeclineResponse
}
type ApiAuthorizationDeclineData = Awaited>
@@ -24,17 +27,8 @@ export const useApiAuthorizationDeclineMutation = ({
UseMutationOptions,
'mutationFn'
> = {}) => {
- const queryClient = useQueryClient()
-
return useMutation(
(vars) => declineApiAuthorization(vars),
- {
- async onSuccess(data, variables, context) {
- // const { id } = variables
- // await queryClient.invalidateQueries(networkRestrictionKeys.list(projectRef))
- // await onSuccess?.(data, variables, context)
- },
- ...options,
- }
+ options
)
}
diff --git a/studio/data/api-authorization/api-authorization-query.ts b/studio/data/api-authorization/api-authorization-query.ts
index f687e49c403..bf73ad67872 100644
--- a/studio/data/api-authorization/api-authorization-query.ts
+++ b/studio/data/api-authorization/api-authorization-query.ts
@@ -9,7 +9,12 @@ export type ApiAuthorizationVariables = {
}
export type ApiAuthorizationResponse = {
- id: string
+ name: string
+ website: string
+ domain: string
+ expires_at: string
+ approved_at: string | null
+ approved_organization_slug?: string
}
export async function getApiAuthorizationDetails(
@@ -18,8 +23,25 @@ export async function getApiAuthorizationDetails(
) {
if (!id) throw new Error('Authorization ID is required')
- const response = await get(`${API_ADMIN_URL}/oauth/authorization/${id}`, { signal })
- if (response.error) throw response.error
+ const response = await get(`${API_ADMIN_URL}/oauth/authorizations/${id}`, { signal })
+ if (response.error) {
+ // 404 is a valid error in which the auth id is invalid
+ const isInvalid =
+ (response.error as any)?.code === 404 &&
+ (response.error as any)?.message?.includes('OAuth authorization request does not exist')
+
+ if (isInvalid) {
+ return {
+ name: '',
+ website: '',
+ domain: '',
+ expires_at: '',
+ approved_at: null,
+ } as ApiAuthorizationResponse
+ }
+
+ throw response.error
+ }
return response as ApiAuthorizationResponse
}
diff --git a/studio/pages/authorize.tsx b/studio/pages/authorize.tsx
index 6161eca20ec..5ea6e61ae0a 100644
--- a/studio/pages/authorize.tsx
+++ b/studio/pages/authorize.tsx
@@ -1,29 +1,101 @@
import { useParams } from 'common'
-
-import { withAuth } from 'hooks'
-import { NextPageWithLayout } from 'types'
-import { FormPanel } from 'components/ui/Forms'
import APIAuthorizationLayout from 'components/layouts/APIAuthorizationLayout'
-import { Alert, Button, Listbox } from 'ui'
-import { useOrganizationsQuery } from 'data/organizations/organizations-query'
+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 { data: organizations, isLoading: isLoadingOrganizations } = useOrganizationsQuery()
- // const { data: apiAuthDetails, isLoading, isSuccess, isError } = useApiAuthorizationQuery({ id: auth_id })
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [selectedOrg, setSelectedOrg] = useState()
- // [Joshen] To be replaced with actual API call above
- const apiAuthDetails = {
- icon: 'https://cdn-icons-png.flaticon.com/512/5969/5969044.png',
- host: 'cloudflare.com',
- name: 'Cloudflare',
+ const { data: organizations, isLoading: isLoadingOrganizations } = useOrganizationsQuery()
+ const { data: requester, isLoading, isError } = useApiAuthorizationQuery({ id: auth_id })
+ const isApproved = requester?.approved_at !== null
+ const isInvalid = requester?.name.length === 0 && requester.expires_at.length === 0
+ 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)
+ }
}
- // [Joshen] To be replaced with actual API call above
- const isApiAuthDetailsError = false
- const isExpired = 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 })
+ 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 (
@@ -37,9 +109,9 @@ const APIAuthorizationPage: NextPageWithLayout = () => {
)
}
- if (isApiAuthDetailsError) {
+ if (isInvalid || isError) {
return (
- Authorization for API access}>
+ Authorize API access}>
+ className="w-8 h-8 md:w-10 md:h-10 bg-center bg-no-repeat bg-cover flex items-center justify-center"
+ // [Joshen] For when we support icons
+ // style={{ backgroundImage: `url('${requester?.icon}')` }}
+ >
+
{requester?.name[0]}
+
- {apiAuthDetails.name} is requesting API access to an organization. The application will
- be able to{' '}
-
- read and write the organization's settings and projects
+ {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
)}
@@ -104,12 +205,17 @@ const APIAuthorizationPage: NextPageWithLayout = () => {