Add support for scopes oAuth apps (#17976)

* Bump @supabase/shared-types.

* Add scopes parameter to all queries and mutations.

* Refactor the publish app side panel to include scopes.

* Refactor the authorize page to show the app scopes.

* Remove a console.log.

* Address all feedback.

* Simplify the scope rendering.

* Simplify the scopes wording when creating an app.

* Make the modal a bit wider so that some of the text doesn't overflow.

* Add docs url to oauth scopes

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Ivan VasilovandJoshen Lim authored and GitHub committed 2023-10-17 13:07:02 +08:00
1 parent c4320bd0ae
commit dbccdf8beb
11 files changed
+356 -28

No files matched your search

+6 -6
View File
@@ -15279,11 +15279,6 @@
"websocket": "^1.0.34"
}
},
"node_modules/@supabase/shared-types": {
"version": "0.1.47",
"resolved": "https://registry.npmjs.org/@supabase/shared-types/-/shared-types-0.1.47.tgz",
"integrity": "sha512-OsXFgKd7yID/5mHuQ8k7I+dCT++ctEEj4YqV8soAik2EP0oiqqRzPwJ8kdmpwK+YyB0Z+AfOxex4dVa7J9J6eA=="
},
"node_modules/@supabase/storage-js": {
"version": "2.5.1",
"license": "MIT",
@@ -42949,7 +42944,7 @@
"@supabase/auth-helpers-nextjs": "^0.5.6",
"@supabase/auth-helpers-react": "^0.3.1",
"@supabase/react-data-grid": "^7.1.0-beta.7",
"@supabase/shared-types": "0.1.47",
"@supabase/shared-types": "0.1.51",
"@supabase/supabase-js": "^2.23.0",
"@tanstack/react-query": "^4.22.0",
"@tanstack/react-query-devtools": "^4.22.0",
@@ -43274,6 +43269,11 @@
"url": "https://opencollective.com/storybook"
}
},
"studio/node_modules/@supabase/shared-types": {
"version": "0.1.51",
"resolved": "https://registry.npmjs.org/@supabase/shared-types/-/shared-types-0.1.51.tgz",
"integrity": "sha512-ZCm3sdxFwFDIOakciAH+or1gWQ6SKqiSWmmejsUCyH5QfMl4ngFc6hZcNEky5+NJ5wI7YtukUpvKihNVR0iNSg=="
},
"studio/node_modules/@tanstack/match-sorter-utils": {
"version": "8.7.6",
"license": "MIT",
@@ -1,13 +1,62 @@
import { OAuthScope } from '@supabase/shared-types/out/constants'
import { IconCheck } from 'ui'
export interface AuthorizeRequesterDetailsProps {
icon: string | null
name: string
domain: string
scopes: OAuthScope[]
}
const AuthorizeRequesterDetails = ({ icon, name, domain }: AuthorizeRequesterDetailsProps) => {
const ScopeSection = ({
description,
hasReadScope,
hasWriteScope,
}: {
description: string
hasReadScope: boolean
hasWriteScope: boolean
}) => {
if (hasReadScope || hasWriteScope) {
const perms = [hasReadScope ? 'Read' : null, hasWriteScope ? 'Write' : null]
.filter(Boolean)
.map((str) => (
<span key={str} className="font-semibold text-foreground">
{str}
</span>
))
.reduce((acc, v) => (
<>
{acc}
<span> and </span>
{v}
</>
))
return (
<div className="first:border-t border-b flex flex-row space-x-1 text-sm text-foreground-light py-2 px-1">
<div className="pt-0.5">
<IconCheck stroke="green" height={18} width={18} strokeWidth={1.5} />
</div>
<div>
{perms} {description}
</div>
</div>
)
}
return null
}
const AuthorizeRequesterDetails = ({
icon,
name,
domain,
scopes,
}: AuthorizeRequesterDetailsProps) => {
return (
<div className="flex space-x-4">
<div>
<div className="flex space-y-4 flex-col">
<div className="flex flex-row space-x-4">
<div className="flex items-center">
<div
className="w-14 h-14 md:w-16 md:h-16 bg-center bg-no-repeat bg-cover flex items-center justify-center rounded-md border border-scale-600"
@@ -18,14 +67,64 @@ const AuthorizeRequesterDetails = ({ icon, name, domain }: AuthorizeRequesterDet
{icon === null && <p className="text-foreground-light text-lg">{name[0]}</p>}
</div>
</div>
<p className="text-sm text-foreground-light">
{name} ({domain}) is requesting API access to an organization.
</p>
</div>
<div>
<h2>Permissions</h2>
<p className="text-sm text-foreground-light">
The following scopes will apply for the{' '}
<span className="text-amber-900">selected organization and all of its projects.</span>
</p>
<div className="pt-2">
<ScopeSection
description="access to auth configurations and SSO providers."
hasReadScope={scopes.includes(OAuthScope.AUTH_READ)}
hasWriteScope={scopes.includes(OAuthScope.AUTH_WRITE)}
/>
<ScopeSection
description="access to Postgres configurations, SQL snippets, SSL enforcement configurations and Typescript schema types."
hasReadScope={scopes.includes(OAuthScope.DATABASE_READ)}
hasWriteScope={scopes.includes(OAuthScope.DATABASE_WRITE)}
/>
<ScopeSection
description="access to custom domains and vanity subdomains."
hasReadScope={scopes.includes(OAuthScope.DOMAINS_READ)}
hasWriteScope={scopes.includes(OAuthScope.DOMAINS_WRITE)}
/>
<ScopeSection
description="access to edge functions."
hasReadScope={scopes.includes(OAuthScope.EDGE_FUNCTIONS_READ)}
hasWriteScope={scopes.includes(OAuthScope.EDGE_FUNCTIONS_WRITE)}
/>
<ScopeSection
description="access to environments/branches."
hasReadScope={scopes.includes(OAuthScope.ENVIRONMENT_READ)}
hasWriteScope={scopes.includes(OAuthScope.ENVIRONMENT_WRITE)}
/>
<ScopeSection
description="access to the organization and all its members."
hasReadScope={scopes.includes(OAuthScope.ORGANIZATIONS_READ)}
hasWriteScope={scopes.includes(OAuthScope.ORGANIZATIONS_WRITE)}
/>
<ScopeSection
description="access to metadata, its upgrade status, network restrictions and network bans."
hasReadScope={scopes.includes(OAuthScope.PROJECTS_READ)}
hasWriteScope={scopes.includes(OAuthScope.PROJECTS_WRITE)}
/>
<ScopeSection
description="access to PostgREST configurations."
hasReadScope={scopes.includes(OAuthScope.REST_READ)}
hasWriteScope={scopes.includes(OAuthScope.REST_WRITE)}
/>
<ScopeSection
description="access to API keys, secrets and pgsodium configurations."
hasReadScope={scopes.includes(OAuthScope.SECRETS_READ)}
hasWriteScope={scopes.includes(OAuthScope.SECRETS_WRITE)}
/>
</div>
</div>
<p className="text-sm text-foreground-light">
{name} ({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>
)
}
@@ -0,0 +1,183 @@
import { OAuthScope } from '@supabase/shared-types/out/constants'
import { Dispatch, PropsWithChildren, SetStateAction } from 'react'
import {
Button,
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
IconChevronDown,
} from 'ui'
const ScopeDropdownCheckboxItem = ({
children,
scopeName,
scopes,
onChange,
}: PropsWithChildren<{
scopeName: OAuthScope
scopes: OAuthScope[]
onChange: (v: OAuthScope[]) => void
}>) => {
return (
<DropdownMenuCheckboxItem
checked={scopes.includes(scopeName)}
onCheckedChange={(checked) => {
if (checked) {
onChange([...scopes, scopeName])
} else {
onChange([...scopes.filter((s) => s !== scopeName)])
}
}}
>
{children}
</DropdownMenuCheckboxItem>
)
}
const Scope = ({
title,
description,
readScopeName,
writeScopeName,
scopes,
setScopes,
}: {
title: string
description: string
readScopeName: OAuthScope
writeScopeName: OAuthScope
scopes: OAuthScope[]
setScopes: Dispatch<SetStateAction<OAuthScope[]>>
}) => {
let accessDescription = 'Access: No access'
if (scopes.includes(readScopeName)) {
accessDescription = 'Access: Read-only'
}
if (scopes.includes(writeScopeName)) {
accessDescription = 'Access: Write-only'
}
if (scopes.includes(readScopeName) && scopes.includes(writeScopeName)) {
accessDescription = 'Access: Read and write'
}
return (
<div
className="flex flex-row justify-between p-4 border border-scale-300 dark:border-scale-500 first:rounded-t last:rounded-b"
key={title}
>
<div className="flex flex-col">
<span className="text-foreground text-sm">{title}</span>
<span className="text-foreground-light text-xs">{description}</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger>
<Button type="default" iconRight={<IconChevronDown />}>
<p>{accessDescription}</p>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Select an access level</DropdownMenuLabel>
<DropdownMenuSeparator />
<ScopeDropdownCheckboxItem scopeName={readScopeName} scopes={scopes} onChange={setScopes}>
Read
</ScopeDropdownCheckboxItem>
<ScopeDropdownCheckboxItem
scopeName={writeScopeName}
scopes={scopes}
onChange={setScopes}
>
Write
</ScopeDropdownCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
export const ScopesPanel = ({
scopes,
setScopes,
}: {
scopes: OAuthScope[]
setScopes: Dispatch<SetStateAction<OAuthScope[]>>
}) => {
return (
<div className="-space-y-px">
<Scope
title="Auth"
description="Auth configurations and SSO providers."
readScopeName={OAuthScope.AUTH_READ}
writeScopeName={OAuthScope.AUTH_WRITE}
scopes={scopes}
setScopes={setScopes}
/>
<Scope
title="Database"
description="Postgres configurations, SQL snippets, SSL enforcement configurations and Typescript schema types."
readScopeName={OAuthScope.DATABASE_READ}
writeScopeName={OAuthScope.DATABASE_WRITE}
scopes={scopes}
setScopes={setScopes}
/>
<Scope
title="Domains"
description="Custom domains and vanity subdomains."
readScopeName={OAuthScope.DOMAINS_READ}
writeScopeName={OAuthScope.DOMAINS_WRITE}
scopes={scopes}
setScopes={setScopes}
/>
<Scope
title="Edge Functions"
description="Edge functions."
readScopeName={OAuthScope.EDGE_FUNCTIONS_READ}
writeScopeName={OAuthScope.EDGE_FUNCTIONS_WRITE}
scopes={scopes}
setScopes={setScopes}
/>
<Scope
title="Environment"
description="Environments/branches."
readScopeName={OAuthScope.ENVIRONMENT_READ}
writeScopeName={OAuthScope.ENVIRONMENT_WRITE}
scopes={scopes}
setScopes={setScopes}
/>
<Scope
title="Organizations"
description="Organizations and all its members."
readScopeName={OAuthScope.ORGANIZATIONS_READ}
writeScopeName={OAuthScope.ORGANIZATIONS_WRITE}
scopes={scopes}
setScopes={setScopes}
/>
<Scope
title="Projects"
description="Metadata, upgrade status, network restrictions and network bans."
readScopeName={OAuthScope.PROJECTS_READ}
writeScopeName={OAuthScope.PROJECTS_WRITE}
scopes={scopes}
setScopes={setScopes}
/>
<Scope
title="REST"
description="PostgREST configurations."
readScopeName={OAuthScope.REST_READ}
writeScopeName={OAuthScope.REST_WRITE}
scopes={scopes}
setScopes={setScopes}
/>
<Scope
title="Secrets"
description="API keys, secrets and pgsodium configurations."
readScopeName={OAuthScope.SECRETS_READ}
writeScopeName={OAuthScope.SECRETS_WRITE}
scopes={scopes}
setScopes={setScopes}
/>
</div>
)
}
@@ -1,6 +1,7 @@
import clsx from 'clsx'
import { ChangeEvent, useEffect, useRef, useState } from 'react'
import { OAuthScope } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import {
OAuthAppCreateResponse,
@@ -20,12 +21,15 @@ import {
DropdownMenuTrigger,
Form,
IconEdit,
IconExternalLink,
IconUpload,
Input,
Modal,
SidePanel,
} from 'ui'
import AuthorizeRequesterDetails from './AuthorizeRequesterDetails'
import AuthorizeRequesterDetails from '../AuthorizeRequesterDetails'
import { ScopesPanel } from './Scopes'
import Link from 'next/link'
export interface PublishAppSidePanelProps {
visible: boolean
@@ -88,6 +92,7 @@ const PublishAppSidePanel = ({
const [iconUrl, setIconUrl] = useState<string>()
const [errors, setErrors] = useState<{ [key: string]: string }>({})
const [urls, setUrls] = useState<{ id: string; value: string }[]>([{ id: uuidv4(), value: '' }])
const [scopes, setScopes] = useState<OAuthScope[]>([])
useEffect(() => {
if (visible) {
@@ -101,9 +106,11 @@ const PublishAppSidePanel = ({
return { id: uuidv4(), value: url }
})
)
setScopes(selectedApp.scopes)
setIconUrl(selectedApp.icon === null ? undefined : selectedApp.icon)
} else {
setUrls([{ id: uuidv4(), value: '' }])
setScopes([])
setIconUrl(undefined)
}
}
@@ -164,6 +171,7 @@ const PublishAppSidePanel = ({
name,
website,
redirect_uris,
scopes,
icon: uploadedIconUrl,
})
} else {
@@ -174,6 +182,7 @@ const PublishAppSidePanel = ({
name,
website,
redirect_uris,
scopes,
icon: uploadedIconUrl === undefined ? null : uploadedIconUrl,
})
}
@@ -332,6 +341,30 @@ const PublishAppSidePanel = ({
</div>
</SidePanel.Content>
<SidePanel.Separator />
<div className="p-6 ">
<div className="flex items-start justify-between space-x-4 pb-4">
<div className="flex flex-col">
<span className="prose text-sm">Application permissions</span>
<span className="text-sm text-foreground-light">
The application permissions are organized in scopes and will be
presented to the user when adding an app to their organization and all
of its projects.
</span>
</div>
<Link
passHref
href="https://supabase.com/docs/guides/platform/oauth-apps/oauth-scopes"
>
<Button asChild type="default" icon={<IconExternalLink />}>
<a target="_blank" rel="noreferrer">
Documentation
</a>
</Button>
</Link>
</div>
<ScopesPanel scopes={scopes} setScopes={setScopes} />
</div>
</div>
<SidePanel.Separator />
@@ -359,7 +392,7 @@ const PublishAppSidePanel = ({
<Modal
hideFooter
className="!w-[500px]"
className="!max-w-[600px]"
visible={showPreview}
onCancel={() => setShowPreview(false)}
>
@@ -376,6 +409,7 @@ const PublishAppSidePanel = ({
icon={iconUrl || null}
name={values.name}
domain={values.website}
scopes={scopes}
/>
<div className="pt-4 space-y-2">
<p className="prose text-sm">
@@ -1,9 +1,9 @@
import Divider from 'components/ui/Divider'
import { BASE_PATH } from 'lib/constants'
import { useTheme } from 'next-themes'
import Head from 'next/head'
import Image from 'next/image'
import { useTheme } from 'next-themes'
import { PropsWithChildren } from 'react'
import { BASE_PATH } from 'lib/constants'
import Divider from 'components/ui/Divider'
export interface APIAuthorizationLayoutProps {}
@@ -37,7 +37,7 @@ const APIAuthorizationLayout = ({ children }: PropsWithChildren<APIAuthorization
</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">
<div className="flex flex-col justify-center flex-grow mx-auto w-[90vw] max-w-[600px] h-full space-y-4">
{children}
</div>
</main>
@@ -1,3 +1,4 @@
import { OAuthScope } from '@supabase/shared-types/out/constants'
import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query'
import { get } from 'lib/common/fetch'
import { API_ADMIN_URL } from 'lib/constants'
@@ -13,6 +14,7 @@ export type ApiAuthorizationResponse = {
website: string
icon: string | null
domain: string
scopes: OAuthScope[]
expires_at: string
approved_at: string | null
approved_organization_slug?: string
@@ -1,16 +1,18 @@
import { toast } from 'react-hot-toast'
import { OAuthScope } from '@supabase/shared-types/out/constants'
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'react-hot-toast'
import { post } from 'lib/common/fetch'
import { API_ADMIN_URL } from 'lib/constants'
import { oauthAppKeys } from './keys'
import { ResponseError } from 'types'
import { oauthAppKeys } from './keys'
export type OAuthAppCreateVariables = {
slug: string
name: string
website: string
icon?: string
scopes?: OAuthScope[]
redirect_uris: string[]
}
@@ -25,12 +27,14 @@ export async function createOAuthApp({
name,
website,
icon,
scopes,
redirect_uris,
}: OAuthAppCreateVariables) {
const response = await post(`${API_ADMIN_URL}/organizations/${slug}/oauth/apps`, {
name,
website,
icon,
scopes,
redirect_uris,
})
if (response.error) throw response.error
@@ -1,3 +1,4 @@
import { OAuthScope } from '@supabase/shared-types/out/constants'
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'react-hot-toast'
@@ -12,6 +13,7 @@ export type OAuthAppUpdateVariables = {
name: string
website: string
icon?: string | null
scopes?: OAuthScope[]
redirect_uris: string[]
}
@@ -21,6 +23,7 @@ export async function updateOAuthApp({
name,
website,
icon,
scopes,
redirect_uris,
}: OAuthAppUpdateVariables) {
if (!id) throw new Error('OAuth app ID is required')
@@ -33,6 +36,7 @@ export async function updateOAuthApp({
name,
website,
icon,
scopes,
redirect_uris,
})
if (response.error) throw response.error
+3 -1
View File
@@ -1,9 +1,10 @@
import { OAuthScope } from '@supabase/shared-types/out/constants'
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 { oauthAppKeys } from './keys'
import { ResponseError } from 'types'
import { oauthAppKeys } from './keys'
export type OAuthAppsVariables = {
slug?: string
@@ -17,6 +18,7 @@ export type OAuthApp = {
created_at: string
name: string
website: string
scopes: OAuthScope[]
redirect_uris: string[]
}
+1 -2
View File
@@ -7,7 +7,6 @@
"dev:secrets:pull": "AWS_PROFILE=supabase-dev node internals/getSecrets.js",
"build": "next build",
"start": "next start",
"test": "jest",
"lint": "next lint",
"deploy:staging": "VERCEL_ORG_ID=team_E6KJ1W561hMTjon1QSwOh0WO VERCEL_PROJECT_ID=QmcmhbiAtCMFTAHCuGgQscNbke4TzgWULECctNcKmxWCoT vercel --prod -A .vercel/staging.json",
"typecheck": "tsc --noEmit",
@@ -36,7 +35,7 @@
"@supabase/auth-helpers-nextjs": "^0.5.6",
"@supabase/auth-helpers-react": "^0.3.1",
"@supabase/react-data-grid": "^7.1.0-beta.7",
"@supabase/shared-types": "0.1.47",
"@supabase/shared-types": "0.1.51",
"@supabase/supabase-js": "^2.23.0",
"@tanstack/react-query": "^4.22.0",
"@tanstack/react-query-devtools": "^4.22.0",
+2 -1
View File
@@ -158,12 +158,13 @@ const APIAuthorizationPage: NextPageWithLayout = () => {
</div>
}
>
<div className="w-full md:w-[500px] px-8 py-6 space-y-8">
<div className="w-full px-8 py-6 space-y-8">
{/* API Authorization requester details */}
<AuthorizeRequesterDetails
icon={requester.icon}
name={requester.name}
domain={requester.domain}
scopes={requester.scopes}
/>
{/* Expiry warning */}