We tried other vector databases - we tried Faiss, we tried Weaviate, we tried Pinecone. We found @@ -45,18 +46,18 @@ Mendable discovered that Supabase supports pgvector and found it to be a simple ## What They Built -Using Supabase and pgvector, Mendable was able to build a more efficient and accurate search function for their AI chatbot. By storing vector data alongside metadata in Supabase, Mendable was able to quickly and easily search through their customers documentation to find the most relevant responses to queries. They found that Supabase's solution was just as performant as dedicated vector databases, but without the high cost. +Using [Supabase Vector](https://supabase.com/vector), Mendable was able to build a more efficient and accurate search function for their AI chatbot. By storing vector data alongside metadata in Supabase, Mendable was able to quickly and easily search through their customers documentation to find the most relevant responses to queries. They found that Supabase's solution was just as performant as dedicated vector databases, but without the high cost. ## The Results Thanks to Supabase and pgvector, Mendable was able to significantly improve the efficiency and accuracy of their Chat Powered Search for Documentation. They were able to build faster and more cost-effectively using Supabase’s open source stack. -## Tech stack - -Mendable's tech stack includes React, Next.js, Express, Vercel, and Supabase. -We looked at the alternatives and chose Supabase because it’s open source, it’s simpler, and, for all the ways we need use it, Supabase has been just as performant - if not more performant - than the other vector databases.+ +## Tech stack + +Mendable's tech stack includes React, Next.js, Express, Vercel, and Supabase. diff --git a/apps/www/layouts/comparison.tsx b/apps/www/layouts/comparison.tsx index 0d55186d0f6..020d31e2559 100644 --- a/apps/www/layouts/comparison.tsx +++ b/apps/www/layouts/comparison.tsx @@ -72,7 +72,7 @@ const LayoutComparison = ({ components, props }: Props) => { // to do: author urls should be internal in future // currently we have external links to github profiles authors: [props.blog.author_url], - tags: props.blog.tags.map((cat: string) => { + tags: props.blog.tags?.map((cat: string) => { return cat }), }, diff --git a/apps/www/lib/posts.tsx b/apps/www/lib/posts.tsx index c0a7fad040f..82b0ba2df6a 100644 --- a/apps/www/lib/posts.tsx +++ b/apps/www/lib/posts.tsx @@ -78,7 +78,7 @@ export const getSortedPosts = ( if (tags) { sortedPosts = sortedPosts.filter((post: any) => { - const found = tags.some((tag: any) => post.tags.includes(tag)) + const found = tags.some((tag: any) => post.tags?.includes(tag)) return found }) } @@ -151,7 +151,7 @@ export const getAllCategories = (directory: Directories) => { posts.map((post: any) => { // add tags into categories array - post.tags.map((tag: string) => { + post.tags?.map((tag: string) => { if (!categories.includes(tag)) return categories.push(tag) }) }) diff --git a/apps/www/lib/redirects.js b/apps/www/lib/redirects.js index 341fdabd2d4..e41861d6bcf 100644 --- a/apps/www/lib/redirects.js +++ b/apps/www/lib/redirects.js @@ -708,17 +708,17 @@ module.exports = [ { permanent: true, source: '/blog/2020/12/02/case-study-monitoro', - destination: '/blog/case-study-monitoro', + destination: '/customers', }, { permanent: true, source: '/blog/2020/12/02/case-study-tayfa', - destination: '/blog/case-study-tayfa', + destination: '/customers', }, { permanent: true, source: '/blog/2020/12/02/case-study-xendit', - destination: '/blog/case-study-xendit', + destination: '/customers/xendit', }, { permanent: true, @@ -743,7 +743,7 @@ module.exports = [ { permanent: true, source: '/blog/2021/02/09/case-study-roboflow', - destination: '/blog/case-study-roboflow', + destination: '/customers/roboflow', }, { permanent: true, @@ -763,7 +763,7 @@ module.exports = [ { permanent: true, source: '/blog/2021/03/08/toad-a-link-shortener-with-simple-apis-for-low-coders', - destination: '/blog/toad-a-link-shortener-with-simple-apis-for-low-coders', + destination: '/customers', }, { permanent: true, @@ -853,7 +853,7 @@ module.exports = [ { permanent: true, source: '/blog/2021/07/26/epsilon3-self-hosting', - destination: '/blog/epsilon3-self-hosting', + destination: '/customers/epsilon3', }, { permanent: true, @@ -868,7 +868,7 @@ module.exports = [ { permanent: true, source: '/blog/2021/07/27/spot-flutter-with-postgres', - destination: '/blog/spot-flutter-with-postgres', + destination: '/customers', }, { permanent: true, @@ -878,7 +878,7 @@ module.exports = [ { permanent: true, source: '/blog/2021/07/28/mobbin-supabase-200000-users', - destination: '/blog/mobbin-supabase-200000-users', + destination: '/customers/mobbin', }, { permanent: true, @@ -938,7 +938,7 @@ module.exports = [ { permanent: true, source: '/blog/2021/10/19/replenysh-time-to-value-in-less-than-24-hours', - destination: '/blog/replenysh-time-to-value-in-less-than-24-hours', + destination: '/customers/replenysh', }, { permanent: true, @@ -1103,7 +1103,7 @@ module.exports = [ { permanent: true, source: '/blog/2022/05/26/how-supabase-accelerates-development-of-all-pull-together', - destination: '/blog/how-supabase-accelerates-development-of-all-pull-together', + destination: '/customers', }, { permanent: true, @@ -1931,6 +1931,11 @@ module.exports = [ // old case studies moved to /customers + { + permanent: true, + source: '/blog/tags/case-study', + destination: '/customers', + }, { permanent: true, source: '/blog/case-study-monitoro', @@ -1983,8 +1988,8 @@ module.exports = [ }, { permanent: true, - source: '/blog/how-supabase-accelerates-development-of-all-pull-together', - destination: '/customers', + source: '/blog/case-study-happyteams', + destination: '/customers/happyteams', }, { permanent: true, diff --git a/apps/www/pages/blog.tsx b/apps/www/pages/blog.tsx index e5a4e1740c8..be68f700722 100644 --- a/apps/www/pages/blog.tsx +++ b/apps/www/pages/blog.tsx @@ -26,7 +26,7 @@ export async function getStaticProps() { fs.writeFileSync('./public/rss.xml', rss) // generate a series of rss feeds for each author (for PlanetPG) - const planetPgPosts = allPostsData.filter((post: any) => post.tags.includes('planetpg')) + const planetPgPosts = allPostsData.filter((post: any) => post.tags?.includes('planetpg')) const planetPgAuthors = planetPgPosts.map((post: any) => post.author.split(',')) const uniquePlanetPgAuthors = new Set([].concat(...planetPgAuthors)) @@ -73,7 +73,7 @@ function Blog(props: any) { category === 'all' ? shiftedBlogs : props.blogs.filter((post: any) => { - const found = post.tags.includes(category) + const found = post.tags?.includes(category) return found }) ) diff --git a/apps/www/pages/blog/[slug].tsx b/apps/www/pages/blog/[slug].tsx index e58397f1502..1b1fcbd5b8b 100644 --- a/apps/www/pages/blog/[slug].tsx +++ b/apps/www/pages/blog/[slug].tsx @@ -110,7 +110,7 @@ function BlogPostPage(props: any) {{maintainers - .filter((x) => activePill == 'All' || x.tags.includes(activePill)) + .filter((x) => activePill == 'All' || x.tags?.includes(activePill)) .sort((a, b) => a.handle.localeCompare(b.handle)) .map((x, idx) => (diff --git a/apps/www/public/images/blog/native-mobile-auth/apple-setting.png b/apps/www/public/images/blog/native-mobile-auth/apple-setting.png new file mode 100644 index 00000000000..21911530722 Binary files /dev/null and b/apps/www/public/images/blog/native-mobile-auth/apple-setting.png differ diff --git a/apps/www/public/images/blog/native-mobile-auth/google-setting.png b/apps/www/public/images/blog/native-mobile-auth/google-setting.png new file mode 100644 index 00000000000..aa0e2bf5766 Binary files /dev/null and b/apps/www/public/images/blog/native-mobile-auth/google-setting.png differ diff --git a/apps/www/public/images/blog/native-mobile-auth/native-mobile-auth.png b/apps/www/public/images/blog/native-mobile-auth/native-mobile-auth.png new file mode 100644 index 00000000000..32ae2d8710f Binary files /dev/null and b/apps/www/public/images/blog/native-mobile-auth/native-mobile-auth.png differ diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index ae7e6c0c148..28c0406029f 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -243,7 +243,7 @@ services: functions: container_name: supabase-edge-functions - image: supabase/edge-runtime:v1.4.2 + image: supabase/edge-runtime:v1.5.2 restart: unless-stopped environment: JWT_SECRET: ${JWT_SECRET} diff --git a/examples/todo-list/nextjs-todo-list/lib/initSupabase.ts b/examples/todo-list/nextjs-todo-list/lib/initSupabase.ts index 5457bdff41f..7827f47d5a9 100644 --- a/examples/todo-list/nextjs-todo-list/lib/initSupabase.ts +++ b/examples/todo-list/nextjs-todo-list/lib/initSupabase.ts @@ -1,7 +1,6 @@ import { createClient } from '@supabase/supabase-js' -import { Database } from './schema' export const supabase = createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY + process.env.NEXT_PUBLIC_SUPABASE_URL ?? '', + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? '' ) diff --git a/i18n/README.bn.md b/i18n/README.bn.md index 1e1c88ee112..10e03403aa2 100644 --- a/i18n/README.bn.md +++ b/i18n/README.bn.md @@ -43,7 +43,7 @@ - [x] পাবলিক বেটা: বেশিরভাগ নন-এন্টারপ্রাইজ ব্যবহারের ক্ষেত্রে যথেষ্ট স্থিতিশীল - [ ] পাবলিক: প্রোডাকশন রেডি -আমরা বর্তমানে পাবলিক বেটাতে আছি। বড় আপডেটের বিজ্ঞপ্তি পেতে এই রিপুর "রিলিজ" দেখুন। +আমরা বর্তমানে পাবলিক বিটাতে আছি। বড় আপডেটের বিজ্ঞপ্তি পেতে এই রেপোর "রিলিজ" দেখুন।)} - {attribute.key === 'cpu_usage' && ( + {attribute.key === 'max_cpu_usage' && (diff --git a/spec/examples/examples.yml b/spec/examples/examples.yml index 4960cd5d206..38933c31805 100644 --- a/spec/examples/examples.yml +++ b/spec/examples/examples.yml @@ -240,7 +240,7 @@ functions: js: | ```js const { data, error } = await supabase.auth.signInWithOAuth({ - provider: 'github' + provider: 'github', options: { redirectTo: 'https://example.com/welcome' } @@ -255,7 +255,7 @@ functions: js: | ```js const { data, error } = await supabase.auth.signInWithOAuth({ - provider: 'github' + provider: 'github', options: { scopes: 'repo gist notifications' } diff --git a/spec/supabase_js_v2.yml b/spec/supabase_js_v2.yml index a498046b33c..144d9b079b0 100644 --- a/spec/supabase_js_v2.yml +++ b/spec/supabase_js_v2.yml @@ -297,7 +297,7 @@ functions: code: | ```js const { data, error } = await supabase.auth.signInWithOAuth({ - provider: 'github' + provider: 'github', options: { redirectTo: 'https://example.com/welcome' } @@ -312,7 +312,7 @@ functions: code: | ```js const { data, error } = await supabase.auth.signInWithOAuth({ - provider: 'github' + provider: 'github', options: { scopes: 'repo gist notifications' } diff --git a/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx b/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx index e78a998960d..cab31f81782 100644 --- a/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx +++ b/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx @@ -151,19 +151,21 @@ const ProviderForm = ({ provider }: ProviderFormProps) => { )} {provider.misc.requiresRedirect && ( <> -
- {provider.misc.helper} - + {provider.misc.helper} + + } /> > )} diff --git a/studio/components/interfaces/BillingV2/Usage/Infrastructure.tsx b/studio/components/interfaces/BillingV2/Usage/Infrastructure.tsx index 1681ee1cdce..b46e60bd771 100644 --- a/studio/components/interfaces/BillingV2/Usage/Infrastructure.tsx +++ b/studio/components/interfaces/BillingV2/Usage/Infrastructure.tsx @@ -60,7 +60,7 @@ const Infrastructure = ({ const { data: cpuUsageData, isLoading: isLoadingCpuUsageData } = useInfraMonitoringQuery({ projectRef, - attribute: 'cpu_usage', + attribute: 'max_cpu_usage', interval, startDate, endDate, @@ -91,7 +91,7 @@ const Infrastructure = ({ ) const chartMeta: { [key: string]: { data: DataPoint[]; isLoading: boolean } } = { - cpu_usage: { + max_cpu_usage: { isLoading: isLoadingCpuUsageData, data: cpuUsageData?.data ?? [], }, @@ -226,7 +226,7 @@ const Infrastructure = ({Your compute instance has {currentComputeInstanceSpecs.cpu_cores} CPU cores.
diff --git a/studio/components/interfaces/BillingV2/Usage/Usage.constants.tsx b/studio/components/interfaces/BillingV2/Usage/Usage.constants.tsx index c4d2b2c1386..abb971d2db1 100644 --- a/studio/components/interfaces/BillingV2/Usage/Usage.constants.tsx +++ b/studio/components/interfaces/BillingV2/Usage/Usage.constants.tsx @@ -49,11 +49,11 @@ export const USAGE_CATEGORIES: CategoryMeta[] = [ attributes: [ { anchor: 'cpu', - key: 'cpu_usage', - attribute: 'cpu_usage', + key: 'max_cpu_usage', + attribute: 'max_cpu_usage', name: 'CPU', unit: 'percentage', - description: 'CPU usage of your server', + description: 'Max CPU usage of your server', chartDescription: '', links: [ { diff --git a/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx b/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx index 6d5ad116972..522a3fae311 100644 --- a/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx +++ b/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx @@ -17,7 +17,7 @@ import { tryParseJson, uuidv4 } from 'lib/helpers' import { Button, Checkbox, Form, Input, Listbox, Modal, Radio, SidePanel } from 'ui' import HTTPRequestFields from './HTTPRequestFields' import { AVAILABLE_WEBHOOK_TYPES, HOOK_EVENTS } from './Hooks.constants' -import { isValidHttpUrl } from './Hooks.utils' +import { isValidHttpUrl } from 'lib/helpers' export interface EditHookPanelProps { visible: boolean diff --git a/studio/components/interfaces/Database/Hooks/Hooks.utils.ts b/studio/components/interfaces/Database/Hooks/Hooks.utils.ts index 75f0d78c39a..43e8a40c57d 100644 --- a/studio/components/interfaces/Database/Hooks/Hooks.utils.ts +++ b/studio/components/interfaces/Database/Hooks/Hooks.utils.ts @@ -28,13 +28,3 @@ export const convertKeyValue = (value: string) => { export const hasWhitespace = (value: string) => { return /\s/.test(value) } - -export const isValidHttpUrl = (value: string) => { - let url: URL - try { - url = new URL(value) - } catch (_) { - return false - } - return url.protocol === 'http:' || url.protocol === 'https:' -} diff --git a/studio/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails.tsx b/studio/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails.tsx new file mode 100644 index 00000000000..16d4edac3f7 --- /dev/null +++ b/studio/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails.tsx @@ -0,0 +1,33 @@ +export interface AuthorizeRequesterDetailsProps { + icon: string | null + name: string + domain: string +} + +const AuthorizeRequesterDetails = ({ icon, name, domain }: AuthorizeRequesterDetailsProps) => { + return ( +++ ) +} + +export default AuthorizeRequesterDetails diff --git a/studio/components/interfaces/Organization/OAuthApps/AuthorizedAppRow.tsx b/studio/components/interfaces/Organization/OAuthApps/AuthorizedAppRow.tsx new file mode 100644 index 00000000000..d8b4360ef66 --- /dev/null +++ b/studio/components/interfaces/Organization/OAuthApps/AuthorizedAppRow.tsx @@ -0,0 +1,57 @@ +import Table from 'components/to-be-cleaned/Table' +import { AuthorizedApp } from 'data/oauth/authorized-apps-query' +import dayjs from 'dayjs' +import { copyToClipboard } from 'lib/helpers' +import { useState } from 'react' +import { Button, IconCheck, IconClipboard, IconTrash } from 'ui' + +export interface AuthorizedAppRowProps { + app: AuthorizedApp + onSelectRevoke: () => void +} + +const AuthorizedAppRow = ({ app, onSelectRevoke }: AuthorizedAppRowProps) => { + const [isCopied, setIsCopied] = useState(false) + + return ( ++++++ {icon === null &&+{name[0]}
} ++ {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. + +
++ + ) +} + +export default AuthorizedAppRow diff --git a/studio/components/interfaces/Organization/OAuthApps/DeleteAppModal.tsx b/studio/components/interfaces/Organization/OAuthApps/DeleteAppModal.tsx new file mode 100644 index 00000000000..d0e3ee7b9e0 --- /dev/null +++ b/studio/components/interfaces/Organization/OAuthApps/DeleteAppModal.tsx @@ -0,0 +1,72 @@ +import { useParams } from 'common' +import { useOAuthAppDeleteMutation } from 'data/oauth/oauth-app-delete-mutation' +import { OAuthApp } from 'data/oauth/oauth-apps-query' +import { useStore } from 'hooks' +import { Alert, IconLock, Modal } from 'ui' + +export interface DeleteAppModalProps { + selectedApp?: OAuthApp + onClose: () => void +} + +const DeleteAppModal = ({ selectedApp, onClose }: DeleteAppModalProps) => { + const { ui } = useStore() + const { slug } = useParams() + const { mutateAsync: deleteOAuthApp, isLoading: isDeleting } = useOAuthAppDeleteMutation() + + const onConfirmDelete = async () => { + if (!slug) return console.error('Slug is required') + if (!selectedApp?.id) return console.error('App ID is required') + + try { + await deleteOAuthApp({ slug, id: selectedApp?.id }) + ui.setNotification({ + category: 'success', + message: `Successfully deleted the app "${selectedApp?.name}"`, + }) + onClose() + } catch (error) { + ui.setNotification({ + category: 'error', + message: `Failed to delete OAuth app: ${(error as any).message}`, + }) + } + } + + return ( ++ ++ {!!app.icon ? '' : `${app.name[0]}`} ++{app.name} ++ {app.id} + : ++ } + className="ml-2 px-1" + onClick={() => { + copyToClipboard(app.id) + setIsCopied(true) + setTimeout(() => { + setIsCopied(false) + }, 3000) + }} + /> + {dayjs(app.authorized_at).format('DD/MM/YYYY, HH:mm:ss')} ++ } + className="px-1" + onClick={() => onSelectRevoke()} + /> + ++ + ) +} + +export default DeleteAppModal diff --git a/studio/components/interfaces/Organization/OAuthApps/OAuthAppRow.tsx b/studio/components/interfaces/Organization/OAuthApps/OAuthAppRow.tsx new file mode 100644 index 00000000000..52765963ce2 --- /dev/null +++ b/studio/components/interfaces/Organization/OAuthApps/OAuthAppRow.tsx @@ -0,0 +1,79 @@ +import Table from 'components/to-be-cleaned/Table' +import { OAuthApp } from 'data/oauth/oauth-apps-query' +import dayjs from 'dayjs' +import { copyToClipboard } from 'lib/helpers' +import { useState } from 'react' +import { + Button, + Dropdown, + IconCheck, + IconClipboard, + IconEdit, + IconMoreVertical, + IconTrash, +} from 'ui' + +export interface OAuthAppRowProps { + app: OAuthApp + onSelectEdit: () => void + onSelectDelete: () => void +} + +const OAuthAppRow = ({ app, onSelectEdit, onSelectDelete }: OAuthAppRowProps) => { + const [isCopied, setIsCopied] = useState(false) + + return ( ++ ++++ Deleting {selectedApp?.name} will invalidate any access tokens from this application + that were authorized by users. + ++
+- +
++ + Before you remove this application, consider: +++
+- + No users are currently using this application. It will no longer be available + for use after deletion. +
++ + ) +} + +export default OAuthAppRow diff --git a/studio/components/interfaces/Organization/OAuthApps/OAuthApps.tsx b/studio/components/interfaces/Organization/OAuthApps/OAuthApps.tsx new file mode 100644 index 00000000000..416679f43e1 --- /dev/null +++ b/studio/components/interfaces/Organization/OAuthApps/OAuthApps.tsx @@ -0,0 +1,230 @@ +import { useState } from 'react' + +import { useParams } from 'common' +import Table from 'components/to-be-cleaned/Table' +import AlertError from 'components/ui/AlertError' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import { OAuthAppCreateResponse } from 'data/oauth/oauth-app-create-mutation' +import { OAuthApp, useOAuthAppsQuery } from 'data/oauth/oauth-apps-query' +import { Alert, Button, IconX, Input } from 'ui' +import DeleteAppModal from './DeleteAppModal' +import OAuthAppRow from './OAuthAppRow' +import PublishAppModal from './PublishAppSidePanel' +import { AuthorizedApp, useAuthorizedAppsQuery } from 'data/oauth/authorized-apps-query' +import AuthorizedAppRow from './AuthorizedAppRow' +import RevokeAppModal from './RevokeAppModal' + +// [Joshen] Note on nav UX +// Kang Ming mentioned that it might be better to split Published Apps and Authorized Apps into 2 separate tabs +// to prevent any confusion (case study: Github). Authorized apps could be in the "integrations" tab, but let's +// check in again after we wrap up Vercel integration + +const OAuthApps = () => { + const { slug } = useParams() + const [createdApp, setCreatedApp] = useState+ ++ {!!app.icon ? '' : `${app.name[0]}`} ++{app.name} ++ {app.client_id} + : ++ } + className="ml-2 px-1" + onClick={() => { + copyToClipboard(app.client_id) + setIsCopied(true) + setTimeout(() => { + setIsCopied(false) + }, 3000) + }} + /> + + {app.client_secret_alias}... + +{dayjs(app.created_at).format('DD/MM/YYYY, HH:mm:ss')} ++ +} onClick={() => onSelectEdit()}> + Edit app + , + , + } onClick={() => onSelectDelete()}> + Delete app + , + ]} + > + } className="px-1" /> + + () + const [showPublishModal, setShowPublishModal] = useState(false) + const [selectedAppToUpdate, setSelectedAppToUpdate] = useState () + const [selectedAppToDelete, setSelectedAppToDelete] = useState () + const [selectedAppToRevoke, setSelectedAppToRevoke] = useState () + + const { + data: publishedApps, + isLoading: isLoadingPublishedApps, + isSuccess: isSuccessPublishedApps, + isError: isErrorPublishedApps, + } = useOAuthAppsQuery({ slug }) + + const sortedPublishedApps = publishedApps?.sort((a, b) => { + return Number(new Date(a.created_at)) - Number(new Date(b.created_at)) + }) + + const { + data: authorizedApps, + isLoading: isLoadingAuthorizedApps, + isSuccess: isSuccessAuthorizedApps, + isError: isErrorAuthorizedApps, + } = useAuthorizedAppsQuery({ slug }) + + const sortedAuthorizedApps = authorizedApps?.sort((a, b) => { + return Number(new Date(a.authorized_at)) - Number(new Date(b.authorized_at)) + }) + + return ( + <> + ++++ +++ +Published Apps
++ Build integrations that extend Supabase's functionality +
++ {isLoadingPublishedApps && ( +++ )} + + {isErrorPublishedApps && ( ++ + + + )} + + {createdApp !== undefined && ( + + + )} + + {isSuccessPublishedApps && ( + <> + {(publishedApps?.length ?? 0) === 0 ? ( ++ } + className="px-1" + onClick={() => setCreatedApp(undefined)} + /> +++++ Ensure that you store the client secret securely - you will not be able to see + it again. +
++ {}} + /> + {}} + /> ++++ ) : ( +You do not have any published applications yet
+, +
Name , +Client ID , +Client Secret , +Created at , +, + ]} + body={ + sortedPublishedApps?.map((app) => ( + { + setShowPublishModal(true) + setSelectedAppToUpdate(app) + }} + onSelectDelete={() => setSelectedAppToDelete(app)} + /> + )) ?? [] + } + /> + )} + > + )} + + + + +Authorized Apps
++ Applications that have access to your organization's settings and projects +
+ ++ {isLoadingAuthorizedApps && ( +++ )} + + {isErrorAuthorizedApps &&+ + + } + + {isSuccessAuthorizedApps && ( + <> + {(authorizedApps.length ?? 0) === 0 ? ( + ++ ) : ( +You do not have any authorized applications yet
+, +
Name , +ID , +Authorized at , +, + ]} + body={ + sortedAuthorizedApps?.map((app) => ( + setSelectedAppToRevoke(app)} + /> + )) ?? [] + } + /> + )} + > + )} + + + + + { + setSelectedAppToUpdate(undefined) + setShowPublishModal(false) + }} + onCreateSuccess={setCreatedApp} + /> + setSelectedAppToDelete(undefined)} + /> + setSelectedAppToRevoke(undefined)} + /> + > + ) +} + +export default OAuthApps diff --git a/studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel.tsx b/studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel.tsx new file mode 100644 index 00000000000..43eaa6e9044 --- /dev/null +++ b/studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel.tsx @@ -0,0 +1,397 @@ +import clsx from 'clsx' +import { ChangeEvent, useEffect, useRef, useState } from 'react' + +import { useParams } from 'common' +import { + OAuthAppCreateResponse, + useOAuthAppCreateMutation, +} from 'data/oauth/oauth-app-create-mutation' +import { useOAuthAppUpdateMutation } from 'data/oauth/oauth-app-update-mutation' +import { OAuthApp } from 'data/oauth/oauth-apps-query' +import { useStore } from 'hooks' +import { isValidHttpUrl, uuidv4 } from 'lib/helpers' +import { Badge, Button, Dropdown, Form, IconEdit, IconUpload, Input, Modal, SidePanel } from 'ui' +import { uploadAttachment } from 'lib/upload' +import AuthorizeRequesterDetails from './AuthorizeRequesterDetails' + +export interface PublishAppModalProps { + visible: boolean + selectedApp?: OAuthApp + onClose: () => void + onCreateSuccess: (app: OAuthAppCreateResponse) => void +} + +const PublishAppModal = ({ + visible, + selectedApp, + onClose, + onCreateSuccess, +}: PublishAppModalProps) => { + const { ui } = useStore() + const { slug } = useParams() + const uploadButtonRef = useRef () + const { mutateAsync: createOAuthApp } = useOAuthAppCreateMutation() + const { mutateAsync: updateOAuthApp } = useOAuthAppUpdateMutation() + + const [showPreview, setShowPreview] = useState(false) + const [iconFile, setIconFile] = useState () + const [iconUrl, setIconUrl] = useState () + const [errors, setErrors] = useState<{ [key: string]: string }>({}) + const [urls, setUrls] = useState<{ id: string; value: string }[]>([{ id: uuidv4(), value: '' }]) + + useEffect(() => { + if (visible) { + setErrors({}) + setIconFile(undefined) + + if (selectedApp !== undefined) { + setUrls( + selectedApp.redirect_uris.map((url) => { + return { id: uuidv4(), value: url } + }) + ) + setIconUrl(selectedApp.icon === null ? undefined : selectedApp.icon) + } else { + setUrls([{ id: uuidv4(), value: '' }]) + setIconUrl(undefined) + } + } + }, [visible, selectedApp]) + + const onUpdateUrl = (id: string, value: string) => { + const updatedUrls = urls.map((url) => { + if (url.id === id) return { id, value } + else return url + }) + setUrls(updatedUrls) + setErrors({}) + } + + const removeUrl = (id: string) => { + const updatedUrls = urls.filter((url) => url.id !== id) + setUrls(updatedUrls) + } + + const onFileUpload = async (event: ChangeEvent ) => { + event.persist() + const [file] = event.target.files || (event as any).dataTransfer.items + setIconFile(file) + setIconUrl(URL.createObjectURL(file)) + event.target.value = '' + } + + const validate = (values: any) => { + const errors: any = {} + if (!values.name) errors.name = 'Please provide a name for your application' + if (!values.website) errors.website = 'Please provide a URL for your site' + if (!isValidHttpUrl(values.website)) errors.website = 'Please provide a valid URL for your site' + return errors + } + + const onSubmit = async (values: any, { setSubmitting }: any) => { + if (!slug) return console.error('Slug is required') + + const redirect_uris = urls.filter((url) => url.value.length > 0).map((url) => url.value) + if (redirect_uris.length === 0) { + setSubmitting(false) + setErrors({ urls: 'Please provide at least one URL' }) + return + } else { + setErrors({}) + } + + const { name, website } = values + const uploadedIconUrl = + iconFile !== undefined + ? await uploadAttachment('oauth-app-icons', `${slug}/${uuidv4()}.png`, iconFile) + : iconUrl + + if (selectedApp === undefined) { + // Create application + try { + const res = await createOAuthApp({ + slug, + name, + website, + redirect_uris, + icon: uploadedIconUrl, + }) + ui.setNotification({ + category: 'success', + message: `Successfully created OAuth app "${name}"!`, + }) + onClose() + onCreateSuccess(res) + } catch (error: any) { + setSubmitting(false) + ui.setNotification({ + category: 'error', + message: `Failed to create OAuth app: ${error.message}`, + }) + } + } else { + // Update application + try { + await updateOAuthApp({ + id: selectedApp.id, + slug, + name, + website, + redirect_uris, + icon: uploadedIconUrl === undefined ? null : uploadedIconUrl, + }) + ui.setNotification({ + category: 'success', + message: `Successfully updated OAuth app "${name}"!`, + }) + onClose() + } catch (error: any) { + setSubmitting(false) + ui.setNotification({ + category: 'error', + message: `Failed to update OAuth app: ${error.message}`, + }) + } + } + } + + return ( + <> + onClose()} + > + + + > + ) +} + +export default PublishAppModal diff --git a/studio/components/interfaces/Organization/OAuthApps/RevokeAppModal.tsx b/studio/components/interfaces/Organization/OAuthApps/RevokeAppModal.tsx new file mode 100644 index 00000000000..7d49e8d54c0 --- /dev/null +++ b/studio/components/interfaces/Organization/OAuthApps/RevokeAppModal.tsx @@ -0,0 +1,73 @@ +import { useParams } from 'common' +import { useAuthorizedAppRevokeMutation } from 'data/oauth/authorized-app-revoke-mutation' +import { AuthorizedApp } from 'data/oauth/authorized-apps-query' +import { useStore } from 'hooks' +import { Alert, IconAlertOctagon, IconLock, Modal } from 'ui' + +export interface RevokeAppModalProps { + selectedApp?: AuthorizedApp + onClose: () => void +} + +const RevokeAppModal = ({ selectedApp, onClose }: RevokeAppModalProps) => { + const { ui } = useStore() + const { slug } = useParams() + const { mutateAsync: revokeAuthorizedApp, isLoading: isDeleting } = + useAuthorizedAppRevokeMutation() + + const onConfirmDelete = async () => { + if (!slug) return console.error('Slug is required') + if (!selectedApp?.id) return console.error('App ID is required') + + try { + await revokeAuthorizedApp({ slug, id: selectedApp?.id }) + ui.setNotification({ + category: 'success', + message: `Successfully revoked the app "${selectedApp?.name}"`, + }) + onClose() + } catch (error) { + ui.setNotification({ + category: 'error', + message: `Failed to revoke app: ${(error as any).message}`, + }) + } + } + + return ( ++ + ) +} + +export default RevokeAppModal diff --git a/studio/components/interfaces/Organization/index.ts b/studio/components/interfaces/Organization/index.ts index beee07cbc43..2c02467f0f9 100644 --- a/studio/components/interfaces/Organization/index.ts +++ b/studio/components/interfaces/Organization/index.ts @@ -3,5 +3,6 @@ import TeamSettings from './TeamSettings/TeamSettings' import BillingSettings from './BillingSettings/BillingSettings' import InvoicesSettings from './InvoicesSettings/InvoicesSettings' import NewOrgForm from './NewOrg/NewOrgForm' +import OAuthApps from './OAuthApps/OAuthApps' -export { GeneralSettings, TeamSettings, BillingSettings, InvoicesSettings, NewOrgForm } +export { GeneralSettings, TeamSettings, BillingSettings, InvoicesSettings, NewOrgForm, OAuthApps } diff --git a/studio/components/interfaces/Settings/Logs/Logs.utils.ts b/studio/components/interfaces/Settings/Logs/Logs.utils.ts index 006f78c3ff9..f8ff4d249a1 100644 --- a/studio/components/interfaces/Settings/Logs/Logs.utils.ts +++ b/studio/components/interfaces/Settings/Logs/Logs.utils.ts @@ -234,7 +234,7 @@ export const genChartQuery = ( const where = _genWhereStatement(table, filters) let joins = 'cross join unnest(t.metadata) as metadata' - if (table === LogsTableName.EDGE) { + if (table === LogsTableName.EDGE || table === LogsTableName.FN_EDGE) { joins += ' \n cross join unnest(metadata.request) as request' joins += ' \n cross join unnest(metadata.response) as response' } else if (table === LogsTableName.POSTGRES) { diff --git a/studio/components/interfaces/SignIn/SignInForm.tsx b/studio/components/interfaces/SignIn/SignInForm.tsx index 4d20f3fa307..cd8be134729 100644 --- a/studio/components/interfaces/SignIn/SignInForm.tsx +++ b/studio/components/interfaces/SignIn/SignInForm.tsx @@ -37,7 +37,7 @@ const SignInForm = () => { const signInClicks = incrementSignInClicks() if (signInClicks > 1) { - Sentry.captureMessage('Sign in without previous sing out detected') + Sentry.captureMessage('Sign in without previous sign out detected') } const { error } = await auth.signInWithPassword({ diff --git a/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx b/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx index d23ab9333c0..c83802db398 100644 --- a/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx +++ b/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx @@ -83,19 +83,20 @@ const SideBarContent = observer(() => {+ ++++ {selectedApp?.name} application will no longer have access to your organization's + settings and projects. + ++
+- +
++ + Before you remove this app, consider: +++
+- + No users are currently using this application. The application will no longer + have access to your organization after being revoked. +
+