From dbccdf8bebaae2b4e6e37f98a89a5259ac8f1d5a Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Tue, 17 Oct 2023 07:07:02 +0200 Subject: [PATCH] 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 --- package-lock.json | 12 +- .../OAuthApps/AuthorizeRequesterDetails.tsx | 119 +++++++++++- .../OAuthApps/PublishAppSidePanel/Scopes.tsx | 183 ++++++++++++++++++ .../index.tsx} | 38 +++- .../layouts/APIAuthorizationLayout.tsx | 8 +- .../api-authorization-query.ts | 2 + .../data/oauth/oauth-app-create-mutation.ts | 8 +- .../data/oauth/oauth-app-update-mutation.ts | 4 + studio/data/oauth/oauth-apps-query.ts | 4 +- studio/package.json | 3 +- studio/pages/authorize.tsx | 3 +- 11 files changed, 356 insertions(+), 28 deletions(-) create mode 100644 studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel/Scopes.tsx rename studio/components/interfaces/Organization/OAuthApps/{PublishAppSidePanel.tsx => PublishAppSidePanel/index.tsx} (89%) diff --git a/package-lock.json b/package-lock.json index 9db6b170ae6..4ab022be6d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/studio/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails.tsx b/studio/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails.tsx index 6fc508b5974..c826d2555ce 100644 --- a/studio/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails.tsx +++ b/studio/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails.tsx @@ -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) => ( + + {str} + + )) + .reduce((acc, v) => ( + <> + {acc} + and + {v} + + )) + + return ( +
+
+ +
+
+ {perms} {description} +
+
+ ) + } + return null +} + +const AuthorizeRequesterDetails = ({ + icon, + name, + domain, + scopes, +}: AuthorizeRequesterDetailsProps) => { return ( -
-
+
+
{name[0]}

}
+

+ {name} ({domain}) is requesting API access to an organization. +

+
+
+

Permissions

+

+ The following scopes will apply for the{' '} + selected organization and all of its projects. +

+
+ + + + + + + + + +
-

- {name} ({domain}) is requesting API access to an organization. The application will be able - to{' '} - - read and write the organization's settings and all of its projects. - -

) } diff --git a/studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel/Scopes.tsx b/studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel/Scopes.tsx new file mode 100644 index 00000000000..8116d3bffe6 --- /dev/null +++ b/studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel/Scopes.tsx @@ -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 ( + { + if (checked) { + onChange([...scopes, scopeName]) + } else { + onChange([...scopes.filter((s) => s !== scopeName)]) + } + }} + > + {children} + + ) +} + +const Scope = ({ + title, + description, + readScopeName, + writeScopeName, + scopes, + setScopes, +}: { + title: string + description: string + readScopeName: OAuthScope + writeScopeName: OAuthScope + scopes: OAuthScope[] + setScopes: Dispatch> +}) => { + 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 ( +
+
+ {title} + {description} +
+ + + + + + Select an access level + + + Read + + + Write + + + +
+ ) +} + +export const ScopesPanel = ({ + scopes, + setScopes, +}: { + scopes: OAuthScope[] + setScopes: Dispatch> +}) => { + return ( +
+ + + + + + + + + +
+ ) +} diff --git a/studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel.tsx b/studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel/index.tsx similarity index 89% rename from studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel.tsx rename to studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel/index.tsx index 24171c32dff..1148d48f5f5 100644 --- a/studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel.tsx +++ b/studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel/index.tsx @@ -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() const [errors, setErrors] = useState<{ [key: string]: string }>({}) const [urls, setUrls] = useState<{ id: string; value: string }[]>([{ id: uuidv4(), value: '' }]) + const [scopes, setScopes] = useState([]) 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 = ({
+
+
+
+ Application permissions + + 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. + +
+ + + +
+ + +
@@ -359,7 +392,7 @@ const PublishAppSidePanel = ({ setShowPreview(false)} > @@ -376,6 +409,7 @@ const PublishAppSidePanel = ({ icon={iconUrl || null} name={values.name} domain={values.website} + scopes={scopes} />

diff --git a/studio/components/layouts/APIAuthorizationLayout.tsx b/studio/components/layouts/APIAuthorizationLayout.tsx index c517fbcbce3..ad7e43de299 100644 --- a/studio/components/layouts/APIAuthorizationLayout.tsx +++ b/studio/components/layouts/APIAuthorizationLayout.tsx @@ -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

-
+
{children}
diff --git a/studio/data/api-authorization/api-authorization-query.ts b/studio/data/api-authorization/api-authorization-query.ts index 837738e0045..506461f479b 100644 --- a/studio/data/api-authorization/api-authorization-query.ts +++ b/studio/data/api-authorization/api-authorization-query.ts @@ -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 diff --git a/studio/data/oauth/oauth-app-create-mutation.ts b/studio/data/oauth/oauth-app-create-mutation.ts index 21fb099ff1d..a2241701781 100644 --- a/studio/data/oauth/oauth-app-create-mutation.ts +++ b/studio/data/oauth/oauth-app-create-mutation.ts @@ -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 diff --git a/studio/data/oauth/oauth-app-update-mutation.ts b/studio/data/oauth/oauth-app-update-mutation.ts index 453b0647e08..4e53c1e1367 100644 --- a/studio/data/oauth/oauth-app-update-mutation.ts +++ b/studio/data/oauth/oauth-app-update-mutation.ts @@ -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 diff --git a/studio/data/oauth/oauth-apps-query.ts b/studio/data/oauth/oauth-apps-query.ts index 5dc6a229ce1..78247b54dd1 100644 --- a/studio/data/oauth/oauth-apps-query.ts +++ b/studio/data/oauth/oauth-apps-query.ts @@ -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[] } diff --git a/studio/package.json b/studio/package.json index af246b8ca65..6a475825b79 100644 --- a/studio/package.json +++ b/studio/package.json @@ -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", diff --git a/studio/pages/authorize.tsx b/studio/pages/authorize.tsx index a6b134030f6..6183ac81c1d 100644 --- a/studio/pages/authorize.tsx +++ b/studio/pages/authorize.tsx @@ -158,12 +158,13 @@ const APIAuthorizationPage: NextPageWithLayout = () => {
} > -
+
{/* API Authorization requester details */} {/* Expiry warning */}