feat: OAuth 2.1 - OAuth apps (#39165)

* oAuth clients index layout

* oAuth apps crud

* is public

* add user count and client secret generation and management

* scaffold oauth server settings

* improve oauth server enablement / disablement

* show cover when oAuth server is disabled

* fix update panel update button

* add site url and authorization path settings values

* move oauth server to it's own nav item

* remove unneeded oauth server settings

* let the user disactivate oauth server even after creating oauth apps

* better delete button

* cleanup

* fix typecheck

* test endpoints

* add EnableOAuth21 feature flag

* update OAUTH_SERVER_ auth config api

* load OAUTH_SERVER_ENABLED in oauth list

* Update the api.d.ts. Remove the custom versions of supa libs.

* Add query for getTemporaryAPIKey.

* Add a hook for initializing a supabase client.

* Add hooks for oAuth Server apps.

* Regenerate pnpm-lock.yaml.

* Revert updates to the platform.d.ts. Not needed for this PR.

* Migrate all code to use the new hooks.

* Try to integrate the mutations and fix some of the sheet and dialogs.

* improve default and saving states

* fix oauth app form validation

* unify components into CreateOrUpdateOAuthAppModal

* create or update oauth app

* Update the OAuth Server page.

* Remove extra files.

* Minor various fixes.

* More fixes to the creation of oauth apps.

* Bump the libs to fix a DELETE oauth app error.

* Clean up the scope feature.

* Move the feature flag in the auth layout.

* Bunch of smaller fixes.

* Regenerate pnpm-lock.

* Revert SidePanel and CardDescription changes.

* Add confirm dialog for regenerating secret.

---------

Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
This commit is contained in:
Francesco SansalvadoreandIvan Vasilov authored and GitHub committed 2025-10-30 17:07:07 +00:00
1 parent cac225b24a
commit bc22be3855
17 files changed
+1557 -2

No files matched your search

@@ -0,0 +1,306 @@
import { zodResolver } from '@hookform/resolvers/zod'
import type { CreateOAuthClientParams, OAuthClient } from '@supabase/supabase-js'
import { Plus, Trash2, X } from 'lucide-react'
import Link from 'next/link'
import { useEffect } from 'react'
import { useFieldArray, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import * as z from 'zod'
import { useParams } from 'common'
import { useOAuthServerAppCreateMutation } from 'data/oauth-server-apps/oauth-server-app-create-mutation'
import { useSupabaseClientQuery } from 'hooks/use-supabase-client-query'
import {
Button,
FormControl_Shadcn_,
FormDescription_Shadcn_,
FormField_Shadcn_,
FormItem_Shadcn_,
FormLabel_Shadcn_,
FormMessage_Shadcn_,
Form_Shadcn_,
Input_Shadcn_,
Separator,
Sheet,
SheetClose,
SheetContent,
SheetFooter,
SheetHeader,
SheetSection,
SheetTitle,
Switch,
cn,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
interface CreateOAuthAppSheetProps {
visible: boolean
onSuccess: (app: OAuthClient) => void
onCancel: () => void
}
const FormSchema = z.object({
name: z
.string()
.min(1, 'Please provide a name for your OAuth app')
.max(100, 'Name must be less than 100 characters'),
type: z.enum(['manual', 'dynamic']).default('manual'),
// scope: z.string().min(1, 'Please select a scope'),
redirect_uris: z
.object({
value: z.string().trim().url('Please provide a valid URL'),
})
.array()
.min(1, 'At least one redirect URI is required'),
is_public: z.boolean().default(false),
})
const FORM_ID = 'create-or-update-oauth-app-form'
const initialValues = {
name: '',
type: 'manual' as const,
// scope: 'email',
redirect_uris: [{ value: '' }],
is_public: false,
}
export const CreateOAuthAppSheet = ({ visible, onSuccess, onCancel }: CreateOAuthAppSheetProps) => {
const { ref: projectRef } = useParams()
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: initialValues,
})
const {
fields: redirectUriFields,
append: appendRedirectUri,
remove: removeRedirectUri,
} = useFieldArray({
name: 'redirect_uris',
control: form.control,
})
const { data: supabaseClientData } = useSupabaseClientQuery({ projectRef })
const { mutateAsync: createOAuthApp, isLoading: isCreating } = useOAuthServerAppCreateMutation({
onSuccess: (data) => {
toast.success(`Successfully created OAuth app "${data.client_name}"`)
onSuccess(data)
},
onError: (error) => {
toast.error(error.message)
},
})
useEffect(() => {
if (visible) {
form.reset(initialValues)
}
}, [visible])
const onSubmit = async (data: z.infer<typeof FormSchema>) => {
// Filter out empty redirect URIs
const validRedirectUris = data.redirect_uris
.map((uri) => uri.value.trim())
.filter((uri) => uri !== '')
const payload: CreateOAuthClientParams = {
client_name: data.name,
client_uri: '',
// scope: data.scope,
redirect_uris: validRedirectUris,
}
createOAuthApp({
projectRef,
supabaseClient: supabaseClientData?.supabaseClient,
...payload,
})
}
const onClose = () => {
form.reset(initialValues)
onCancel()
}
return (
<>
<Sheet open={visible} onOpenChange={() => onCancel()}>
<SheetContent
size="default"
showClose={false}
className="flex flex-col gap-0"
tabIndex={undefined}
>
<SheetHeader>
<div className="flex flex-row gap-3 items-center">
<SheetClose
className={cn(
'text-muted hover:text ring-offset-background transition-opacity hover:opacity-100',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'disabled:pointer-events-none data-[state=open]:bg-secondary',
'transition'
)}
>
<X className="h-3 w-3" />
<span className="sr-only">Close</span>
</SheetClose>
<SheetTitle className="truncate">Create a new OAuth app</SheetTitle>
</div>
</SheetHeader>
<SheetSection className="overflow-auto flex-grow px-0">
<Form_Shadcn_ {...form}>
<form className="space-y-6" onSubmit={form.handleSubmit(onSubmit)} id={FORM_ID}>
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout label="Name" className={'px-5'}>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} placeholder="My OAuth App" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
{/* <FormField_Shadcn_
control={form.control}
name="scope"
rules={{ required: true }}
render={({ field }) => (
<FormItemLayout
label="Scope"
layout="vertical"
description={
<>
Select the permissions your app will request from users.{' '}
<Link
href="https://supabase.com/docs/guides/auth/oauth/oauth-apps#scope"
target="_blank"
rel="noreferrer"
className="text-foreground-light underline hover:text-foreground transition"
>
Learn more
</Link>
</>
}
className={'px-5'}
>
<FormControl_Shadcn_>
<Select_Shadcn_ value={field.value} onValueChange={field.onChange}>
<SelectTrigger_Shadcn_ className="w-full">
<SelectValue_Shadcn_ placeholder="Select scope..." />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{OAUTH_APP_SCOPE_OPTIONS.map((scope) => (
<SelectItem_Shadcn_ key={scope.value} value={scope.value}>
{scope.name}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/> */}
<div className="px-5 gap-2 flex flex-col">
<FormLabel_Shadcn_ className="text-foreground">Redirect URIs</FormLabel_Shadcn_>
<div className="space-y-2">
{redirectUriFields.map((fieldItem, index) => (
<FormField_Shadcn_
control={form.control}
key={fieldItem.id}
name={`redirect_uris.${index}.value`}
render={({ field: inputField }) => (
<FormItem_Shadcn_>
<div className="flex flex-row gap-2">
<FormControl_Shadcn_>
<Input_Shadcn_
{...inputField}
placeholder={'https://example.com/callback'}
onChange={(e) => {
inputField.onChange(e)
}}
/>
</FormControl_Shadcn_>
{redirectUriFields.length > 1 && (
<Button
type="default"
size="tiny"
className="h-[34px]"
icon={<Trash2 size={14} />}
onClick={() => removeRedirectUri(index)}
/>
)}
</div>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
))}
</div>
<div>
<Button
type="default"
icon={<Plus strokeWidth={1.5} />}
onClick={() => appendRedirectUri({ value: '' })}
>
Add redirect URI
</Button>
</div>
<FormDescription_Shadcn_ className="text-foreground-lighter">
URLs where users will be redirected after authentication.
</FormDescription_Shadcn_>
</div>
<Separator />
<FormField_Shadcn_
control={form.control}
name="is_public"
render={({ field }) => (
<FormItemLayout
label="Public"
layout="flex"
description={
<>
If enabled, the Authorization Code with PKCE (Proof Key for Code Exchange)
flow can be used, particularly beneficial for applications that cannot
securely store Client Secrets, such as native and mobile apps.{' '}
<Link
href="https://supabase.com/docs/guides/auth/oauth/public-oauth-apps"
target="_blank"
rel="noreferrer"
className="text-foreground-light underline hover:text-foreground transition"
>
Learn more
</Link>
</>
}
className={'px-5'}
>
<FormControl_Shadcn_>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</form>
</Form_Shadcn_>
</SheetSection>
<SheetFooter>
<Button type="default" disabled={isCreating} onClick={onClose}>
Cancel
</Button>
<Button htmlType="submit" form={FORM_ID} loading={isCreating}>
Create app
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</>
)
}
@@ -0,0 +1,77 @@
import type { OAuthClient } from '@supabase/supabase-js'
import { useParams } from 'common'
import { useOAuthServerAppDeleteMutation } from 'data/oauth-server-apps/oauth-server-app-delete-mutation'
import { useSupabaseClientQuery } from 'hooks/use-supabase-client-query'
import { useState } from 'react'
import { toast } from 'sonner'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
interface DeleteOAuthAppModalProps {
visible: boolean
selectedApp?: OAuthClient
onClose: () => void
}
export const DeleteOAuthAppModal = ({
visible,
selectedApp,
onClose,
}: DeleteOAuthAppModalProps) => {
const { ref: projectRef } = useParams()
const [isDeleting, setIsDeleting] = useState(false)
const { data: supabaseClientData } = useSupabaseClientQuery({ projectRef })
const { mutateAsync: deleteOAuthApp } = useOAuthServerAppDeleteMutation()
const onConfirmDeleteApp = async () => {
if (!selectedApp) return console.error('No OAuth app selected')
setIsDeleting(true)
try {
await deleteOAuthApp({
projectRef,
supabaseClient: supabaseClientData?.supabaseClient,
clientId: selectedApp.client_id,
})
toast.success(`Successfully deleted OAuth app "${selectedApp.client_name}"`)
onClose()
} catch (error) {
toast.error('Failed to delete OAuth app')
console.error('Error deleting OAuth app:', error)
} finally {
setIsDeleting(false)
}
}
return (
<ConfirmationModal
variant={'destructive'}
size="medium"
loading={isDeleting}
visible={visible}
title={
<>
Confirm to delete OAuth app <code className="text-sm">{selectedApp?.client_name}</code>
</>
}
confirmLabel="Confirm delete"
confirmLabelLoading="Deleting..."
onCancel={onClose}
onConfirm={() => onConfirmDeleteApp()}
alert={{
title: 'This action cannot be undone',
description: 'You will need to re-create the OAuth app if you want to revert the deletion.',
}}
>
<p className="text-sm">Before deleting this OAuth app, consider:</p>
<ul className="space-y-2 mt-2 text-sm text-foreground-light">
<li className="list-disc ml-6">Any applications using this OAuth app will lose access</li>
<li className="list-disc ml-6">This OAuth app is no longer in use by any applications</li>
</ul>
</ConfirmationModal>
)
}
@@ -0,0 +1,59 @@
import type { OAuthClient } from '@supabase/supabase-js'
import { X } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { Input } from 'ui-patterns/DataInputs/Input'
interface NewOAuthAppBannerProps {
oauthApp: OAuthClient
onClose: () => void
}
export const NewOAuthAppBanner = ({ oauthApp, onClose }: NewOAuthAppBannerProps) => {
return (
<Admonition
type="default"
className="relative mb-6"
title={`Successfully generated credentials for your ${oauthApp.client_name} OAuth app!`}
description={
<div className="w-full space-y-2">
<p className="text-sm">
Do copy this client id and client secret and store it in a secure place - you will not
be able to see it again.
</p>
<div className="max-w-xl">
<Input
copy
readOnly
size="small"
className="max-w-xl input-mono"
value={oauthApp?.client_id}
onChange={() => {}}
onCopy={() => toast.success('Client Id copied to clipboard')}
/>
</div>
<div className="max-w-xl">
<Input
copy
readOnly
size="small"
className="max-w-xl input-mono"
value={oauthApp?.client_secret}
onChange={() => {}}
onCopy={() => toast.success('Client secret copied to clipboard')}
/>
</div>
</div>
}
>
<Button
type="text"
icon={<X />}
className="w-7 h-7 absolute top-3 right-3"
onClick={onClose}
/>
</Admonition>
)
}
@@ -0,0 +1,301 @@
import type { OAuthClient } from '@supabase/supabase-js'
import { MoreVertical, Plus, RotateCw, Search, Trash } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { useParams } from 'common'
import AlertError from 'components/ui/AlertError'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { FilterPopover } from 'components/ui/FilterPopover'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
import { useOAuthServerAppRegenerateSecretMutation } from 'data/oauth-server-apps/oauth-server-app-regenerate-secret-mutation'
import { useOAuthServerAppsQuery } from 'data/oauth-server-apps/oauth-server-apps-query'
import { useSupabaseClientQuery } from 'hooks/use-supabase-client-query'
import {
Badge,
Button,
Card,
CardContent,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Input,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
import { CreateOAuthAppSheet } from './CreateOAuthAppSheet'
import { DeleteOAuthAppModal } from './DeleteOAuthAppModal'
import { NewOAuthAppBanner } from './NewOAuthAppBanner'
export const OAUTH_APP_SCOPE_OPTIONS = [
{ name: 'email', value: 'email' },
{ name: 'profile', value: 'profile' },
{ name: 'openid', value: 'openid' },
]
export const OAUTH_APP_TYPE_OPTIONS = [
{ name: 'Manual', value: 'manual' },
{ name: 'Dynamic', value: 'dynamic' },
]
export const OAuthAppsList = () => {
const { ref: projectRef } = useParams()
const { data: authConfig, isLoading: isAuthConfigLoading } = useAuthConfigQuery({ projectRef })
const isOAuthServerEnabled = !!authConfig?.OAUTH_SERVER_ENABLED
const [newOAuthApp, setNewOAuthApp] = useState<OAuthClient | undefined>(undefined)
// State for OAuth apps
const [showCreateSheet, setShowCreateSheet] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [showRegenerateDialog, setShowRegenerateDialog] = useState(false)
const [selectedApp, setSelectedApp] = useState<OAuthClient>()
const [filteredAppTypes, setFilteredAppTypes] = useState<string[]>([])
const [filteredAppScopes, setFilteredAppScopes] = useState<string[]>([])
const { data: supabaseClientData } = useSupabaseClientQuery({ projectRef })
const { data, isLoading, isError, error } = useOAuthServerAppsQuery(
{
projectRef,
supabaseClient: supabaseClientData?.supabaseClient,
},
{ enabled: isOAuthServerEnabled }
)
const { mutateAsync: regenerateSecret, isLoading: isRegenerating } =
useOAuthServerAppRegenerateSecretMutation({
onSuccess: (data) => {
if (data) {
setNewOAuthApp(data)
}
},
})
const oAuthApps = data?.clients || []
const handleDeleteClick = (app: OAuthClient) => {
setSelectedApp(app)
setShowDeleteModal(true)
}
const [filterString, setFilterString] = useState<string>('')
if (isAuthConfigLoading || (isOAuthServerEnabled && isLoading)) {
return <GenericSkeletonLoader />
}
if (isError) {
return <AlertError error={error} subject="Failed to retrieve OAuth Server apps" />
}
return (
<>
<div className="space-y-4">
{newOAuthApp && (
<NewOAuthAppBanner oauthApp={newOAuthApp} onClose={() => setNewOAuthApp(undefined)} />
)}
{!isOAuthServerEnabled && (
<div className="space-y-4">
<Card>
<CardContent className="flex flex-col md:flex-row gap-4 justify-between md:items-center">
<div className="flex flex-col gap-2-4">
<h3 className="">OAuth Server is disabled</h3>
<p className="text-foreground-light text-sm">
Enable the OAuth Server to make your project act as an identity provider for
third-party applications.
</p>
</div>
<Button asChild>
<Link href={`/project/${projectRef}/auth/oauth-server`}>
Go to OAuth Server Settings
</Link>
</Button>
</CardContent>
</Card>
</div>
)}
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-2 flex-wrap">
<div className="flex flex-col lg:flex-row lg:items-center gap-2">
<Input
placeholder="Search oAuth apps"
size="tiny"
icon={<Search size="14" />}
value={filterString}
className="w-full lg:w-52"
onChange={(e) => setFilterString(e.target.value)}
/>
<FilterPopover
name="Type"
options={OAUTH_APP_TYPE_OPTIONS}
labelKey="name"
valueKey="value"
iconKey="icon"
activeOptions={filteredAppTypes}
labelClass="text-xs text-foreground-light"
maxHeightClass="h-[190px]"
className="w-52"
onSaveFilters={setFilteredAppTypes}
/>
<FilterPopover
name="Scope"
options={OAUTH_APP_SCOPE_OPTIONS}
labelKey="name"
valueKey="value"
iconKey="icon"
activeOptions={filteredAppScopes}
labelClass="text-xs text-foreground-light"
maxHeightClass="h-[190px]"
className="w-52"
onSaveFilters={setFilteredAppScopes}
/>
</div>
<div className="flex items-center gap-x-2">
<ButtonTooltip
disabled={!isOAuthServerEnabled}
icon={<Plus />}
onClick={() => setShowCreateSheet(true)}
className="flex-grow"
tooltip={{
content: {
side: 'bottom',
text: !isOAuthServerEnabled
? 'OAuth server must be enabled in settings'
: undefined,
},
}}
>
New OAuth App
</ButtonTooltip>
</div>
</div>
<div className="w-full overflow-hidden overflow-x-auto">
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Client ID</TableHead>
<TableHead>Type</TableHead>
<TableHead>Scope</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-8"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{oAuthApps.length === 0 && (
<TableRow>
<TableCell colSpan={6}>
<p className="text-foreground-lighter">No OAuth apps found</p>
</TableCell>
</TableRow>
)}
{oAuthApps.length > 0 &&
oAuthApps.map((app) => (
<TableRow key={app.client_id} className="w-full">
<TableCell className="w-100 max-w-64 truncate" title={app.client_name}>
{app.client_name}
</TableCell>
<TableCell className="max-w-40" title={app.client_id}>
<Badge>{app.client_id}</Badge>
</TableCell>
<TableCell className="max-w-40">
{app.client_type === 'public' ? 'Public' : 'Private'}
</TableCell>
<TableCell className="max-w-40">
{app.scope ? (
<Badge>{app.scope}</Badge>
) : (
<span className="text-xs text-foreground-light">N/A</span>
)}
</TableCell>
<TableCell className="text-xs text-foreground-light w-1/6">
<TimestampInfo utcTimestamp={app.created_at} labelFormat="D MMM, YYYY" />
</TableCell>
<TableCell>
<div className="flex justify-end items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" className="px-1" icon={<MoreVertical />} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-52">
<DropdownMenuItem
className="space-x-2"
onClick={() => {
setSelectedApp(app)
setShowRegenerateDialog(true)
}}
>
<RotateCw size={14} />
<p>Regenerate client secret</p>
</DropdownMenuItem>
<DropdownMenuItem
className="space-x-2"
onClick={() => handleDeleteClick(app)}
>
<Trash size={14} />
<p>Delete</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
</div>
</div>
<CreateOAuthAppSheet
visible={showCreateSheet}
onSuccess={(app) => {
setShowCreateSheet(false)
setSelectedApp(undefined)
setNewOAuthApp(app)
}}
onCancel={() => {
setShowCreateSheet(false)
setSelectedApp(undefined)
}}
/>
<DeleteOAuthAppModal
visible={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
selectedApp={selectedApp}
/>
<ConfirmationModal
variant="warning"
visible={showRegenerateDialog}
loading={isRegenerating}
title="Confirm regenerating client secret"
confirmLabel="Confirm"
onCancel={() => setShowRegenerateDialog(false)}
onConfirm={() => {
regenerateSecret({
projectRef,
supabaseClient: supabaseClientData?.supabaseClient,
clientId: selectedApp?.client_id!,
})
setShowRegenerateDialog(false)
}}
>
<p className="text-sm text-foreground-light">
Are you sure you wish to regenerate the client secret for "{selectedApp?.client_name}"?
All existing sessions will be invalidated. This action cannot be undone.
</p>
</ConfirmationModal>
</>
)
}
@@ -0,0 +1,416 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import * as z from 'zod'
import { useParams } from 'common'
import {
ScaffoldSection,
ScaffoldSectionContent,
ScaffoldSectionTitle,
} from 'components/layouts/Scaffold'
import NoPermission from 'components/ui/NoPermission'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
import { useOAuthServerAppsQuery } from 'data/oauth-server-apps/oauth-server-apps-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSupabaseClientQuery } from 'hooks/use-supabase-client-query'
import {
Button,
Card,
CardContent,
CardFooter,
FormControl_Shadcn_,
FormField_Shadcn_,
Form_Shadcn_,
Input_Shadcn_,
Switch,
} from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
const configUrlSchema = z.object({
id: z.string(),
name: z.string(),
value: z.string(),
description: z.string().optional(),
})
const schema = z
.object({
OAUTH_SERVER_ENABLED: z.boolean().default(false),
OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: z.boolean().default(false),
OAUTH_SERVER_AUTHORIZATION_PATH: z.string().default(''),
availableScopes: z.array(z.string()).default(['openid', 'email', 'profile']),
config_urls: z.array(configUrlSchema).optional(),
})
.superRefine((data, ctx) => {
if (data.OAUTH_SERVER_ENABLED && data.OAUTH_SERVER_AUTHORIZATION_PATH.trim() === '') {
ctx.addIssue({
path: ['OAUTH_SERVER_AUTHORIZATION_PATH'],
code: z.ZodIssueCode.custom,
message: 'Authorization Path is required when OAuth Server is enabled.',
})
}
})
interface ConfigUrl {
id: string
name: string
value: string
description?: string
}
interface OAuthServerSettings {
OAUTH_SERVER_ENABLED: boolean
OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: boolean
OAUTH_SERVER_AUTHORIZATION_PATH?: string
availableScopes: string[]
config_urls?: ConfigUrl[]
}
export const OAuthServerSettingsForm = () => {
const { ref: projectRef } = useParams()
const {
data: authConfig,
isLoading: isAuthConfigLoading,
isSuccess,
} = useAuthConfigQuery({ projectRef })
const { mutate: updateAuthConfig, isLoading } = useAuthConfigUpdateMutation({
onSuccess: () => {
toast.success('OAuth server settings updated successfully')
},
onError: (error) => {
toast.error(`Failed to update OAuth server settings: ${error?.message}`)
},
})
const [showDynamicAppsConfirmation, setShowDynamicAppsConfirmation] = useState(false)
const [showDisableOAuthServerConfirmation, setShowDisableOAuthServerConfirmation] =
useState(false)
const {
can: canReadConfig,
isLoading: isLoadingPermissions,
isSuccess: isPermissionsLoaded,
} = useAsyncCheckPermissions(PermissionAction.READ, 'custom_config_gotrue')
const { data: supabaseClientData } = useSupabaseClientQuery({ projectRef })
const { data: oAuthAppsData } = useOAuthServerAppsQuery({
projectRef,
supabaseClient: supabaseClientData?.supabaseClient,
})
const oauthApps = oAuthAppsData?.clients || []
const { can: canUpdateConfig } = useAsyncCheckPermissions(
PermissionAction.UPDATE,
'custom_config_gotrue'
)
const form = useForm<OAuthServerSettings>({
resolver: zodResolver(schema),
defaultValues: {
OAUTH_SERVER_ENABLED: true,
OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: false,
OAUTH_SERVER_AUTHORIZATION_PATH: '/oauth/consent',
availableScopes: ['openid', 'email', 'profile'],
},
})
// Reset the values when the authConfig is loaded
useEffect(() => {
if (isSuccess && authConfig) {
form.reset({
OAUTH_SERVER_ENABLED: authConfig.OAUTH_SERVER_ENABLED ?? false,
OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION:
authConfig.OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION ?? false,
OAUTH_SERVER_AUTHORIZATION_PATH:
authConfig.OAUTH_SERVER_AUTHORIZATION_PATH ?? '/oauth/consent',
availableScopes: ['openid', 'email', 'profile'], // Keep default scopes
})
}
}, [isSuccess])
const onSubmit = async (values: OAuthServerSettings) => {
if (!projectRef) return console.error('Project ref is required')
const config = {
OAUTH_SERVER_ENABLED: values.OAUTH_SERVER_ENABLED,
OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: values.OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION,
OAUTH_SERVER_AUTHORIZATION_PATH: values.OAUTH_SERVER_AUTHORIZATION_PATH,
}
updateAuthConfig({ projectRef, config })
}
const handleDynamicAppsToggle = (checked: boolean) => {
if (checked) {
setShowDynamicAppsConfirmation(true)
} else {
form.setValue('OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION', false, { shouldDirty: true })
}
}
const confirmDynamicApps = () => {
form.setValue('OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION', true, { shouldDirty: true })
setShowDynamicAppsConfirmation(false)
}
const cancelDynamicApps = () => {
setShowDynamicAppsConfirmation(false)
}
const handleOAuthServerToggle = (checked: boolean) => {
if (!checked && oauthApps.length > 0) {
setShowDisableOAuthServerConfirmation(true)
} else {
form.setValue('OAUTH_SERVER_ENABLED', checked, { shouldDirty: true })
}
}
const confirmDisableOAuthServer = () => {
form.setValue('OAUTH_SERVER_ENABLED', false, { shouldDirty: true })
setShowDisableOAuthServerConfirmation(false)
}
const cancelDisableOAuthServer = () => {
setShowDisableOAuthServerConfirmation(false)
}
if (isPermissionsLoaded && !canReadConfig) {
return (
<ScaffoldSection isFullWidth>
<ScaffoldSectionTitle className="mb-4">OAuth Server</ScaffoldSectionTitle>
<div className="mt-8">
<NoPermission resourceText="view OAuth server settings" />
</div>
</ScaffoldSection>
)
}
if (isAuthConfigLoading || isLoadingPermissions) {
return (
<div className="pt-12">
<GenericSkeletonLoader />
</div>
)
}
return (
<>
<ScaffoldSection isFullWidth>
<ScaffoldSectionContent>
<Form_Shadcn_ {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="pb-10">
<Card>
<CardContent className="flex flex-col py-6 gap-y-4">
<FormField_Shadcn_
control={form.control}
name="OAUTH_SERVER_ENABLED"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Enable the Supabase OAuth Server"
description={
<>
Enable OAuth server functionality for your project to create and manage
OAuth applications.{' '}
<Link
href="https://supabase.com/docs/guides/auth/oauth/oauth-apps"
target="_blank"
rel="noreferrer"
className="text-foreground-light underline hover:text-foreground transition"
>
Learn more
</Link>
</>
}
>
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={handleOAuthServerToggle}
disabled={!canUpdateConfig}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
{/* Site URL and Authorization Path - Only show when OAuth Server is enabled */}
{form.watch('OAUTH_SERVER_ENABLED') && (
<>
<CardContent className="flex flex-col py-6 gap-y-4">
<FormItemLayout
label="Site URL"
description={
<>
The base URL of your application, configured in{' '}
<Link
href={`/project/${projectRef}/auth/url-configuration`}
rel="noreferrer"
className="text-foreground-light underline hover:text-foreground transition"
>
Auth URL Configuration
</Link>{' '}
settings.
</>
}
>
<Input_Shadcn_
value={authConfig?.SITE_URL}
disabled
placeholder="https://example.com"
/>
</FormItemLayout>
<FormField_Shadcn_
control={form.control}
name="OAUTH_SERVER_AUTHORIZATION_PATH"
render={({ field }) => (
<FormItemLayout
label="Authorization Path"
description="Path where you'll implement the OAuth authorization UI (consent screens)."
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} placeholder="/auth/authorize" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<Admonition
type="tip"
title="Make sure this path is implemented in your application."
description={`Preview Authorization URL: ${authConfig?.SITE_URL}${form.watch('OAUTH_SERVER_AUTHORIZATION_PATH') || '/oauth/consent'}`}
/>
</CardContent>
<CardContent className="py-6">
<FormField_Shadcn_
control={form.control}
name="OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Allow Dynamic OAuth Apps"
description={
<>
Enable dynamic OAuth app registration. Apps can be registered
programmatically via apis.{' '}
<Link
href="https://supabase.com/docs/guides/auth/oauth/oauth-apps#dynamic-oauth-apps"
target="_blank"
rel="noreferrer"
className="text-foreground-light underline hover:text-foreground transition"
>
Learn more
</Link>
</>
}
>
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={handleDynamicAppsToggle}
disabled={!canUpdateConfig}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
</>
)}
<CardFooter className="justify-end space-x-2">
<Button type="default" onClick={() => form.reset()} disabled={isLoading}>
Cancel
</Button>
<Button
type="primary"
htmlType="submit"
disabled={!canUpdateConfig || !form.formState.isDirty}
loading={isLoading}
>
Save changes
</Button>
</CardFooter>
</Card>
</form>
</Form_Shadcn_>
</ScaffoldSectionContent>
</ScaffoldSection>
{/* Dynamic Apps Confirmation Modal */}
<ConfirmationModal
variant="warning"
visible={showDynamicAppsConfirmation}
size="large"
title="Enable dynamic client registration"
confirmLabel="Enable dynamic registration"
onConfirm={confirmDynamicApps}
onCancel={cancelDynamicApps}
alert={{
title:
'By confirming, you acknowledge the risks and would like to move forward with enabling dynamic client registration.',
}}
>
<p className="text-sm text-foreground-lighter pb-4">
Enabling dynamic client registration will open up a public API endpoint that anyone can
use to register OAuth applications with your app. This can be a security concern, as
attackers could register OAuth applications with legitimate-sounding names and send them
to your users for approval.
</p>
<p className="text-sm text-foreground-lighter pb-4">
If your users don't look carefully and accept, the attacker could potentially take over
the user's account. Attackers can also flood your application with thousands of OAuth
applications that cannot be attributed to anyone (as it's a public endpoint), and make it
difficult for you to find and shut them down, or even find legitimate ones.
</p>
<p className="text-sm text-foreground-lighter pb-4">
If dynamic client registration is enabled, the consent screen is forced to be enabled for
all OAuth flows and can no longer be disabled. Disabling the consent screen opens up a
CSRF vulnerability in your app.
</p>
</ConfirmationModal>
{/* Disable OAuth Server Confirmation Modal */}
<ConfirmationModal
variant="warning"
visible={showDisableOAuthServerConfirmation}
size="large"
title="Disable OAuth Server"
confirmLabel="Disable OAuth Server"
onConfirm={confirmDisableOAuthServer}
onCancel={cancelDisableOAuthServer}
alert={{
title: `You have ${oauthApps.length} active OAuth app${oauthApps.length > 1 ? 's' : ''} that will be deactivated.`,
}}
>
<p className="text-sm text-foreground-lighter pb-4">
Disabling the OAuth Server will immediately deactivate all OAuth applications and prevent
new authentication flows from working. This action will affect all users currently using
your OAuth applications.
</p>
<p className="text-sm text-foreground-lighter pb-4">
<strong>What will happen:</strong>
</p>
<ul className="text-sm text-foreground-lighter pb-4 list-disc list-inside space-y-1">
<li>All OAuth apps will be deactivated</li>
<li>Existing access tokens will become invalid</li>
<li>Users won't be able to sign in through OAuth flows</li>
<li>Third-party integrations will stop working</li>
</ul>
<p className="text-sm text-foreground-lighter pb-4">
You can re-enable the OAuth Server at any time.
</p>
</ConfirmationModal>
</>
)
}
@@ -15,6 +15,7 @@ const AuthProductMenu = () => {
const { ref: projectRef = 'default' } = useParams()
const authenticationShowOverview = useFlag('authOverviewPage')
const authenticationOauth21 = useFlag('EnableOAuth21')
const authenticationShowSecurityNotifications = useIsSecurityNotificationsEnabled()
const {
@@ -48,6 +49,7 @@ const AuthProductMenu = () => {
authenticationAdvanced,
authenticationShowOverview,
authenticationShowSecurityNotifications,
authenticationOauth21,
})}
/>
)
@@ -12,6 +12,7 @@ export const generateAuthMenu = (
authenticationAdvanced: boolean
authenticationShowOverview: boolean
authenticationShowSecurityNotifications: boolean
authenticationOauth21: boolean
}
): ProductMenuGroup[] => {
const {
@@ -23,6 +24,7 @@ export const generateAuthMenu = (
authenticationAdvanced,
authenticationShowOverview,
authenticationShowSecurityNotifications,
authenticationOauth21,
} = flags ?? {}
return [
@@ -33,6 +35,16 @@ export const generateAuthMenu = (
? [{ name: 'Overview', key: 'overview', url: `/project/${ref}/auth/overview`, items: [] }]
: []),
{ name: 'Users', key: 'users', url: `/project/${ref}/auth/users`, items: [] },
...(authenticationOauth21
? [
{
name: 'OAuth Apps',
key: 'oauth-apps',
url: `/project/${ref}/auth/oauth-apps`,
items: [],
},
]
: []),
],
},
...(authenticationEmails && authenticationShowSecurityNotifications && IS_PLATFORM
@@ -77,6 +89,16 @@ export const generateAuthMenu = (
},
]
: []),
...(authenticationOauth21
? [
{
name: 'OAuth Server',
key: 'oauth-server',
url: `/project/${ref}/auth/oauth-server`,
label: 'BETA',
},
]
: []),
{
name: 'Sessions',
key: 'sessions',
+1
View File
@@ -3,4 +3,5 @@ export const apiKeysKeys = {
['projects', projectRef, 'api-keys', reveal].filter(Boolean),
single: (projectRef?: string, id?: string) => ['projects', projectRef, 'api-keys', id] as const,
status: (projectRef?: string) => ['projects', projectRef, 'api-keys', 'legacy'] as const,
temporary: (projectRef?: string) => ['projects', projectRef, 'api-keys', 'temporary'] as const,
}
@@ -1,4 +1,8 @@
import { type UseQueryOptions, useQuery } from '@tanstack/react-query'
import { handleError, post } from 'data/fetchers'
import type { ResponseError } from 'types'
import { apiKeysKeys } from './keys'
interface getTemporaryAPIKeyVariables {
projectRef?: string
@@ -6,8 +10,7 @@ interface getTemporaryAPIKeyVariables {
expiry?: number
}
// [Joshen] This one specifically shouldn't need a useQuery hook since the expiry is meant to be short lived
// Used in storage explorer and realtime inspector.
// Used in storage explorer, realtime inspector and OAuth Server apps.
export async function getTemporaryAPIKey(
{ projectRef, expiry = 300 }: getTemporaryAPIKeyVariables,
signal?: AbortSignal
@@ -28,3 +31,18 @@ export async function getTemporaryAPIKey(
if (error) handleError(error)
return data
}
export type TemporaryAPIKeyData = Awaited<ReturnType<typeof getTemporaryAPIKey>>
export const useTemporaryAPIKeyQuery = <TData = TemporaryAPIKeyData>(
{ projectRef, expiry = 300 }: getTemporaryAPIKeyVariables,
{ enabled = true, ...options }: UseQueryOptions<TemporaryAPIKeyData, ResponseError, TData> = {}
) => {
return useQuery<TemporaryAPIKeyData, ResponseError, TData>({
queryKey: apiKeysKeys.temporary(projectRef),
queryFn: ({ signal }) => getTemporaryAPIKey({ projectRef, expiry }, signal),
enabled: enabled && typeof projectRef !== 'undefined',
refetchInterval: expiry * 1000, // convert to ms
...options,
})
}
@@ -0,0 +1,4 @@
export const oauthServerAppKeys = {
// temporaryApiKey has to be added to reset the query when it changes
list: (projectRef: string | undefined) => ['projects', projectRef, 'oauth-server-apps'] as const,
}
@@ -0,0 +1,58 @@
import { CreateOAuthClientParams, SupabaseClient } from '@supabase/supabase-js'
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { handleError } from 'data/fetchers'
import type { ResponseError } from 'types'
import { oauthServerAppKeys } from './keys'
export type OAuthServerAppCreateVariables = CreateOAuthClientParams & {
projectRef?: string
supabaseClient?: SupabaseClient<any>
}
export async function createOAuthServerApp({
projectRef,
supabaseClient,
...params
}: OAuthServerAppCreateVariables) {
if (!projectRef) throw new Error('Project reference is required')
if (!supabaseClient) throw new Error('Supabase client is required')
const { data, error } = await supabaseClient.auth.admin.oauth.createClient(params)
if (error) return handleError(error)
return data
}
type OAuthAppCreateData = Awaited<ReturnType<typeof createOAuthServerApp>>
export const useOAuthServerAppCreateMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<OAuthAppCreateData, ResponseError, OAuthServerAppCreateVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<OAuthAppCreateData, ResponseError, OAuthServerAppCreateVariables>({
mutationFn: (vars) => createOAuthServerApp(vars),
onSuccess: async (data, variables, context) => {
const { projectRef } = variables
await queryClient.invalidateQueries({
queryKey: oauthServerAppKeys.list(projectRef),
})
await onSuccess?.(data, variables, context)
},
onError: async (data, variables, context) => {
if (onError === undefined) {
toast.error(`Failed to create OAuth Server application: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
@@ -0,0 +1,60 @@
import { SupabaseClient } from '@supabase/supabase-js'
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { handleError } from 'data/fetchers'
import type { ResponseError } from 'types'
import { oauthServerAppKeys } from './keys'
export type OAuthServerAppDeleteVariables = {
clientId?: string
projectRef?: string
supabaseClient?: SupabaseClient<any>
}
export async function deleteOAuthServerApp({
projectRef,
supabaseClient,
clientId,
}: OAuthServerAppDeleteVariables) {
if (!projectRef) throw new Error('Project reference is required')
if (!supabaseClient) throw new Error('Supabase client is required')
if (!clientId) throw new Error('Client ID is required')
const { data, error } = await supabaseClient.auth.admin.oauth.deleteClient(clientId)
console.log(data, error)
if (error) return handleError(error)
return null
}
type OAuthAppDeleteData = Awaited<ReturnType<typeof deleteOAuthServerApp>>
export const useOAuthServerAppDeleteMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<OAuthAppDeleteData, ResponseError, OAuthServerAppDeleteVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<OAuthAppDeleteData, ResponseError, OAuthServerAppDeleteVariables>({
mutationFn: (vars) => deleteOAuthServerApp(vars),
onSuccess: async (data, variables, context) => {
const { projectRef } = variables
await queryClient.invalidateQueries({
queryKey: oauthServerAppKeys.list(projectRef),
})
await onSuccess?.(data, variables, context)
},
onError: async (data, variables, context) => {
if (onError === undefined) {
toast.error(`Failed to delete OAuth Server application: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
@@ -0,0 +1,68 @@
import { SupabaseClient } from '@supabase/supabase-js'
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { handleError } from 'data/fetchers'
import type { ResponseError } from 'types'
import { oauthServerAppKeys } from './keys'
export type OAuthServerAppRegenerateSecretVariables = {
projectRef?: string
supabaseClient?: SupabaseClient<any>
clientId: string
}
export async function regenerateSecret({
projectRef,
supabaseClient,
clientId,
}: OAuthServerAppRegenerateSecretVariables) {
if (!projectRef) throw new Error('Project reference is required')
if (!supabaseClient) throw new Error('Supabase client is required')
if (!clientId) throw new Error('Oauth app client id is required')
const { data, error } = await supabaseClient.auth.admin.oauth.regenerateClientSecret(clientId)
if (error) handleError(error)
return data
}
type OAuthAppRegenerateSecretData = Awaited<ReturnType<typeof regenerateSecret>>
export const useOAuthServerAppRegenerateSecretMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<
OAuthAppRegenerateSecretData,
ResponseError,
OAuthServerAppRegenerateSecretVariables
>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<
OAuthAppRegenerateSecretData,
ResponseError,
OAuthServerAppRegenerateSecretVariables
>({
mutationFn: (vars) => regenerateSecret(vars),
onSuccess: async (data, variables, context) => {
const { projectRef } = variables
await queryClient.invalidateQueries({
queryKey: oauthServerAppKeys.list(projectRef),
})
await onSuccess?.(data, variables, context)
},
onError: async (data, variables, context) => {
if (onError === undefined) {
toast.error(`Failed to regenerate OAuth Server application secret: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
@@ -0,0 +1,52 @@
import { SupabaseClient } from '@supabase/supabase-js'
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { components } from 'api-types'
import { handleError } from 'data/fetchers'
import type { ResponseError } from 'types'
import { oauthServerAppKeys } from './keys'
export type OAuthServerAppsVariables = {
projectRef?: string
supabaseClient?: SupabaseClient<any>
page?: number
}
const APPS_PER_PAGE = 100
export type OAuthApp = components['schemas']['OAuthAppResponse']
export async function getOAuthServerApps({
projectRef,
supabaseClient,
page = 1,
}: OAuthServerAppsVariables) {
if (!projectRef) throw new Error('Project reference is required')
if (!supabaseClient) throw new Error('Supabase client is required')
const { data, error } = await supabaseClient.auth.admin.oauth.listClients({
page,
perPage: APPS_PER_PAGE,
})
if (error) handleError(error)
return data
}
export type OAuthServerAppsData = Awaited<ReturnType<typeof getOAuthServerApps>>
export type OAuthServerAppsError = ResponseError
export const useOAuthServerAppsQuery = <TData = OAuthServerAppsData>(
{ projectRef, supabaseClient }: OAuthServerAppsVariables,
{
enabled = true,
...options
}: UseQueryOptions<OAuthServerAppsData, OAuthServerAppsError, TData> = {}
) => {
return useQuery({
queryKey: oauthServerAppKeys.list(projectRef),
queryFn: () => getOAuthServerApps({ projectRef, supabaseClient }),
enabled: enabled && typeof projectRef !== 'undefined' && !!supabaseClient,
...options,
})
}
@@ -0,0 +1,54 @@
import { createClient } from '@supabase/supabase-js'
import { useQuery } from '@tanstack/react-query'
import { useTemporaryAPIKeyQuery } from 'data/api-keys/temp-api-keys-query'
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
const getSupabaseClient = ({
projectRef,
endpoint,
temporaryApiKey,
}: {
projectRef?: string
endpoint?: string
temporaryApiKey?: string
}) => {
if (!projectRef) {
return undefined
}
if (!endpoint) {
return undefined
}
if (temporaryApiKey === undefined) {
return undefined
}
const supabaseClient = createClient(endpoint, temporaryApiKey)
return { supabaseClient }
}
/**
* The client uses a temporary API key to authenticate requests. The API key expires after one hour which may cause
* 401 errors for all requests made with the client. It's easily fixable by refreshing the page.
*/
export const useSupabaseClientQuery = (
{ projectRef }: { projectRef?: string },
{ enabled = true, ...options } = {}
) => {
const { data: settings } = useProjectSettingsV2Query({ projectRef })
const { data: temporaryApiKeyData } = useTemporaryAPIKeyQuery({ projectRef, expiry: 3600 })
const endpoint = settings
? `${settings?.app_config?.protocol ?? 'https'}://${settings?.app_config?.endpoint}`
: undefined
const temporaryApiKey = temporaryApiKeyData?.api_key
return useQuery({
queryKey: [projectRef, 'supabase-client', endpoint, temporaryApiKey],
queryFn: () => getSupabaseClient({ projectRef, endpoint, temporaryApiKey }),
enabled: enabled && typeof projectRef !== 'undefined' && !!endpoint && !!temporaryApiKey,
...options,
})
}
@@ -0,0 +1,28 @@
import { OAuthAppsList } from 'components/interfaces/Auth/OAuthApps/OAuthAppsList'
import AuthLayout from 'components/layouts/AuthLayout/AuthLayout'
import DefaultLayout from 'components/layouts/DefaultLayout'
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import type { NextPageWithLayout } from 'types'
const OAuthApps: NextPageWithLayout = () => (
<ScaffoldContainer>
<ScaffoldSection>
<div className="col-span-12">
<FormHeader
title="OAuth Apps"
docsUrl="https://supabase.com/docs/guides/auth/oauth/oauth-apps"
/>
<OAuthAppsList />
</div>
</ScaffoldSection>
</ScaffoldContainer>
)
OAuthApps.getLayout = (page) => (
<DefaultLayout>
<AuthLayout>{page}</AuthLayout>
</DefaultLayout>
)
export default OAuthApps
@@ -0,0 +1,29 @@
import { OAuthServerSettingsForm } from 'components/interfaces/Auth/OAuthApps/OAuthServerSettingsForm'
import AuthLayout from 'components/layouts/AuthLayout/AuthLayout'
import DefaultLayout from 'components/layouts/DefaultLayout'
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
import { ScaffoldContainer } from 'components/layouts/Scaffold'
import type { NextPageWithLayout } from 'types'
const ProvidersPage: NextPageWithLayout = () => {
return (
<ScaffoldContainer>
<OAuthServerSettingsForm />
</ScaffoldContainer>
)
}
ProvidersPage.getLayout = (page) => (
<DefaultLayout>
<AuthLayout>
<PageLayout
title="OAuth Server"
subtitle="Configure your project to act as an identity provider for third-party applications."
>
{page}
</PageLayout>
</AuthLayout>
</DefaultLayout>
)
export default ProvidersPage