Merge pull request #14171 from supabase/feat/api-auth-page

Feat/api auth page
This commit is contained in:
Joshen Lim
2023-05-08 22:04:11 +08:00
committed by GitHub
8 changed files with 454 additions and 0 deletions

View File

@@ -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<APIAuthorizationLayoutProps>) => {
const { isDarkMode } = useTheme()
return (
<>
<Head>
<title>Authorize API access | Supabase</title>
</Head>
<main className="flex-grow flex flex-col w-full h-full overflow-y-auto">
<div>
<div className="mx-auto px-4 sm:px-6">
<div className="max-w-xl flex justify-between items-center mx-auto py-4">
<div className="flex justify-start lg:w-0 lg:flex-1">
<div>
<span className="sr-only">Supabase</span>
<Image
src={
isDarkMode
? `${BASE_PATH}/img/supabase-dark.svg`
: `${BASE_PATH}/img/supabase-light.svg`
}
alt="Supabase Logo"
height={20}
width={105}
/>
</div>
</div>
</div>
</div>
</div>
<Divider light />
<div className="flex flex-col justify-center flex-grow mx-auto max-w-[90vw] md:max-w-xl h-full space-y-4">
{children}
</div>
</main>
</>
)
}
export default APIAuthorizationLayout

View File

@@ -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<ReturnType<typeof approveApiAuthorization>>
export const useApiAuthorizationApproveMutation = ({
onSuccess,
...options
}: Omit<
UseMutationOptions<ApiAuthorizationApproveData, unknown, ApiAuthorizationApproveVariables>,
'mutationFn'
> = {}) => {
return useMutation<ApiAuthorizationApproveData, unknown, ApiAuthorizationApproveVariables>(
(vars) => approveApiAuthorization(vars),
options
)
}

View File

@@ -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<ReturnType<typeof declineApiAuthorization>>
export const useApiAuthorizationDeclineMutation = ({
onSuccess,
...options
}: Omit<
UseMutationOptions<ApiAuthorizationDeclineData, unknown, ApiAuthorizationDeclineVariables>,
'mutationFn'
> = {}) => {
return useMutation<ApiAuthorizationDeclineData, unknown, ApiAuthorizationDeclineVariables>(
(vars) => declineApiAuthorization(vars),
options
)
}

View File

@@ -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<ReturnType<typeof getApiAuthorizationDetails>>
export type ResourceError = { errorEventId: string; message: string }
export const useApiAuthorizationQuery = <TData = ResourceData>(
{ id }: ApiAuthorizationVariables,
{ enabled = true, ...options }: UseQueryOptions<ResourceData, ResourceError, TData> = {}
) =>
useQuery<ResourceData, ResourceError, TData>(
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])
}

View File

@@ -0,0 +1,3 @@
export const resourceKeys = {
resource: (id: string | undefined) => ['api-authorization', id] as const,
}

View File

@@ -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) =>

View File

@@ -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<ReturnType<typeof getOrganizations>>
export type OrganizationsError = unknown
export const useOrganizationsQuery = <TData = OrganizationsData>({
enabled = true,
...options
}: UseQueryOptions<OrganizationsData, OrganizationsError, TData> = {}) =>
useQuery<OrganizationsData, OrganizationsError, TData>(
organizationKeys.list(),
({ signal }) => getOrganizations(signal),
{ enabled: enabled, ...options }
)
export const useOrganizationsPrefetch = () => {
const client = useQueryClient()
return useCallback(
() => client.prefetchQuery(organizationKeys.list(), ({ signal }) => getOrganizations(signal)),
[]
)
}

232
studio/pages/authorize.tsx Normal file
View File

@@ -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<string>()
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 (
<FormPanel header={<p>Authorize API access</p>}>
<div className="w-[500px] px-8 py-6 space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
</FormPanel>
)
}
if (auth_id === undefined) {
return (
<FormPanel header={<p>Authorization for API access</p>}>
<div className="w-[500px] px-8 py-6">
<Alert withIcon variant="warning" title="Missing authorization ID">
Please provide a valid authorization ID in the URL
</Alert>
</div>
</FormPanel>
)
}
if (isError) {
return (
<FormPanel header={<p>Authorize API access</p>}>
<div className="w-[500px] px-8 py-6">
<Alert
withIcon
variant="warning"
title="Failed to fetch details for API authorization request"
>
<p>Please retry your authorization request from the requesting app</p>
{error !== undefined && <p className="mt-2">Error: {error?.message}</p>}
</Alert>
</div>
</FormPanel>
)
}
if (isApproved) {
const approvedOrganization = organizations?.find(
(org) => org.slug === requester.approved_organization_slug
)
return (
<FormPanel header={<p>Authorize API access for {requester?.name}</p>}>
<div className="w-full md:w-[500px] px-8 py-6 space-y-8">
<Alert withIcon variant="success" title="This authorization request has been approved">
<p>
{requester.name} has read and write access to the organization "
{approvedOrganization?.name ?? 'Unknown'}" and all of its projects
</p>
<p className="mt-2">
Approved on: {dayjs(requester.approved_at).format('DD MMM YYYY HH:mm:ss (ZZ)')}
</p>
</Alert>
</div>
</FormPanel>
)
}
return (
<FormPanel
header={<p>Authorize API access for {requester?.name}</p>}
footer={
<div className="flex items-center justify-end py-4 px-8">
<div className="flex items-center space-x-2">
<Button type="default" disabled={isSubmitting || isExpired} onClick={onDeclineRequest}>
Decline
</Button>
<Button
loading={isSubmitting}
disabled={isSubmitting || isExpired}
onClick={onApproveRequest}
>
Authorize {requester?.name}
</Button>
</div>
</div>
}
>
<div className="w-full md:w-[500px] px-8 py-6 space-y-8">
{/* API Authorization requester details */}
<div className="flex space-x-4">
<div>
<div className="rounded-md border border-scale-600 p-2.5 flex items-center">
<div
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}')` }}
>
<p className="text-scale-1000 text-lg">{requester?.name[0]}</p>
</div>
</div>
</div>
<p className="text-sm text-scale-1100">
{requester?.name} ({requester?.domain}) is requesting API access to an organization. The
application will be able to{' '}
<span className="text-amber-1200">
read and write the organization's settings and all of its projects.
</span>
</p>
</div>
{/* Expiry warning */}
{isExpired && (
<Alert withIcon variant="warning" title="This authorization request is expired">
Please retry your authorization request from the requesting app
</Alert>
)}
{/* Organization selection */}
{isLoadingOrganizations ? (
<div className="py-4 space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
</div>
) : (
<Listbox
label="Select an organization to grant API access to"
value={selectedOrg}
disabled={isExpired}
onChange={setSelectedOrg}
>
{(organizations ?? []).map((organization) => (
<Listbox.Option
key={organization.id}
label={organization.name}
value={organization.slug}
>
{organization.name}
</Listbox.Option>
))}
</Listbox>
)}
</div>
</FormPanel>
)
}
APIAuthorizationPage.getLayout = (page) => <APIAuthorizationLayout>{page}</APIAuthorizationLayout>
export default withAuth(APIAuthorizationPage)