mirror of
https://github.com/supabase/supabase.git
synced 2026-06-20 23:06:06 +08:00
Merge pull request #14171 from supabase/feat/api-auth-page
Feat/api auth page
This commit is contained in:
48
studio/components/layouts/APIAuthorizationLayout.tsx
Normal file
48
studio/components/layouts/APIAuthorizationLayout.tsx
Normal 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
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
57
studio/data/api-authorization/api-authorization-query.ts
Normal file
57
studio/data/api-authorization/api-authorization-query.ts
Normal 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])
|
||||
}
|
||||
3
studio/data/api-authorization/keys.ts
Normal file
3
studio/data/api-authorization/keys.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const resourceKeys = {
|
||||
resource: (id: string | undefined) => ['api-authorization', id] as const,
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
37
studio/data/organizations/organizations-query.ts
Normal file
37
studio/data/organizations/organizations-query.ts
Normal 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
232
studio/pages/authorize.tsx
Normal 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)
|
||||
Reference in New Issue
Block a user