diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 839bd310041..b7074fd4b1a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2,7 +2,9 @@ /packages/shared-data/plans.ts @roryw10 @kevcodez /apps/studio/ @supabase/Dashboard -/apps/www/ @supabase/Website -/apps/www/_blog @supabase/marketing + +/apps/www/ @supabase/marketing /apps/www/public/images/blog @supabase/marketing +/apps/www/lib/redirects.js + /docker/ @supabase/cli diff --git a/.github/workflows/docs-tests-smoke.yml b/.github/workflows/docs-tests-smoke.yml new file mode 100644 index 00000000000..0ebf7512c19 --- /dev/null +++ b/.github/workflows/docs-tests-smoke.yml @@ -0,0 +1,35 @@ +# Smoke tests against the docs production site, run daily + +name: Docs Production Smoke Tests + +on: + workflow_dispatch: + schedule: + - cron: '0 4 * * *' + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: | + apps/docs + packages + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: Install deps + run: npm ci + + - name: Run tests + run: npm --prefix="apps/docs" run test:smoke diff --git a/apps/docs/app/api/crawlers/route.smoke.test.ts b/apps/docs/app/api/crawlers/route.smoke.test.ts new file mode 100644 index 00000000000..e2224180cec --- /dev/null +++ b/apps/docs/app/api/crawlers/route.smoke.test.ts @@ -0,0 +1,72 @@ +import { load } from 'cheerio' +import { describe, expect, it } from 'vitest' + +const REFERENCE_DOCS_URL = 'https://supabase.com/docs/reference' +// For dev testing: comment out above and uncomment below +// const REFERENCE_DOCS_URL = 'http://localhost:3001/docs/reference' + +describe('prod smoke test: crawler pages return correct data', () => { + /** + * No special tricks required to spoof the user agent. Tests are correctly + * detected as coming from bots. If they ever aren't, the `h1` test will fail + * as a different `h1` is served to non-bots. + */ + + it('metadata: title, description, canonical, image', async () => { + const result = await fetch(REFERENCE_DOCS_URL + '/javascript/rangelte') + const text = await result.text() + + const $ = load(text) + const title = $('title').text() + expect(title).toBe('JavaScript: Less than or equal to a range | Supabase Docs') + + const metaDescription = $('meta[name="description"]') + expect(metaDescription.attr('content')).toBe( + 'Supabase API reference for JavaScript: Less than or equal to a range' + ) + + const canonical = $('link[rel="canonical"]') + expect(canonical.attr('href')).toBe('https://supabase.com/docs/reference/javascript/rangelte') + + const ogImage = $('meta[name="og:image"]') + expect(ogImage.attr('content')).toBe('https://supabase.com/docs/img/supabase-og-image.png') + + const twitterImage = $('meta[name="twitter:image"]') + expect(twitterImage.attr('content')).toBe('https://supabase.com/docs/img/supabase-og-image.png') + }) + + it('markdown pages', async () => { + const result = await fetch(REFERENCE_DOCS_URL + '/javascript/introduction') + const text = await result.text() + + const $ = load(text) + const h1 = $('h1').text() + expect(h1).toBe('JavaScript: Introduction') + + const firstPara = $('h1').next().text() + expect(/JavaScript library/.test(firstPara)).toBe(true) + expect(/supabase-js/.test(firstPara)).toBe(true) + }) + + it('function pages', async () => { + const result = await fetch(REFERENCE_DOCS_URL + '/javascript/rangelte') + const text = await result.text() + + const $ = load(text) + const h1 = $('h1').text() + expect(h1).toBe('JavaScript: Less than or equal to a range') + + const description = $('h1').next().text() + expect(description).toBe( + 'Only relevant for range columns. Match only rows where every element in column is either contained in range or less than any element in range.' + ) + + const headings = [] as Array + $('h2').map(function () { + headings.push($(this).attr('id')) + }) + + expect(headings.includes('parameters')).toBe(true) + expect(headings.includes('examples')).toBe(true) + }) +}) diff --git a/apps/docs/app/api/crawlers/route.ts b/apps/docs/app/api/crawlers/route.ts new file mode 100644 index 00000000000..51850fa26e3 --- /dev/null +++ b/apps/docs/app/api/crawlers/route.ts @@ -0,0 +1,240 @@ +import { toHtml } from 'hast-util-to-html' +import { fromMarkdown } from 'mdast-util-from-markdown' +import { mdxFromMarkdown } from 'mdast-util-mdx' +import { toHast } from 'mdast-util-to-hast' +import { mdxjs } from 'micromark-extension-mdxjs' +import { redirect } from 'next/navigation' +import { visit } from 'unist-util-visit' + +import { REFERENCES } from '~/content/navigation.references' +import { + getFlattenedSections, + getFunctionsList, + getTypeSpec, +} from '~/features/docs/Reference.generated.singleton' +import { getRefMarkdown } from '~/features/docs/Reference.mdx' +import type { MethodTypes } from '~/features/docs/Reference.typeSpec' +import type { AbbrevCommonClientLibSection } from '~/features/docs/Reference.utils' +import { notFoundLink } from '~/features/recommendations/NotFound.utils' +import { BASE_PATH } from '~/lib/constants' + +export async function GET(request: Request) { + const url = new URL(request.url) + let [, , lib, maybeVersion, slug] = url.pathname.split('/') + + const libraryMeta = REFERENCES[lib] + + const isVersion = /^v\d+$/.test(maybeVersion) + const version = isVersion ? maybeVersion : libraryMeta.versions[0] + if (!isVersion) { + slug = maybeVersion + } + + let section: AbbrevCommonClientLibSection + let sectionsWithUrl: Array + try { + const flattenedSections = (await getFlattenedSections(lib, version)) ?? [] + sectionsWithUrl = flattenedSections.map((section) => { + const url = new URL(request.url) + url.pathname = [BASE_PATH, 'reference', lib, isVersion ? version : null, section.slug] + .filter(Boolean) + .join('/') + + return { + ...section, + url, + } + }) + section = flattenedSections.find( + (section) => + (section.type === 'markdown' || section.type === 'function') && section.slug === slug + ) + } catch {} + + if (!section) { + redirect(notFoundLink(`${lib}/${slug}`)) + } + + const html = htmlShell( + lib, + isVersion ? version : null, + slug, + section, + libraryNav(sectionsWithUrl) + (await sectionDetails(lib, isVersion ? version : null, section)) + ) + const response = new Response(html) + response.headers.set('Content-Type', 'text/html; charset=utf-8') + + return response +} + +function htmlShell( + lib: string, + version: string | null, + slug: string, + section: AbbrevCommonClientLibSection, + body: string +) { + const libraryName = REFERENCES[lib].name + let title = libraryName + ': ' + section.title ?? '' + + return ( + '' + + '' + + `${title} | Supabase Docs` + + `` + + `` + + `` + + `` + + '' + + '' + + body + + '' + ) +} + +function libraryNav(sections: Array) { + return ( + '' + ) +} + +async function sectionDetails(lib: string, version: string, section: AbbrevCommonClientLibSection) { + const libraryName = REFERENCES[lib].name + let result = '

' + (libraryName + ': ' + section.title ?? '') + '

' + + if (section.type === 'markdown') { + result += await markdown(lib, version, section) + } else { + result += await functionDetails(lib, version, section) + } + + return result +} + +async function markdown( + lib: string, + version: string | null, + section: AbbrevCommonClientLibSection +) { + const dir = !!section.meta?.shared ? 'shared' : lib + (version ? '/' + version : '') + + let content = await getRefMarkdown(dir + '/' + section.slug) + content = mdxToHtml(content) + return content +} + +async function functionDetails( + lib: string, + version: string | null, + section: AbbrevCommonClientLibSection +) { + const libraryMeta = REFERENCES[lib] + + const fns = await getFunctionsList(lib, version ?? libraryMeta.versions[0]) + const fn = fns!.find((fn) => fn.id === section.id) + if (!fn) return '' + + let types: MethodTypes | undefined + if (libraryMeta.typeSpec && '$ref' in fn) { + types = await getTypeSpec(fn['$ref'] as string) + } + + const fullDescription = [ + types?.comment?.shortText, + 'description' in fn && (fn.description as string), + 'notes' in fn && (fn.notes as string), + ] + .filter((x) => typeof x === 'string') + .map(mdxToHtml) + .join('') + + const parameters = parametersToHtml(fn, types) + const examples = examplesToHtml(fn) + + return fullDescription + parameters + examples +} + +function mdxToHtml(markdownUnescaped: string): string { + const markdown = markdownUnescaped.replace(/(? { + node.value = node.value.replace(/\n/g, ' ') + }) + if (!mdast) return '' + + const hast = toHast(mdast) + if (!hast) return '' + + // @ts-ignore + const html = toHtml(hast) + + return html +} + +function parametersToHtml(fn: any, types: MethodTypes | undefined) { + let result = '

Parameters

' + + if ('overwriteParams' in fn || 'params' in fn) { + const params = fn.overwriteParams ?? fn.params + if (params.length === 0) return '' + + result += + '
    ' + + params + .map( + (param) => + '
  • ' + + `

    ${param.name}

    ` + + `${param.isOptional ? '(Optional)' : '(Required)'}` + + `

    ${param.description}

    ` + + '
  • ' + ) + .join('') + + '
' + + return result + } + + if (!types?.params || types.params.length === 0) return '' + + result += + '
    ' + + types.params + .map( + (param) => + '
  • ' + + `

    ${String(param.name)}

    ` + + `${param.isOptional ? '(Optional)' : '(Required)'}` + + `

    ${param.comment?.shortText ?? ''}

    ` + + '
  • ' + ) + .join('') + + '
' + + return result +} + +function examplesToHtml(fn: any) { + if (!fn.examples || fn.examples.length === 0) return '' + + let result = '

Examples

' + + result += fn.examples + .map((example) => `

${example.name ?? ''}

` + mdxToHtml(example.code ?? '')) + .join('') + + return result +} diff --git a/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx b/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx index 7bf3c87876b..16a6e7ce50e 100644 --- a/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx +++ b/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx @@ -1,4 +1,7 @@ import matter from 'gray-matter' +import { type Heading } from 'mdast' +import { fromMarkdown } from 'mdast-util-from-markdown' +import { toMarkdown } from 'mdast-util-to-markdown' import { type SerializeOptions } from 'next-mdx-remote/dist/types' import { readFile } from 'node:fs/promises' import { join, relative } from 'node:path' @@ -13,6 +16,7 @@ import { UrlTransformFunction, linkTransform } from '~/lib/mdx/plugins/rehypeLin import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition' import { removeTitle } from '~/lib/mdx/plugins/remarkRemoveTitle' import remarkPyMdownTabs from '~/lib/mdx/plugins/remarkTabs' +import remarkGfm from 'remark-gfm' // We fetch these docs at build time from an external repo const org = 'supabase' @@ -175,8 +179,27 @@ const getContent = async (params: Params) => { ;({ remoteFile, meta } = federatedPage) const repoPath = `${org}/${repo}/${branch}/${docsDir}/${remoteFile}` editLink = `${org}/${repo}/blob/${branch}/${docsDir}/${remoteFile}` + const response = await fetchRevalidatePerDay(`https://raw.githubusercontent.com/${repoPath}`) - content = await response.text() + const rawContent = await response.text() + + const { content: contentWithoutFrontmatter } = matter(rawContent) + + // This is the more robust way of doing it, but problems with the rewritten + // Markdown and handling of tables this way, so saving it for later. + // + // const mdxTree = fromMarkdown(contentWithoutFrontmatter) + // const maybeH1 = mdxTree.children[0] + // if (maybeH1 && maybeH1.type === 'heading' && (maybeH1 as Heading).depth === 1) { + // mdxTree.children.shift() + // } + // content = toMarkdown(mdxTree) + + content = contentWithoutFrontmatter + if (meta.title) { + const h1Regex = new RegExp(`(?:^|\n)# ${meta.title}\n+`) + content = content.replace(h1Regex, '') + } } return { diff --git a/apps/docs/app/reference/[...slug]/page.tsx b/apps/docs/app/reference/[...slug]/page.tsx new file mode 100644 index 00000000000..43f8903399d --- /dev/null +++ b/apps/docs/app/reference/[...slug]/page.tsx @@ -0,0 +1,42 @@ +import { redirect } from 'next/navigation' + +import { REFERENCES } from '~/content/navigation.references' +import { ClientSdkReferencePage } from '~/features/docs/Reference.sdkPage' +import { + generateReferenceMetadata, + generateReferenceStaticParams, + parseReferencePath, + redirectNonexistentReferenceSection, +} from '~/features/docs/Reference.utils' +import { notFoundLink } from '~/features/recommendations/NotFound.utils' + +export default async function ReferencePage({ + params: { slug }, +}: { + params: { slug: Array } +}) { + if (!Object.keys(REFERENCES).includes(slug[0])) { + redirect(notFoundLink(slug.join('/'))) + } + + const parsedPath = parseReferencePath(slug) + const isClientSdkReference = parsedPath.__type === 'clientSdk' + + if (isClientSdkReference) { + const { sdkId, maybeVersion, path } = parsedPath + + const sdkData = REFERENCES[sdkId] + const latestVersion = sdkData.versions[0] + const version = maybeVersion ?? latestVersion + + await redirectNonexistentReferenceSection(sdkId, version, path, version === latestVersion) + + return + } else { + // Unimplemented -- eventually API and CLI + redirect(notFoundLink(slug.join('/'))) + } +} + +export const generateStaticParams = generateReferenceStaticParams +export const generateMetadata = generateReferenceMetadata diff --git a/apps/docs/app/reference/page.tsx b/apps/docs/app/reference/page.tsx new file mode 100644 index 00000000000..2475c34d309 --- /dev/null +++ b/apps/docs/app/reference/page.tsx @@ -0,0 +1,62 @@ +import Link from 'next/link' + +import { REFERENCES, clientSdkIds } from '~/content/navigation.references' +import { IconPanelWithIconPicker } from '~/features/ui/IconPanelWithIconPicker' +import { LayoutMainContent } from '~/layouts/DefaultLayout' +import { SidebarSkeleton } from '~/layouts/MainSkeleton' + +export default function ReferenceIndexPage() { + return ( + + +
+

API References

+

+ The Supabase client libraries help you interact with Supabase products, such as the + Postgres Database, Auth, and Realtime. They are available in several popular programming + languages. +

+

+ Supabase also has a Management API to help with managing your Supabase Platform, and a + CLI for local development and CI workflows. +

+

Client Libraries

+
+ {clientSdkIds.map((sdkId) => { + return ( + + + + ) + })} +
+

Management API and CLI

+
+ + + + + + +
+
+
+
+ ) +} diff --git a/apps/docs/components/Breadcrumbs.tsx b/apps/docs/components/Breadcrumbs.tsx index c8225015f67..cc7e96f3d90 100644 --- a/apps/docs/components/Breadcrumbs.tsx +++ b/apps/docs/components/Breadcrumbs.tsx @@ -1,8 +1,10 @@ 'use client' -import React, { Fragment } from 'react' import Link from 'next/link' import { usePathname } from 'next/navigation' +import React, { Fragment } from 'react' + +import { useBreakpoint } from 'common' import { Breadcrumb_Shadcn_ as Breadcrumb, BreadcrumbList_Shadcn_ as BreadcrumbList, @@ -23,9 +25,9 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from 'ui' + +import * as NavItems from '~/components/Navigation/NavigationMenu/NavigationMenu.constants' import { getMenuId } from '~/components/Navigation/NavigationMenu/NavigationMenu.utils' -import { useBreakpoint } from 'common' -import * as NavItems from './Navigation/NavigationMenu/NavigationMenu.constants' const Breadcrumbs = ({ className }: { className?: string }) => { const pathname = usePathname() diff --git a/apps/docs/components/MDX/partials.tsx b/apps/docs/components/MDX/partials.tsx index f7a6e738c0f..1f6133c6e4f 100644 --- a/apps/docs/components/MDX/partials.tsx +++ b/apps/docs/components/MDX/partials.tsx @@ -1,5 +1,6 @@ 'use client' +import AuthErrorCodesTable from './auth_error_codes_table.mdx' import AuthRateLimits from './auth_rate_limits.mdx' import CreateClientSnippet from './create_client_snippet.mdx' import DatabaseSetup from './database_setup.mdx' @@ -15,6 +16,7 @@ import SocialProviderSettingsSupabase from './social_provider_settings_supabase. import SocialProviderSetup from './social_provider_setup.mdx' export { + AuthErrorCodesTable, AuthRateLimits, CreateClientSnippet, DatabaseSetup, diff --git a/apps/docs/components/MDX/quickstart_intro.mdx b/apps/docs/components/MDX/quickstart_intro.mdx index d20d9f32ece..761f8a36c66 100644 --- a/apps/docs/components/MDX/quickstart_intro.mdx +++ b/apps/docs/components/MDX/quickstart_intro.mdx @@ -1,5 +1,5 @@ This tutorial demonstrates how to build a basic user management app. The app authenticates and identifies the user, stores their profile information in the database, and allows the user to log in, update their profile details, and upload a profile photo. The app uses: - [Supabase Database](/docs/guides/database) - a Postgres database for storing your user data and [Row Level Security](/docs/guides/auth#row-level-security) so data is protected and users can only access their own information. -- [Supabase Auth](/docs/guides/auth) - users log in through magic links sent to their email (without having to set up passwords). +- [Supabase Auth](/docs/guides/auth) - allow users to sign up and log in. - [Supabase Storage](/docs/guides/storage) - users can upload a profile photo. diff --git a/apps/docs/components/Navigation/Navigation.types.ts b/apps/docs/components/Navigation/Navigation.types.ts index dd744fe4b0e..0b8489dacc8 100644 --- a/apps/docs/components/Navigation/Navigation.types.ts +++ b/apps/docs/components/Navigation/Navigation.types.ts @@ -13,16 +13,6 @@ export interface NavMenuSection { items: Partial[] } -export interface References { - [key: string]: { - name: string - library?: string - versions: string[] - icon: string - currentVersion?: string - } -} - type MenuItem = { label: string icon?: string diff --git a/apps/docs/components/Navigation/NavigationMenu/MenuIconPicker.tsx b/apps/docs/components/Navigation/NavigationMenu/MenuIconPicker.tsx index 18fef33a67b..acab4cf6f97 100644 --- a/apps/docs/components/Navigation/NavigationMenu/MenuIconPicker.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/MenuIconPicker.tsx @@ -1,7 +1,6 @@ -'use client' - -import React from 'react' import { + IconBranching, + IconGitHub, IconMenuApi, IconMenuAuth, IconMenuCli, @@ -26,10 +25,8 @@ import { IconMenuKotlin, IconMenuAI, IconMenuDevCli, - IconGitHub, IconSupport, IconTroubleshooting, - IconBranching, } from './MenuIcons' function getMenuIcon(menuKey: string, width: number = 16, height: number = 16, className?: string) { diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 11d3b8eb12c..d5c8479e33f 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -1,5 +1,4 @@ -import { IS_DEV } from '~/lib/constants' -import type { GlobalMenuItems, NavMenuConstant, References } from '../Navigation.types' +import type { GlobalMenuItems, NavMenuConstant } from '../Navigation.types' export const GLOBAL_MENU_ITEMS: GlobalMenuItems = [ [ @@ -100,38 +99,38 @@ export const GLOBAL_MENU_ITEMS: GlobalMenuItems = [ { label: 'JavaScript', icon: 'reference-javascript', - href: '/reference/javascript/introduction', + href: '/reference/javascript', level: 'reference_javascript', }, { label: 'Flutter', icon: 'reference-dart', - href: '/reference/dart/introduction', + href: '/reference/dart', level: 'reference_dart', }, { label: 'Swift', icon: 'reference-swift', - href: '/reference/swift/introduction', + href: '/reference/swift', level: 'reference_swift', }, { label: 'Python', icon: 'reference-python', - href: '/reference/python/introduction', + href: '/reference/python', level: 'reference_python', }, { label: 'C#', icon: 'reference-csharp', - href: '/reference/csharp/introduction', + href: '/reference/csharp', level: 'reference_csharp', community: true, }, { label: 'Kotlin', icon: 'reference-kotlin', - href: '/reference/kotlin/introduction', + href: '/reference/kotlin', level: 'reference_kotlin', community: true, }, @@ -202,51 +201,6 @@ export const GLOBAL_MENU_ITEMS: GlobalMenuItems = [ ], ] -export const REFERENCES: References = { - javascript: { - name: 'supabase-js', - library: 'supabase-js', - versions: ['v2', 'v1'], - icon: '/img/libraries/javascript-icon', - }, - dart: { - name: 'Flutter', - library: 'supabase-dart', - versions: ['v2', 'v1'], - icon: '/docs/img/libraries/flutter-icon.svg', - }, - csharp: { - name: 'C#', - library: 'supabase-csharp', - versions: ['v1', 'v0'], - icon: '/docs/img/libraries/c-sharp-icon.svg', - }, - swift: { - name: 'Swift', - library: 'supabase-swift', - versions: ['v2', 'v1'], - icon: '/docs/img/libraries/swift-icon.svg', - }, - kotlin: { - name: 'Kotlin', - library: 'supabase-kt', - versions: ['v2', 'v1'], - icon: '/docs/img/libraries/kotlin-icon.svg', - }, - cli: { - name: 'CLI', - library: undefined, - versions: [], - icon: '/docs/img/icons/cli-icon.svg', - }, - api: { - name: 'API', - library: undefined, - versions: [], - icon: '/docs/img/icons/api-icon.svg', - }, -} - export const gettingstarted: NavMenuConstant = { icon: 'getting-started', title: 'Start with Supabase', @@ -615,13 +569,29 @@ export const auth = { name: 'Identity Linking', url: '/guides/auth/auth-identity-linking', }, - { name: 'Multi-Factor Authentication', url: '/guides/auth/auth-mfa' }, + { + name: 'Multi-Factor Authentication', + url: '/guides/auth/auth-mfa', + items: [ + { name: 'App Authenticator (TOTP)', url: '/guides/auth/auth-mfa/totp' }, + { name: 'Phone', url: '/guides/auth/auth-mfa/phone' }, + ], + }, { name: 'Signout', url: '/guides/auth/signout', }, ], }, + { + name: 'Third-party auth', + items: [ + { name: 'Overview', url: '/guides/auth/third-party/overview' }, + { name: 'Firebase Auth', url: '/guides/auth/third-party/firebase-auth' }, + { name: 'Auth0', url: '/guides/auth/third-party/auth0' }, + { name: 'AWS Cognito (Amplify)', url: '/guides/auth/third-party/aws-cognito' }, + ], + }, { name: 'Configuration', items: [ @@ -634,7 +604,32 @@ export const auth = { name: 'Redirect URLs', url: '/guides/auth/redirect-urls', }, - { name: 'Auth Hooks', url: '/guides/auth/auth-hooks' }, + { + name: 'Auth Hooks', + url: '/guides/auth/auth-hooks', + items: [ + { + name: 'Custom access token hook', + url: '/guides/auth/auth-hooks/custom-access-token-hook', + }, + { + name: 'Send SMS hook', + url: '/guides/auth/auth-hooks/send-sms-hook', + }, + { + name: 'Send email hook', + url: '/guides/auth/auth-hooks/send-email-hook', + }, + { + name: 'MFA verification hook', + url: '/guides/auth/auth-hooks/mfa-verification-hook', + }, + { + name: 'Password verification hook', + url: '/guides/auth/auth-hooks/password-verification-hook', + }, + ], + }, { name: 'Custom SMTP', url: '/guides/auth/auth-smtp' }, { name: 'User Management', url: '/guides/auth/managing-user-data' }, ], @@ -770,6 +765,10 @@ export const database: NavMenuConstant = { name: 'Managing Postgres Roles', url: '/guides/database/postgres/roles', }, + { + name: 'Using Custom Postgres Roles', + url: '/guides/storage/schema/custom-roles', + }, { name: 'Managing secrets with Vault', url: '/guides/database/vault' }, { name: 'Superuser Access and Unsupported Operations', @@ -1170,6 +1169,10 @@ export const functions: NavMenuConstant = { name: 'Deploying with CI / CD pipelines', url: '/guides/functions/cicd-workflow', }, + { + name: 'Integrating with Log Drains', + url: '/guides/functions/log-drains', + }, ], }, { @@ -1743,6 +1746,7 @@ export const platform: NavMenuConstant = { url: undefined, items: [ { name: 'Logging', url: '/guides/platform/logs' }, + { name: 'Advanced Log Filtering', url: '/guides/platform/advanced-log-filtering' }, { name: 'Log Drains', url: '/guides/platform/log-drains' }, { name: 'Metrics', url: '/guides/platform/metrics' }, { name: 'Monitoring with Sentry', url: '/guides/platform/sentry-monitoring' }, @@ -1924,6 +1928,11 @@ export const resources: NavMenuConstant = { name: 'Migrate to Supabase', url: '/guides/resources/migrating-to-supabase', items: [ + { + name: 'Amazon RDS', + url: '/guides/resources/migrating-to-supabase/amazon-rds', + items: [], + }, { name: 'Auth0', url: '/guides/resources/migrating-to-supabase/auth0', @@ -1933,7 +1942,7 @@ export const resources: NavMenuConstant = { url: '/guides/resources/migrating-to-supabase/firebase-auth', }, { - name: 'Firestore Data', + name: 'Firebase Firestore', url: '/guides/resources/migrating-to-supabase/firestore-data', }, { @@ -1941,23 +1950,9 @@ export const resources: NavMenuConstant = { url: '/guides/resources/migrating-to-supabase/firebase-storage', }, { - name: 'Heroku', + name: 'Heroku Postgres', url: '/guides/resources/migrating-to-supabase/heroku', }, - { - name: 'Render', - url: '/guides/resources/migrating-to-supabase/render', - }, - { - name: 'Amazon RDS', - url: '/guides/resources/migrating-to-supabase/amazon-rds', - items: [], - }, - { - name: 'Postgres', - url: '/guides/resources/migrating-to-supabase/postgres', - items: [], - }, { name: 'MySQL', url: '/guides/resources/migrating-to-supabase/mysql', @@ -1968,6 +1963,15 @@ export const resources: NavMenuConstant = { url: '/guides/resources/migrating-to-supabase/mssql', items: [], }, + { + name: 'Postgres', + url: '/guides/resources/migrating-to-supabase/postgres', + items: [], + }, + { + name: 'Render', + url: '/guides/resources/migrating-to-supabase/render', + }, ], }, ], @@ -2093,13 +2097,6 @@ export const reference = { items: [], icon: '/img/icons/menu/reference-kotlin', }, - // { - // name: 'supabase-python', - // url: '/reference/python/start', - // level: 'reference_python', - // - // icon: '/img/icons/menu/reference-javascript', - // }, ], }, { @@ -2125,6 +2122,10 @@ export const reference_javascript_v1 = { title: 'JavaScript', url: '/guides/reference/javascript', parent: '/reference', + pkg: { + name: '@supabase/supabase-js', + repo: 'https://github.com/supabase/supabase-js', + }, } export const reference_javascript_v2 = { @@ -2132,6 +2133,10 @@ export const reference_javascript_v2 = { title: 'JavaScript', url: '/guides/reference/javascript', parent: '/reference', + pkg: { + name: '@supabase/supabase-js', + repo: 'https://github.com/supabase/supabase-js', + }, } export const reference_dart_v1 = { @@ -2139,6 +2144,10 @@ export const reference_dart_v1 = { title: 'Flutter', url: '/guides/reference/dart', parent: '/reference', + pkg: { + name: 'supabase_flutter', + repo: 'https://github.com/supabase/supabase-flutter', + }, } export const reference_dart_v2 = { @@ -2146,6 +2155,10 @@ export const reference_dart_v2 = { title: 'Flutter', url: '/guides/reference/dart', parent: '/reference', + pkg: { + name: 'supabase_flutter', + repo: 'https://github.com/supabase/supabase-flutter', + }, } export const reference_csharp_v0 = { @@ -2153,6 +2166,10 @@ export const reference_csharp_v0 = { title: 'C#', url: 'guides/reference/csharp', parent: '/reference', + pkg: { + name: 'supabase', + repo: 'https://github.com/supabase-community/supabase-csharp', + }, } export const reference_csharp_v1 = { @@ -2160,6 +2177,10 @@ export const reference_csharp_v1 = { title: 'C#', url: 'guides/reference/csharp', parent: '/reference', + pkg: { + name: 'supabase', + repo: 'https://github.com/supabase-community/supabase-csharp', + }, } export const reference_python_v2 = { @@ -2167,6 +2188,10 @@ export const reference_python_v2 = { title: 'Python', url: '/guides/reference/python', parent: '/reference', + pkg: { + name: 'supabase-py', + repo: 'https://github.com/supabase/supabase-py', + }, } export const reference_swift_v1 = { @@ -2174,6 +2199,10 @@ export const reference_swift_v1 = { title: 'swift', url: 'guides/reference/swift', parent: '/reference', + pkg: { + name: 'supabase-swift', + repo: 'https://github.com/supabase/supabase-swift', + }, } export const reference_swift_v2 = { @@ -2181,6 +2210,10 @@ export const reference_swift_v2 = { title: 'swift', url: 'guides/reference/swift', parent: '/reference', + pkg: { + name: 'supabase-swift', + repo: 'https://github.com/supabase/supabase-swift', + }, } export const reference_kotlin_v1 = { @@ -2188,6 +2221,10 @@ export const reference_kotlin_v1 = { title: 'kotlin', url: 'guides/reference/kotlin', parent: '/reference', + pkg: { + name: '@supabase-community/supabase-kt', + repo: 'https://github.com/supabase-community/supabase-kt', + }, } export const reference_kotlin_v2 = { @@ -2195,6 +2232,10 @@ export const reference_kotlin_v2 = { title: 'kotlin', url: 'guides/reference/kotlin', parent: '/reference', + pkg: { + name: '@supabase-community/supabase-kt', + repo: 'https://github.com/supabase-community/supabase-kt', + }, } export const reference_cli = { diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.tsx b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.tsx index 95dc17e710f..272443a3498 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.tsx @@ -1,4 +1,5 @@ import { memo } from 'react' + import NavigationMenuGuideList from './NavigationMenuGuideList' import NavigationMenuRefList from './NavigationMenuRefList' import { useCloseMenuOnRouteChange } from './NavigationMenu.utils' @@ -50,8 +51,7 @@ interface GuideMenu extends BaseMenu { interface ReferenceMenu extends BaseMenu { type: 'reference' path: string - commonSectionsFile: string - specFile?: string + commonSectionsFile?: string } type Menu = GuideMenu | ReferenceMenu @@ -115,133 +115,109 @@ const menus: Menu[] = [ }, { id: MenuId.RefJavaScriptV1, - commonSectionsFile: 'common-client-libs-sections.json', - specFile: 'supabase_js_v1.yml', type: 'reference', path: '/reference/javascript/v1', }, { id: MenuId.RefJavaScriptV2, - commonSectionsFile: 'common-client-libs-sections.json', - specFile: 'supabase_js_v2.yml', type: 'reference', path: '/reference/javascript', }, { id: MenuId.RefDartV1, - commonSectionsFile: 'common-client-libs-sections.json', - specFile: 'supabase_dart_v1.yml', type: 'reference', path: '/reference/dart/v1', }, { id: MenuId.RefDartV2, - commonSectionsFile: 'common-client-libs-sections.json', - specFile: 'supabase_dart_v2.yml', type: 'reference', path: '/reference/dart', }, { id: MenuId.RefCSharpV0, - commonSectionsFile: 'common-client-libs-sections.json', - specFile: 'supabase_csharp_v0.yml', type: 'reference', path: '/reference/csharp/v0', }, { id: MenuId.RefCSharpV1, - commonSectionsFile: 'common-client-libs-sections.json', - specFile: 'supabase_csharp_v1.yml', type: 'reference', path: '/reference/csharp', }, { id: MenuId.RefPythonV2, - commonSectionsFile: 'common-client-libs-sections.json', - specFile: 'supabase_py_v2.yml', type: 'reference', path: '/reference/python', }, { id: MenuId.RefSwiftV1, - commonSectionsFile: 'common-client-libs-sections.json', - specFile: 'supabase_swift_v1.yml', type: 'reference', path: '/reference/swift', }, { id: MenuId.RefSwiftV2, - commonSectionsFile: 'common-client-libs-sections.json', - specFile: 'supabase_swift_v2.yml', type: 'reference', path: '/reference/swift', }, { id: MenuId.RefKotlinV1, - commonSectionsFile: 'common-client-libs-sections.json', - specFile: 'supabase_kt_v1.yml', type: 'reference', path: '/reference/kotlin/v1', }, { id: MenuId.RefKotlinV2, - commonSectionsFile: 'common-client-libs-sections.json', - specFile: 'supabase_kt_v2.yml', type: 'reference', path: '/reference/kotlin', }, { id: MenuId.RefCli, - commonSectionsFile: 'common-cli-sections.json', type: 'reference', path: '/reference/cli', + commonSectionsFile: 'common-cli-sections.json', }, { id: MenuId.RefApi, - commonSectionsFile: 'common-api-sections.json', type: 'reference', path: '/reference/api', + commonSectionsFile: 'common-api-sections.json', }, { id: MenuId.SelfHostingAuth, - commonSectionsFile: 'common-self-hosting-auth-sections.json', type: 'reference', path: '/reference/self-hosting-auth', + commonSectionsFile: 'common-self-hosting-auth-sections.json', }, { id: MenuId.SelfHostingStorage, - commonSectionsFile: 'common-self-hosting-storage-sections.json', type: 'reference', path: '/reference/self-hosting-storage', + commonSectionsFile: 'common-self-hosting-storage-sections.json', }, { id: MenuId.SelfHostingRealtime, - commonSectionsFile: 'common-self-hosting-realtime-sections.json', type: 'reference', path: '/reference/self-hosting-realtime', + commonSectionsFile: 'common-self-hosting-realtime-sections.json', }, { id: MenuId.SelfHostingAnalytics, - commonSectionsFile: 'common-self-hosting-analytics-sections.json', type: 'reference', path: '/reference/self-hosting-analytics', + commonSectionsFile: 'common-self-hosting-analytics-sections.json', }, { id: MenuId.SelfHostingFunctions, - commonSectionsFile: 'common-self-hosting-functions-sections.json', type: 'reference', path: '/reference/self-hosting-functions', + commonSectionsFile: 'common-self-hosting-functions-sections.json', }, ] -export function getMenuById(id: MenuId) { +function getMenuById(id: MenuId) { return menus.find((menu) => menu.id === id) } function getMenuElement(menu: Menu | undefined) { - if (!menu) throw Error('No menu found for this menuId') - - const menuType = menu.type + const menuType = menu?.type switch (menuType) { case 'guide': return @@ -251,7 +227,6 @@ function getMenuElement(menu: Menu | undefined) { id={menu.id} basePath={menu.path} commonSectionsFile={menu.commonSectionsFile} - specFile={menu.specFile} /> ) default: @@ -268,5 +243,5 @@ const NavigationMenu = ({ menuId }: { menuId: MenuId }) => { return getMenuElement(menu) } -export { MenuId } +export { MenuId, getMenuById } export default memo(NavigationMenu) diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.utils.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.utils.ts index 28633a25ab9..85934ed317e 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.utils.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.utils.ts @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react' import { usePathname } from 'next/navigation' import { MenuId } from '~/components/Navigation/NavigationMenu/NavigationMenu' import type { ICommonItem } from '~/components/reference/Reference.types' -import type { Json } from '~/types' +import type { Json } from '~/features/helpers.types' import { menuState } from '../../../hooks/useMenuState' export function getPathWithoutHash(relativePath: string) { @@ -99,14 +99,6 @@ export function useSpec(specFile?: string) { return spec } -export const useCloseMenuOnRouteChange = () => { - const pathname = usePathname() - - useEffect(() => { - menuState.setMenuMobileOpen(false) - }, [pathname]) -} - export const getMenuId = (pathname: string | null) => { pathname = (pathname ??= '').replace(/^\/guides\//, '') @@ -141,3 +133,11 @@ export const getMenuId = (pathname: string | null) => { return MenuId.GettingStarted } } + +export const useCloseMenuOnRouteChange = () => { + const pathname = usePathname() + + useEffect(() => { + menuState.setMenuMobileOpen(false) + }, [pathname]) +} diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenuRefList.tsx b/apps/docs/components/Navigation/NavigationMenu/NavigationMenuRefList.tsx index af0c26b8050..9725daa229b 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenuRefList.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenuRefList.tsx @@ -28,12 +28,6 @@ const NavigationMenuRefList = ({ } const filteredSections = commonSections.filter((section) => { - if (section.type === 'category') { - section.items = section.items.filter((item) => { - return !item.excludes?.includes(id) - }) - } - return !section.excludes?.includes(id) }) diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenuRefListItems.tsx b/apps/docs/components/Navigation/NavigationMenu/NavigationMenuRefListItems.tsx index 4d42b905c1d..fa84e200b06 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenuRefListItems.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenuRefListItems.tsx @@ -185,7 +185,6 @@ const NavigationMenuRefListItems = ({
-
    {filteredSections.map((section) => { diff --git a/apps/docs/components/Navigation/SideBar.tsx b/apps/docs/components/Navigation/SideBar.tsx index fcf02a89a98..179b66a333b 100644 --- a/apps/docs/components/Navigation/SideBar.tsx +++ b/apps/docs/components/Navigation/SideBar.tsx @@ -2,7 +2,7 @@ import Link from 'next/link' import Image from 'next/legacy/image' import { usePathname } from 'next/navigation' import { IconChevronRight, IconArrowLeft } from '~/../../packages/ui' -import { REFERENCES } from './NavigationMenu/NavigationMenu.constants' +import { REFERENCES } from '~/content/navigation.references' import { NavMenuGroup, NavMenuSection } from './Navigation.types' import * as Accordion from '@radix-ui/react-accordion' diff --git a/apps/docs/components/RefVersionDropdown.tsx b/apps/docs/components/RefVersionDropdown.tsx index 19c92b28898..9083d286155 100644 --- a/apps/docs/components/RefVersionDropdown.tsx +++ b/apps/docs/components/RefVersionDropdown.tsx @@ -1,4 +1,8 @@ -import { usePathname, useRouter } from 'next/navigation' +'use client' + +import { ChevronDown } from 'lucide-react' +import { useRouter } from 'next/navigation' + import { Badge, DropdownMenu, @@ -6,34 +10,32 @@ import { DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger, - IconChevronDown, } from 'ui' -import { REFERENCES } from './Navigation/NavigationMenu/NavigationMenu.constants' -const RevVersionDropdown = () => { - const pathname = usePathname() +import { REFERENCES } from '~/content/navigation.references' + +const RevVersionDropdown = ({ + library, + currentVersion, +}: { + library: string + currentVersion: string +}) => { const { push } = useRouter() - const pathSegments = pathname.split('/') - const library = pathSegments.length >= 3 ? pathSegments[2] : undefined const libraryMeta = REFERENCES?.[library] ?? undefined const versions = libraryMeta?.versions ?? [] - const currentVersion = versions.includes(pathSegments[pathSegments.indexOf(library) + 1]) - ? pathSegments[pathSegments.indexOf(library) + 1] - : versions[0] - - const onSelectVersion = (version: string) => { - if (!library) return - if (version === versions[0]) { - push(`/reference/${library}/start`) - } else { - push(`/reference/${library}/${version}/start`) - } + if (!versions || versions.length <= 1) { + return null } - if (!versions || versions.length === 0) { - return <> + const onSelectVersion = (version: string) => { + if (version === versions[0]) { + push(`/reference/${library}`) + } else { + push(`/reference/${library}/${version}`) + } } return ( @@ -52,11 +54,10 @@ const RevVersionDropdown = () => { flex items-center gap-1 text-foreground-muted text-xs group-hover:text-foreground transition " > - {/* version */} {currentVersion}.0 - + diff --git a/apps/docs/content/guides/auth/auth-hooks.mdx b/apps/docs/content/guides/auth/auth-hooks.mdx index 5986aeb6d0c..5ca24eb75b9 100644 --- a/apps/docs/content/guides/auth/auth-hooks.mdx +++ b/apps/docs/content/guides/auth/auth-hooks.mdx @@ -1,10 +1,12 @@ --- id: 'auth-hooks' title: 'Auth Hooks' -description: 'Use Supabase Postgres Functions to customize your authentication flow' +subtitle: 'Use HTTP or Postgres Functions to customize your authentication flow' --- -Supabase allows you to use Postgres functions to alter the default Supabase Auth flow. Developers can use hooks to add custom behavior that's not supported natively. +## What is a hook + +A hook is an endpoint that allows you to alter the default Supabase Auth flow at specific execution points. Developers can use hooks to add custom behavior that's not supported natively. Hooks help you: @@ -12,33 +14,195 @@ Hooks help you: - Improve security by adding additional checks to password and multi-factor authentication - Support legacy systems by integrating with identity credentials from external authentication systems - Add additional custom claims to your JWT +- Send authentication emails or SMS messages through a custom provider -You can use hooks at specific points along an Auth flow to perform custom behavior via Postgres Functions. The following hooks are available: +The following hooks are available: -| Hook | Available on Plan | -| ----------------------------- | ------------------- | -| Custom Access Token | Free, Pro | -| MFA Verification Attempt | Team and Enterprise | -| Password Verification Attempt | Team and Enterprise | +| Hook | Available on Plan | +| ---------------------------------------------------------------------------------------- | -------------------- | +| [Custom Access Token](/docs/guides/auth/auth-hooks/custom-access-token-hook) | Free, Pro | +| [Send SMS](/docs/guides/auth/auth-hooks/send-sms-hook) | Free, Pro | +| [Send Email](/docs/guides/auth/auth-hooks/send-email-hook) | Free, Pro | +| [MFA Verification Attempt](/docs/guides/auth/auth-hooks/mfa-verification-hook) | Teams and Enterprise | +| [Password Verification Attempt](/docs/guides/auth/auth-hooks/password-verification-hook) | Teams and Enterprise | -You can connect a hook to Supabase Auth to signal to Supabase Auth that it should make use of the hook. +Supabase supports 2 ways to [configure a hook](/dashboard/project/_/auth/hooks) in your project: -## Create a hook + + -### What is a hook +A [postgres function](/docs/guides/database/functions) can be configured as a hook. The function should take in a single argument -- the event of type JSONB -- and return a JSONB object. Since the postgres function runs on your database, the request does not leave your project's instance. -A hook is a [Postgres Function](https://www.postgresql.org/docs/current/sql-createfunction.html) with a single argument -- the `event` of type [JSONB]() -- and which returns a JSONB object. + + -This function is invoked by Supabase Auth at its point in the flow by providing an event object. The object returned by the function gives instructions on how Supabase Auth should continue processing. +A HTTP Hook is an endpoint which takes in a JSON event payload and returns a JSON response. You can use any HTTP endpoint as a Hook, including an endpoint in your application. The easiest way to create a HTTP hook is to create a [Supabase Edge Function](/docs/guides/functions/quickstart). -To access properties of the `event` argument, you can use the [JSON operators and functions](https://www.postgresql.org/docs/current/functions-json.html). + + -There are no restrictions as to what language can be used to write Auth Hooks. If [PL/pgSQL](https://www.postgresql.org/docs/current/plpgsql.html) is too difficult consider using the [plv8](/docs/guides/database/extensions/plv8) extension which lets you use JavaScript to define functions. +## Security Model + +Sign the payload and grant permissions selectively in order to guard the integrity of the payload. + + + + +When you configure a postgres function as a hook, Supabase will automatically apply the following grants to the function for these reasons: + +- Allow the `supabase_auth_admin` role to execute the function. The `supabase_auth_admin` role is the postgres role that is used by Supabase Auth to make requests to your database. +- Revoke permissions from other roles (e.g. `anon`, `authenticated`, `public`) to ensure the function is not accessible by Supabase Data APIs. + +```sql +-- Grant access to function to supabase_auth_admin +grant execute + on function public.custom_access_token_hook + to supabase_auth_admin; + +-- Grant access to schema to supabase_auth_admin +grant usage on schema public to supabase_auth_admin; + +-- Revoke function permissions from authenticated, anon and public +revoke execute + on function public.custom_access_token_hook + from authenticated, anon, public; +``` + + + +We do not recommend creating postgres functions with the `security definer` tag. The `security definer` tag specifies that the function is to be executed with the privileges of the user that owns it. Read more about the `security definer` tag [in our database guide](/docs/guides/database/functions#security-definer-vs-invoker). + + + + + + +HTTP Hooks in Supabase follow the [Standard Webhooks Specification](https://www.standardwebhooks.com/), which is a set of guidelines aligning how hooks are implemented. The specification attaches three security headers to guarantee the integrity of the payload: + +- `webhook-id`: the unique webhook identifier described in the preceding sections. +- `webhook-timestamp`: integer UNIX timestamp (seconds since epoch). +- `webhook-signature`: the signatures of this webhook. This is generated from body of the hook. + +When the request is made to the HTTP hook, you should use the [Standard Webhooks libraries](https://github.com/standard-webhooks/standard-webhooks/tree/main/libraries) to verify these headers. + +When a HTTP hook is created, the secret generated should be of the following format: `v1,whsec_`: + +- `v1` denotes the version of the hook +- `whsec_` signifies that the secret is symmetric +- `` implies a Standard Base64 encoded secret which can contain the characters `+`, `/` and `=` + +The secret is used to verify the payload received in your hook. Create an entry in your `.env.local` file to store the secret for each hook that you have. For example: + +```bash +HOOK_SECRET= +``` + +Use the secret in conjunction with the Standard Webhooks package to verify the payload before processing it: + +```jsx +import { Webhook } from 'https://esm.sh/standardwebhooks@1.0.0' + +Deno.serve(async (req) => { + const payload = await req.text() + const hookSecret = Deno.env.get('HOOK_SECRET') + // Extract headers and security specific fields + const headers = Object.fromEntries(req.headers) + const wh = new Webhook(hookSecret) + const data = wh.verify(payload, headers) + + // Payload data is verified, continue with business logic here + // ... +}) +``` + + + + +## Using Hooks + +### Developing + +Let us develop a Hook locally and then deploy it to the cloud. As a recap, here’s a list of available Hooks + +| Hook | Suggested Function Name | When it is called | What it Does | +| ----------------------------- | ------------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| Send SMS | `send_sms` | Each time an SMS is sent | Allows you to customize message content and SMS Provider | +| Send Email | `send_email` | Each time an Email is sent | Allows you to customize message content and Email Provider | +| Custom Access Token | `custom_access_token` | Each time a new JWT is created | Returns the claims you wish to be present in the JWT. | +| MFA Verification Attempt | `mfa_verification_attempt` | Each time a user tries to verify an MFA factor. | Returns a decision on whether to reject the attempt and future ones, or to allow the user to keep trying. | +| Password Verification Attempt | `password_verification_attempt` | Each time a user tries to sign in with a password. | Return a decision whether to allow the user to reject the attempt, or to allow the user to keep trying. | + +Edit `config.toml` to set up the Auth Hook locally. + + + +Modify the `auth.hook.` field and set `uri` to a value of `pg-functions://postgres//` + +``` +[auth.hook.] +enabled = true +uri = "pg-functions://...." + +``` + +You need to assign additional permissions so that Supabase Auth can access the hook as well as the tables it interacts with. + +The `supabase_auth_admin` role does not have permissions to the `public` schema. You need to grant the role permission to execute your hook: + +```sql +grant execute + on function public.custom_access_token_hook + to supabase_auth_admin; + +``` + +You also need to grant usage to `supabase_auth_admin`: + +```sql +grant usage on schema public to supabase_auth_admin; + +``` + +Also revoke permissions from the `authenticated` and `anon` roles to ensure the function is not accessible by Supabase Serverless APIs. + +```sql +revoke execute + on function public.custom_access_token_hook + from authenticated, anon; + +``` + +For security, we recommend against the use the `security definer` tag. The `security definer` tag specifies that the function is to be executed with the privileges of the user that owns it. When a function is created via the Supabase dashboard with the tag, it will have the extensive permissions of the `postgres` role which make it easier for undesirable actions to occur. + +We recommend that you do not use any tag and explicitly grant permissions to `supabase_auth_admin` as described above. + +Read more about `security definer` tag [in our database guide](/docs/guides/database/functions#security-definer-vs-invoker). + +There are no restrictions as to what language can be used to write a Postgres Hook. If [PL/pgSQL](https://www.postgresql.org/docs/current/plpgsql.html) is too difficult consider using the [plv8](/docs/guides/database/extensions/plv8) extension which lets you use JavaScript to define functions. + +Once done, save your Auth Hook as a migration in order to version the Auth Hook and share it with other team members. Run [`supabase migration new`](/docs/reference/cli/supabase-migration-new) to create a migration. - If you're using the Supabase SQL Editor, there's an issue when using the `?` (*Does the string - exist as a top-level key within the JSON value?*) operator. Use a direct connection to the - database if you need to use it when defining a function. + +If you're using the Supabase SQL Editor, there's an issue when using the `?` (_Does the string exist as a top-level key within the JSON value?_) operator. Use a direct connection to the database if you need to use it when defining a function. + Here is an example hook signature: @@ -55,43 +219,47 @@ begin return event; end; $$; + ``` You can visit `SQL Editor > Templates` for hook templates. -### Hook grants and roles - -You need to assign additional permissions so that Supabase Auth can access the hook as well as the tables it interacts with. - -The `supabase_auth_admin` role does not have permissions to the `public` schema. You need to grant the role permission to execute your hook function: - -```sql -grant execute - on function public.custom_access_token_hook - to supabase_auth_admin; + + +Modify the `auth.hook.` field and set `uri` to a valid HTTP URI. For example, the `send_sms` hook would take the following fields: +``` +[auth.hook.send_sms] +enabled = true +uri = "http://host.docker.internal:54321/functions/v1/send_sms" +secret = "env(SEND_SMS_HOOK_SECRET)" ``` -You also need to grant usage to `supabase_auth_admin`: +`host.docker.internal` is a special DNS name used in Docker to allow a container to access the host machine's network. This allows the Auth container to reach the Functions container -```sql -grant usage on schema public to supabase_auth_admin; +Fill in the Hook Secret in `supabase/functions/.env` + +```bash +SEND_SMS_HOOK_SECRET='v1,whsec_' ``` -Also revoke permissions from the `authenticated`, `public`, and `anon` roles to ensure the function is not accessible by Supabase Serverless APIs. The `public` role has access to functions created on `public.*` by default and `anon` and `authenticated` inherit permissions from the `public` role. Permission is revoked from the `public` role in order to prevent the `anon` and `authenticated` roles from inheriting permissions to invoke the Postgres Hook. +Start the function locally: -```sql -revoke execute - on function public.custom_access_token_hook - from authenticated, anon, public; +```bash +supabase functions serve send-sms --no-verify-jwt ``` -For security, we recommend against the use of the `security definer` tag. The `security definer` tag specifies that the function is to be executed with the privileges of the user that owns it. When a function is created via the Supabase dashboard with the tag, it will have the extensive permissions of the `postgres` role which make it easier for undesirable actions to occur. +Disable JWT verification via the `--no-verify-jwt` to accommodate hooks which are run before a JWT is issued. Payload authenticity is instead protected via the appended security headers associated with the Standard Webhooks Standard. -We recommend that you do not use any tag and explicitly grant permissions to `supabase_auth_admin` as described above. +Note that payloads are sent uncompressed in order to accurately track Content Length. In addition, there is a 20KB payload limit to guard against payload stuffing attacks. -Read more about the `security definer` tag [in our database guide](/docs/guides/database/functions#security-definer-vs-invoker). + + -### Hook errors +### Deploying + +In the dashboard, navigate to [`Authentication > Hooks`](/dashboard/project/_/auth/hooks) and select the appropriate function type (SQL or HTTP) from the dropdown menu. + +### Error Handling You should return an error when facing a runtime error. Runtime errors are specific to your application and arise from specific business rules rather than programmer errors. @@ -100,6 +268,16 @@ Runtime errors could happen when: - The user does not have appropriate permissions - The event payload received does not have required claims. - The user has performed an action which violates a business rule. +- The email or phone provider used in the webhook returned an error. + + + The error is a JSON object and has the following properties: @@ -118,618 +296,86 @@ Here's an example: } ``` -When an error is returned, the error is propagated from the hook to Supabase Auth and translated into a HTTP error which is returned to your application. Supabase Auth will only take into account the error and disregard the rest of the payload. +Errors returned from a Postgres Hook are not retry-able. When an error is returned, the error is propagated from the hook to Supabase Auth and translated into a HTTP error which is returned to your application. Supabase Auth will only take into account the error and disregard the rest of the payload. -### Timeouts + -Ensure that your hooks complete within **2 seconds** to avoid any errors. + +Hooks return status codes based on the nature of the response. These status codes help determine the next steps in the processing flow: -### Connect a hook +| HTTP Status Code | Description | Example Usage | +| ---------------- | ------------------------------------------------------------- | ---------------------------------------------- | +| 200, 202, 204 | Valid response, proceed | Successful processing of the request | +| 429, 503 | Retry-able errors with Retry-after header supported | Temporary server overload or maintenance | +| 403, 400 | Treated as Internal Server Errors and return a 500 Error Code | Malformed requests or insufficient permissions | -In the dashboard, navigate to [`Authentication > Hooks (Beta)`](/dashboard/project/_/auth/hooks) and select the appropriate PostgreSQL function from the dropdown menu. +Errors are responses which contain status codes 400 and above. On a retry-able error, such as an error with a `429` or `503` status code, HTTP Hooks will attempt up to three retries with a back-off of two seconds. -## Hook: MFA verification attempt +Here's a sample HTTP retry schedule: -You can add additional checks to the [Supabase MFA implementation](/docs/guides/auth/auth-mfa) with hooks. For example, you can: +| Time Since Start (HH:MM:SS) | Event | Notes | +| --------------------------- | ----------------------- | ------------------------------------------------- | +| 00:00:00 | Initial Attempt | Initial invocation begins. | +| 00:00:05 | Initial Attempt Timeout | Initial invocation must complete. | +| 00:00:07 | Retry Start #1 | After 2 sec delay, first retry begins. | +| 00:00:12 | Retry Timeout #1 | First retry timeout. | +| 00:00:14 | Retry Start #2 | After 2 sec delay, second retry begins. | +| 00:00:19 | Retry Timeout #2 | Second retry timeout. Returns an error on failure | -- Limit the number of verification attempts performed over a period of time. -- Sign out users who have too many invalid verification attempts. -- Count, rate limit, or ban sign-ins. +Return a retry-able error by attaching a appropriate status code (`429`, `503` ) and a non-empty `retry-after` header -**Inputs** - -Supabase Auth will supply the following fields to your hook: - -- `factor_id` Unique identifier for the MFA factor being verified. -- `user_id` Unique identifier for the user. -- `valid` Whether the verification attempt was valid. For TOTP, this means that the six digit code was correct (true) or incorrect (false). - -Example payload: - -```json -{ - "factor_id": "6eab6a69-7766-48bf-95d8-bd8f606894db", - "user_id": "3919cb6e-4215-4478-a960-6d3454326cec", - "valid": true -} +```jsx +return new Response( + JSON.stringify({ + error: `Failed to process the request: ${error}`, + }), + { status: 429, headers: { 'Content-Type': 'application/json', 'retry-after': 'true' } } +) ``` -**Outputs** - -Return this if your hook processed the input without errors. - -- `decision` A string containing the decision on whether to allow authentication to move forward. Use `reject` to deny the verification attempt and log the user out of all active sessions. Use `continue` to use the default Supabase Auth behavior. -- `message` The message to show the user if the decision was `reject`. - -Example output: - -```json -{ - "decision": "reject", - "message": "You have exceeded maximum number of MFA attempts." -} -``` - -**Examples** - - - - -Your company requires that a user can input an incorrect MFA Verification code no more than once every 2 seconds. - -Create a table to record the last time a user had an incorrect MFA verification attempt for a factor. - -```sql -create table public.mfa_failed_verification_attempts ( - user_id uuid not null, - factor_id uuid not null, - last_failed_at timestamp not null default now(), - primary key (user_id, factor_id) -); -``` - -Create a hook to read and write information to this table. For example: - -```sql -create function public.hook_mfa_verification_attempt(event jsonb) - returns jsonb - language plpgsql -as $$ - declare - last_failed_at timestamp; - begin - if event->'valid' is true then - -- code is valid, accept it - return jsonb_build_object('decision', 'continue'); - end if; - - select last_failed_at into last_failed_at - from public.mfa_failed_verification_attempts - where - user_id = event->'user_id' - and - factor_id = event->'factor_id'; - - if last_failed_at is not null and now() - last_failed_at < interval '2 seconds' then - -- last attempt was done too quickly - return jsonb_build_object( - 'error', jsonb_build_object( - 'http_code', 429, - 'message', 'Please wait a moment before trying again.' - ) - ); - end if; - - -- record this failed attempt - insert into public.mfa_failed_verification_attempts - ( - user_id, - factor_id, - last_refreshed_at - ) - values - ( - event->'user_id', - event->'factor_id', - now() - ) - on conflict do update - set last_refreshed_at = now(); - - -- finally let Supabase Auth do the default behavior for a failed attempt - return jsonb_build_object('decision', 'continue'); - end; -$$; - --- Assign appropriate permissions and revoke access -grant execute - on function public.hook_mfa_verification_attempt - to supabase_auth_admin; - -grant all - on table public.mfa_failed_verification_attempts - to supabase_auth_admin; - -revoke execute - on function public.hook_mfa_verification_attempt - from authenticated, anon, public; - -revoke all - on table public.mfa_failed_verification_attempts - from authenticated, anon, public; -``` +Note that all responses, including error responses, need a `Content-Type` of `application/json` - not specifying the appropriate `Content-Type` will result in the function returning an error response. Supabase Auth will in turn return an Internal Server Error. -## Hook: Password verification attempt - -Your company wishes to increase security beyond the requirements of the default password implementation in order to fulfill security or compliance requirements. You plan to track the status of a password sign-in attempt and take action via an email or a restriction on logins where necessary. - -As this hook runs on unauthenticated requests, malicious users can abuse the hook by calling it multiple times. Pay extra care when using the hook as you can unintentionally block legitimate users from accessing your application. - -Check if a password is valid prior to taking any additional action to ensure the user is legitimate. Where possible, send an email or notification instead of blocking the user. - -**Inputs** - -- `user_id` Unique identifier for the user attempting to sign in. Correlate this to the `auth.users` table. -- `valid` Whether the password verification attempt was valid. - -```json -{ - "user_id": "3919cb6e-4215-4478-a960-6d3454326cec", - "valid": true -} -``` - -**Outputs** - -Return these only if your hook processed the input without errors. - -- `decision` A string containing the decision whether to allow authentication to move forward. Use `reject` to completely reject the verification attempt and log the user out of all active sessions. Use `continue` to use the default Supabase Auth behavior. -- `message` The message to show the user if the decision was `reject`. -- `should_logout_user` Whether to logout a user if a `reject` decision is issued. Has no effect when a `continue` decision is issued. - -Example output: - -```json -{ - "decision": "reject", - "message": "You have exceeded maximum number of password sign-in attempts.", - "should_logout_user": "false" -} -``` - -**Examples** - - - - -As part of new security measures within the company, users can only input an incorrect password every 10 seconds and not more than that. You want to write a hook to enforce this. - -Create a table to record each user's last incorrect password verification attempt. - -```sql -create table public.password_failed_verification_attempts ( - user_id uuid not null, - last_failed_at timestamp not null default now(), - primary key (user_id) -); -``` - -Create a hook to read and write information to this table. For example: - -```sql -create function public.hook_password_verification_attempt(event jsonb) -returns jsonb -language plpgsql -as $$ - declare - last_failed_at timestamp; - begin - if event->'valid' is true then - -- password is valid, accept it - return jsonb_build_object('decision', 'continue'); - end if; - - select last_failed_at into last_failed_at - from public.password_failed_verification_attempts - where - user_id = event->'user_id'; - - if last_failed_at is not null and now() - last_failed_at < interval '10 seconds' then - -- last attempt was done too quickly - return jsonb_build_object( - 'error', jsonb_build_object( - 'http_code', 429, - 'message', 'Please wait a moment before trying again.' - ) - ); - end if; - - -- record this failed attempt - insert into public.password_failed_verification_attempts - ( - user_id, - last_failed_at - ) - values - ( - event->'user_id', - now() - ) - on conflict do update - set last_failed_at = now(); - - -- finally let Supabase Auth do the default behavior for a failed attempt - return jsonb_build_object('decision', 'continue'); - end; -$$; - --- Assign appropriate permissions -grant execute - on function public.hook_password_verification_attempt - to supabase_auth_admin; - -grant all - on table public.password_failed_verification_attempts - to supabase_auth_admin; - -revoke execute - on function public.hook_password_verification_attempt - from authenticated, anon, public; - -revoke all - on table public.password_failed_verification_attempts - from authenticated, anon, public; -``` - - - - -You can notify a user via email instead of blocking the user. To do so, make use of [Supabase Vault](/docs/guides/database/vault) to store the API Key of our mail provider and use [`pg_net`](/docs/guides/database/extensions/pg_net) to send a HTTP request to our email provider to send the email. Ensure that you have configured a sender signature for the email account which you are sending emails from. - -First, create a table to track sign in attempts. - -```sql -create table public.password_sign_in_attempts ( - user_id uuid not null, - attempt_id uuid not null, - last_attempt_at timestamp not null default now(), - attempt_successful boolean not null, - primary key (user_id, attempt_id) -); -``` - -Next, store the API key of our email API provider: - -```sql -select vault.create_secret('my_api_key', 'my_api_key_name', 'description_of_my_api_key'); -``` - -Create the hook: - -```sql -create or replace function public.hook_notify_user_on_failed_attempts(event jsonb) -returns jsonb -language plpgsql -as $$ - declare - user_id uuid; - server_token text; - user_email_address text; - email_body jsonb; - response_id int; -- Variable to store the response ID - http_code int; - error_message jsonb; - attempt_count int; - max_attempts int := 5; -- Set the threshold for failed attempts - begin - user_id := (event->>'user_id')::uuid; - - -- Record the attempt - insert into public.password_sign_in_attempts (user_id, attempt_id, last_attempt_at, attempt_successful) - values (user_id, (event->>'attempt_id')::uuid, now(), (event->>'valid')::boolean) - on conflict (user_id, attempt_id) - do update set last_attempt_at = now(), attempt_successful = (event->>'valid')::boolean; - - -- Check failed attempts and fetch user email - select count(*), u.email into attempt_count, user_email_address - from public.password_sign_in_attempts a - join auth.users u on a.user_id = u.id - where a.user_id = user_id and attempt_successful = false and last_attempt_at > (now() - interval '1 day'); - - -- Notify user if the number of failed attempts exceeds the threshold - if attempt_count >= max_attempts then - -- Fetch the server token - select decrypted_secret into server_token from vault.decrypted_secrets where name = 'my_api_key_name'; - - -- Prepare the email body - email_body := format('{ - "from": "yoursenderemail@example.com", - "to": "%s", - "subject": "Security Alert: Repeated Login Attempts Detected", - "textbody": "We have detected repeated login attempts for your account. If this was not you, please secure your account.", - "htmlbody": "Security Alert: We have detected repeated login attempts for your account. If this was not you, please secure your account.", - "messagestream": "outbound" - }', user_email_address)::jsonb; - - -- Perform the HTTP POST request using Postmark - select id into response_id from net.http_post( - 'https://api.youremailprovider.com/email', - email_body, - 'application/json', - array['Accept: application/json', 'X-Postmark-Server-Token: ' || server_token] - ); - - -- Fetch the response from net._http_response using the obtained id - select status_code, content into http_code, error_message from net._http_response where id = response_id; - - -- Handle email sending errors - if http_code is null or (http_code < 200 or http_code >= 300) then - return jsonb_build_object( - 'error', jsonb_build_object( - 'http_code', coalesce(http_code, 0), - 'message', coalesce(error_message ->> 'message', 'error sending email') - ) - ); - end if; - end if; - - -- Continue with default behavior - return jsonb_build_object('decision', 'continue'); - end; -$$; - --- Assign appropriate permissions -grant execute - on function public.hook_notify_user_on_failed_attempts - to supabase_auth_admin; - -revoke execute - on function public.hook_notify_user_on_failed_attempts - from authenticated, anon, public; - -grant all - on table public.password_sign_in_attempts - to supabase_auth_admin; - -revoke all - on table public.password_sign_in_attempts - from authenticated, anon, public; -``` - - - - -## Hook: Custom access token - -The custom access token hook runs before a token is issued and allows you to add additional claims based on the authentication method used. - -Claims returned must conform to our specification. Supabase Auth will check for these claims after the hook is run and return an error if they are not present. - -These are the fields currently available on an access token: - -Required Claims: `aud`, `exp`, `iat`, `sub`, `email`, `phone`, `role`, `aal`, `session_id` -Optional Claims: `jti`, `iss`, `nbf`, `app_metadata`, `user_metadata`, `amr` - -**Inputs** - -- `user_id` Unique identifier for the user attempting to sign in. Correlate this to the `auth.users` table. -- `claims` Claims which are attached to the access token. -- `authentication_method` the authentication method used to request the access token. Possible values include: - `oauth`, `password`, `otp`, `totp`, `recovery`, `invite`, `sso/saml`, `magiclink`, `email/signup`, `email_change`, `token_refresh`. - -**Outputs** - -Return these only if your hook processed the input without errors. - -- `claims` A json containing the updated claims after the hook has been run. - -**Examples** - - - - -You can allow registered admin users to perform restricted actions by granting an `admin` claim to their token. - -Create a profiles table with an `is_admin` flag: - -```sql -create table profiles ( - user_id uuid not null primary key references auth.users (id), - is_admin boolean not null default false -); -``` - -Create a hook: - -```sql -create or replace function public.custom_access_token_hook(event jsonb) -returns jsonb -language plpgsql -as $$ - declare - claims jsonb; - is_admin boolean; - begin - -- Check if the user is marked as admin in the profiles table - select is_admin into is_admin from profiles where user_id = (event->>'user_id')::uuid; - - -- Proceed only if the user is an admin - if is_admin then - claims := event->'claims'; - - -- Check if 'app_metadata' exists in claims - if jsonb_typeof(claims->'app_metadata') is null then - -- If 'app_metadata' does not exist, create an empty object - claims := jsonb_set(claims, '{app_metadata}', '{}'); - end if; - - -- Set a claim of 'admin' - claims := jsonb_set(claims, '{app_metadata, admin}', 'true'); - - -- Update the 'claims' object in the original event - event := jsonb_set(event, '{claims}', claims); - end if; - - -- Return the modified or original event - return event; - end; -$$; - -grant execute - on function public.custom_access_token_hook - to supabase_auth_admin; - -revoke execute - on function public.custom_access_token_hook - from authenticated, anon, public; - -grant all - on table public.profiles - to supabase_auth_admin; - -revoke all - on table public.profiles - from authenticated, anon, public; -``` - - - -You can add custom claims to the access token. In this example, we fetch the level of an existing user from the `profiles` table and assign new users a default level of zero. - -For convenience, we enable the `plv8` extension under [`Database > Extensions`](/dashboard/project/_/database/extensions) to run JavaScript within our query. - -Create a `profiles` table to hold information: - -```sql -create table profiles ( - user_id uuid not null primary key references auth.users (id), - level integer, - is_admin boolean not null default false -); -``` - -```javascript -create or replace function custom_access_token_hook(event jsonb) -returns jsonb -language plv8 -as $$ - var user_level; - - // Fetch the current user's level from the profiles table - var result = plv8.execute("select level from public.profiles where user_id = $1", [event.user_id]); - if (result.length > 0) { - user_level = result[0].level; - } else { - // Assign a default level of 0 if no level is found - user_level = 0; - } - - // Check if 'claims' exists in the event object; if not, initialize it - if (!event.claims) { - event.claims = {}; - } - - // Update the level in the claims - event.claims.level = user_level; - - return event; -$$; - -grant all - on table public.profiles - to supabase_auth_admin; - -revoke all - on table public.profiles - from authenticated, anon, public; -``` - - - -You can restrict access to internal applications with a hook. For example, you can require that employees log in via [SAML Single Sign On (SSO)](/docs/guides/auth/sso/auth-sso-saml). You can exempt select employees from the policy via an allowlist. - -```sql -create or replace function public.restrict_application_access(event jsonb) - returns jsonb - language plpgsql -as $function$ -declare - authentication_method text; - email_claim text; - allowed_emails text[] := array['myemail@company.com', 'example@company.com']; -begin - -- Extract email claim and authentication method - email_claim = event->'claims'->>'email'; - authentication_method = event->'authentication_method'; - -- Authentication methods come double quoted (e.g. "otp") - authentication_method = replace(authentication_method, '"', ''); - - if email_claim ilike '%@supabase.io' or authentication_method = 'sso/saml' or email_claim = any(allowed_emails) then - return event; - end if; - - -- If none of the conditions are met, return an error - return jsonb_build_object( - 'error', jsonb_build_object( - 'http_code', 403, - 'message', 'Staging access is only allowed to team members. Please use your @company.com account instead' - ) - ); -end; -$function$ -; --- manually added -grant execute - on function public.restrict_application_access - to supabase_auth_admin; - -revoke execute - on function public.restrict_application_access - from authenticated, anon, public; -``` - - - - -## Local Development - -Unlike the hosted platform, the local development dashboard does not have a user interface for linking a Hook. Instead, edit `config.toml` to link an Auth Hook and run it with your local setup. Modify the `auth.hook.` field and set `uri` to a value of `pg-functions://postgres//` - -For instance, the `config.toml` for a Custom Access Token Hook which uses the function `public.custom_access_token_hook`: - -``` -... - -[auth.hook.custom_access_token] -enabled = true -uri = "pg-functions://postgres/public/custom_access_token_hook" - -... -``` - -Save your Auth Hook as a migration in order to version the Auth Hook and share it with other team members. Run [`supabase migration new`](https://supabase.com/docs/reference/cli/supabase-migration-new) to create a migration. - -### Debugging Your Hook - -Test the Postgres Function in isolation before enabling the hook by connecting to your database with `psql` and running `select `. Where necessary, use [`RAISE` statements](https://www.postgresql.org/docs/current/plpgsql-errors-and-messages.html) to print information to the terminal. +Outside of runtime errors, both HTTP Hooks and Postgres Hooks return timeout errors. Postgres Hooks have auth.hook_timeouts.postgres_hooks seconds to complete processing while HTTP Hooks should complete in auth.hook_timeouts.http_hooks seconds. Both HTTP Hooks and Postgres Hooks are run in a transaction do limit the duration of execution to avoid delays in authentication process. + +## Available Hooks + +Each Hook description contains an example JSONSchema which you can use in conjunction with [JSONSchema Faker](https://json-schema-faker.js.org/) in order to generate a mock payload. For HTTP Hooks, you can also use [the Standard Webhooks Testing Tool](https://www.standardwebhooks.com/simulate) to simulate a request. + +
    + {[ + { + name: 'Custom Access Token', + description: 'Customize the access token issued by Supabase Auth', + href: '/guides/auth/auth-hooks/custom-access-token-hook', + }, + { + name: 'Send SMS', + description: 'Use a custom SMS provider to send authentication messages', + href: '/guides/auth/auth-hooks/send-sms-hook', + }, + { + name: 'Send Email', + description: 'Use a custom email provider to send authentication messages', + href: '/guides/auth/auth-hooks/send-email-hook', + }, + { + name: 'MFA Verification', + description: 'Add additional checks to the MFA verification flow', + href: '/guides/auth/auth-hooks/mfa-verification-hook', + }, + { + name: 'Password verification', + description: 'Add additional checks to the password verification flow', + href: '/guides/auth/auth-hooks/password-verification-hook', + }, + ].map((x) => ( +
    + + {x.description} + +
    + ))} +
    diff --git a/apps/docs/content/guides/auth/auth-hooks/custom-access-token-hook.mdx b/apps/docs/content/guides/auth/auth-hooks/custom-access-token-hook.mdx new file mode 100644 index 00000000000..70f151d9f52 --- /dev/null +++ b/apps/docs/content/guides/auth/auth-hooks/custom-access-token-hook.mdx @@ -0,0 +1,483 @@ +--- +id: 'custom-access-token-hook' +title: 'Custom Access Token Hook' +subtitle: 'Customize the access token issued by Supabase Auth' +--- + +The custom access token hook runs before a token is issued and allows you to add additional claims based on the authentication method used. + +Claims returned must conform to our specification. Supabase Auth will check for these claims after the hook is run and return an error if they are not present. + +These are the fields currently available on an access token: + +Required Claims: `aud`, `exp`, `iat`, `sub`, `email`, `phone`, `role`, `aal`, `session_id` +Optional Claims: `jti`, `iss`, `nbf`, `app_metadata`, `user_metadata`, `amr` + +**Inputs** + +| Field | Type | Description | +| ----------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `user_id` | `string` | Unique identifier for the user attempting to sign in. | +| `claims` | `object` | Claims which are included in the access token. | +| `authentication_method` | `string` | The authentication method used to request the access token. Possible values include: `oauth`, `password`, `otp`, `totp`, `recovery`, `invite`, `sso/saml`, `magiclink`, `email/signup`, `email_change`, `token_refresh`, `anonymous`. | + + + + +```json +{ + "user_id": "8ccaa7af-909f-44e7-84cb-67cdccb56be6", + "claims": { + "aud": "authenticated", + "exp": 1715690221, + "iat": 1715686621, + "sub": "8ccaa7af-909f-44e7-84cb-67cdccb56be6", + "email": "", + "phone": "", + "app_metadata": {}, + "user_metadata": {}, + "role": "authenticated", + "aal": "aal1", + "amr": [ { "method": "anonymous", "timestamp": 1715686621 } ], + "session_id": "4b938a09-5372-4177-a314-cfa292099ea2", + "is_anonymous": true + }, + "authentication_method": "anonymous" +} +``` + + + +```json +{ + "type": "object", + "properties": { + "user_id": { + "type": "string", + "x-faker": "random.uuid" + }, + "claims": { + "type": "object", + "properties": { + "aud": { + "type": "string", + "x-faker": "random.word" + }, + "exp": { + "type": "integer", + "x-faker": "date.future" + }, + "iat": { + "type": "integer", + "x-faker": "date.past" + }, + "sub": { + "type": "string", + "x-faker": "random.uuid" + }, + "email": { + "type": "string", + "x-faker": "internet.email" + }, + "phone": { + "type": "string", + "x-faker": { + "fake": "{{phone.phoneNumber('+1##########')}}" + } + }, + "app_metadata": { + "type": "object", + "x-faker": "random.objectElement" + }, + "user_metadata": { + "type": "object", + "x-faker": "random.objectElement" + }, + "role": { + "type": "string", + "enum": ["anon", "authenticated"] + }, + "aal": { + "type": "string", + "enum": ["aal1", "aal2", "aal3"] + }, + "amr": { + "type": "array", + "items": { + "type": "object", + "properties": { + "method": { + "type": "string", + "enum": [ + "oauth", + "password", + "otp", + "totp", + "recovery", + "invite", + "sso/saml", + "magiclink", + "email/signup", + "email_change", + "token_refresh", + "anonymous" + ] + }, + "timestamp": { + "type": "integer", + "x-faker": "date.past" + } + }, + "required": ["method", "timestamp"] + } + }, + "session_id": { + "type": "string", + "x-faker": "random.uuid" + }, + "is_anonymous": { + "type": "boolean", + "x-faker": "random.boolean" + } + }, + "required": [ + "aud", + "exp", + "iat", + "sub", + "email", + "phone", + "app_metadata", + "user_metadata", + "role", + "aal", + "amr", + "session_id", + "is_anonymous" + ] + }, + "authentication_method": { + "type": "string", + "enum": [ + "oauth", + "password", + "otp", + "totp", + "recovery", + "invite", + "sso/saml", + "magiclink", + "email/signup", + "email_change", + "token_refresh", + "anonymous" + ] + } + }, + "required": ["user_id", "claims", "authentication_method"] +} +``` + + + + +**Outputs** + +Return these only if your hook processed the input without errors. + +| Field | Type | Description | +| -------- | -------- | ----------------------------------------------- | +| `claims` | `object` | The updated claims after the hook has been run. | + + + + + + +You can allow registered admin users to perform restricted actions by granting an `admin` claim to their token. + +Create a profiles table with an `is_admin` flag: + +```sql +create table profiles ( + user_id uuid not null primary key references auth.users (id), + is_admin boolean not null default false +); +``` + +Create a hook: + +```sql +create or replace function public.custom_access_token_hook(event jsonb) +returns jsonb +language plpgsql +as $$ + declare + claims jsonb; + is_admin boolean; + begin + -- Check if the user is marked as admin in the profiles table + select is_admin into is_admin from profiles where user_id = (event->>'user_id')::uuid; + + -- Proceed only if the user is an admin + if is_admin then + claims := event->'claims'; + + -- Check if 'app_metadata' exists in claims + if jsonb_typeof(claims->'app_metadata') is null then + -- If 'app_metadata' does not exist, create an empty object + claims := jsonb_set(claims, '{app_metadata}', '{}'); + end if; + + -- Set a claim of 'admin' + claims := jsonb_set(claims, '{app_metadata, admin}', 'true'); + + -- Update the 'claims' object in the original event + event := jsonb_set(event, '{claims}', claims); + end if; + + -- Return the modified or original event + return event; + end; +$$; + +grant all + on table public.profiles + to supabase_auth_admin; + +revoke all + on table public.profiles + from authenticated, anon, public; +``` + + + +You can add custom claims to the access token. In this example, we fetch the level of an existing user from the `profiles` table and assign new users a default level of zero. + +For convenience, we enable the `plv8` extension under [`Database > Extensions`](/dashboard/project/_/database/extensions) to run JavaScript within our query. + +Create a `profiles` table to hold information: + +```sql +create table profiles ( + user_id uuid not null primary key references auth.users (id), + level integer, + is_admin boolean not null default false +); +``` + +```sql +create or replace function custom_access_token_hook(event jsonb) +returns jsonb +language plv8 +as $$ + var user_level; + + -- Fetch the current user's level from the profiles table + var result = plv8.execute("select level from public.profiles where user_id = $1", [event.user_id]); + if (result.length > 0) { + user_level = result[0].level; + } else { + -- Assign a default level of 0 if no level is found + user_level = 0; + } + + -- Check if 'claims' exists in the event object; if not, initialize it + if (!event.claims) { + event.claims = {}; + } + + -- Update the level in the claims + event.claims.level = user_level; + + return event; +$$; + +grant all + on table public.profiles + to supabase_auth_admin; + +revoke all + on table public.profiles + from authenticated, anon, public; +``` + + + +You can restrict access to internal applications with a hook. For example, you can require that employees log in via [SAML Single Sign On (SSO)](/docs/guides/auth/sso/auth-sso-saml). You can exempt select employees from the policy via an allowlist. + +```sql +create or replace function public.restrict_application_access(event jsonb) + returns jsonb + language plpgsql +as $function$ +declare + authentication_method text; + email_claim text; + allowed_emails text[] := array['myemail@company.com', 'example@company.com']; +begin + -- Extract email claim and authentication method + email_claim = event->'claims'->>'email'; + authentication_method = event->'authentication_method'; + -- Authentication methods come double quoted (e.g. "otp") + authentication_method = replace(authentication_method, '"', ''); + + if email_claim ilike '%@supabase.io' or authentication_method = 'sso/saml' or email_claim = any(allowed_emails) then + return event; + end if; + + -- If none of the conditions are met, return an error + return jsonb_build_object( + 'error', jsonb_build_object( + 'http_code', 403, + 'message', 'Staging access is only allowed to team members. Please use your @company.com account instead' + ) + ); +end; +$function$ +; +-- manually added +grant execute + on function public.restrict_application_access + to supabase_auth_admin; + +revoke execute + on function public.restrict_application_access + from authenticated, anon, public; +``` + + + + + + + + +Your company wishes to add assign permissions via the role claim on the `app_metadata` field. Add the role claim to the token via a Hook. + +```javascript +import { Webhook } from 'https://esm.sh/standardwebhooks@1.0.0' +import { readAll } from 'https://deno.land/std/io/read_all.ts' +import * as base64 from 'https://denopkg.com/chiefbiiko/base64/mod.ts' + +Deno.serve(async (req) => { + const payload = await req.text() + const base64_secret = Deno.env.get('AUTH_CUSTOM_ACCESS_TOKEN_SECRET') + const headers = Object.fromEntries(req.headers) + const wh = new Webhook(base64_secret) + try { + const { user_id, claims, authentication_method } = wh.verify(payload, headers) + if (claims.app_metadata && claims.app_metadata.role) { + claims['role'] = claims.app_metadata.role + } + return new Response( + JSON.stringify({ + claims, + }), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + } + ) + } catch (error) { + return new Response( + JSON.stringify({ + error: `Failed to process the request: ${error}`, + }), + { + status: 500, + headers: { + 'Content-Type': 'application/json', + }, + } + ) + } +}) +``` + + + +You can restrict access to internal applications with a hook. For example, you can require that employees log in via [SAML Single Sign On (SSO)](/docs/guides/auth/sso/auth-sso-saml). You can exempt select employees from the policy via an allowlist. + +```javascript +import { Webhook } from 'https://esm.sh/standardwebhooks@1.0.0' +import { readAll } from 'https://deno.land/std/io/read_all.ts' +import * as base64 from 'https://denopkg.com/chiefbiiko/base64/mod.ts' + +Deno.serve(async (req) => { + const payload = await req.text() + const base64_secret = Deno.env.get('AUTH_CUSTOM_ACCESS_TOKEN_SECRET') + const headers = Object.fromEntries(req.headers) + const wh = new Webhook(base64_secret) + try { + const { user_id, claims, authentication_method } = wh.verify(payload, headers) + + // Check the condition + const allowedEmails = ['myemail@company.com', 'example@company.com'] + if (authentication_method === 'sso/saml' || allowedEmails.includes(claims.email)) { + return new Response( + JSON.stringify({ + claims, + }), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + } + ) + } else { + return new Response( + JSON.stringify({ + error: 'Unauthorized', + }), + { + status: 500, + headers: { + 'Content-Type': 'application/json', + }, + } + ) + } + } catch (error) { + return new Response( + JSON.stringify({ + error: `Failed to process the request: ${error}`, + }), + { + status: 500, + headers: { + 'Content-Type': 'application/json', + }, + } + ) + } +}) +``` + + + + + diff --git a/apps/docs/content/guides/auth/auth-hooks/mfa-verification-hook.mdx b/apps/docs/content/guides/auth/auth-hooks/mfa-verification-hook.mdx new file mode 100644 index 00000000000..bbaf4cc924b --- /dev/null +++ b/apps/docs/content/guides/auth/auth-hooks/mfa-verification-hook.mdx @@ -0,0 +1,174 @@ +--- +id: 'mfa-verification-hook' +title: 'MFA Verification Hook' +--- + +You can add additional checks to the [Supabase MFA implementation](/docs/guides/auth/auth-mfa) with hooks. For example, you can: + +- Limit the number of verification attempts performed over a period of time. +- Sign out users who have too many invalid verification attempts. +- Count, rate limit, or ban sign-ins. + +**Inputs** + +Supabase Auth will send a payload containing these fields to your hook: + +| Field | Type | Description | +| ------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `factor_id` | `string` | Unique identifier for the MFA factor being verified | +| `factor_type` | `string` | `totp` or `phone` | +| `user_id` | `string` | Unique identifier for the user | +| `valid` | `boolean` | Whether the verification attempt was valid. For TOTP, this means that the six digit code was correct (true) or incorrect (false). | + + + + +```json +{ + "factor_id": "6eab6a69-7766-48bf-95d8-bd8f606894db", + "user_id": "3919cb6e-4215-4478-a960-6d3454326cec", + "valid": true +} +``` + + + + + +```json +{ + "type": "object", + "properties": { + "user_id": { + "type": "string", + "x-faker": "random.uuid" + }, + "valid": { + "type": "boolean", + "x-faker": "random.boolean" + } + }, + "required": ["user_id", "valid"] +} +``` + + + + +**Outputs** + +Return this if your hook processed the input without errors. + +| Field | Type | Description | +| ---------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `decision` | `string` | The decision on whether to allow authentication to move forward. Use `reject` to deny the verification attempt and log the user out of all active sessions. Use `continue` to use the default Supabase Auth behavior. | +| `message` | `string` | The message to show the user if the decision was `reject`. | + +```json +{ + "decision": "reject", + "message": "You have exceeded maximum number of MFA attempts." +} +``` + + + + + + +Your company requires that a user can input an incorrect MFA Verification code no more than once every 2 seconds. + +Create a table to record the last time a user had an incorrect MFA verification attempt for a factor. + +```sql +create table public.mfa_failed_verification_attempts ( + user_id uuid not null, + factor_id uuid not null, + last_failed_at timestamp not null default now(), + primary key (user_id, factor_id) +); +``` + +Create a hook to read and write information to this table. For example: + +```sql +create function public.hook_mfa_verification_attempt(event jsonb) + returns jsonb + language plpgsql +as $$ + declare + last_failed_at timestamp; + begin + if event->'valid' is true then + -- code is valid, accept it + return jsonb_build_object('decision', 'continue'); + end if; + + select last_failed_at into last_failed_at + from public.mfa_failed_verification_attempts + where + user_id = event->'user_id' + and + factor_id = event->'factor_id'; + + if last_failed_at is not null and now() - last_failed_at < interval '2 seconds' then + -- last attempt was done too quickly + return jsonb_build_object( + 'error', jsonb_build_object( + 'http_code', 429, + 'message', 'Please wait a moment before trying again.' + ) + ); + end if; + + -- record this failed attempt + insert into public.mfa_failed_verification_attempts + ( + user_id, + factor_id, + last_refreshed_at + ) + values + ( + event->'user_id', + event->'factor_id', + now() + ) + on conflict do update + set last_refreshed_at = now(); + + -- finally let Supabase Auth do the default behavior for a failed attempt + return jsonb_build_object('decision', 'continue'); + end; +$$; + +-- Assign appropriate permissions and revoke access +grant all + on table public.mfa_failed_verification_attempts + to supabase_auth_admin; + +revoke all + on table public.mfa_failed_verification_attempts + from authenticated, anon, public; +``` + + + + + diff --git a/apps/docs/content/guides/auth/auth-hooks/password-verification-hook.mdx b/apps/docs/content/guides/auth/auth-hooks/password-verification-hook.mdx new file mode 100644 index 00000000000..b1d262b6f3d --- /dev/null +++ b/apps/docs/content/guides/auth/auth-hooks/password-verification-hook.mdx @@ -0,0 +1,279 @@ +--- +id: 'password-verification-hook' +title: 'Password Verification Hook' +--- + +Your company wishes to increase security beyond the requirements of the default password implementation in order to fulfill security or compliance requirements. You plan to track the status of a password sign-in attempt and take action via an email or a restriction on logins where necessary. + +As this hook runs on unauthenticated requests, malicious users can abuse the hook by calling it multiple times. Pay extra care when using the hook as you can unintentionally block legitimate users from accessing your application. + +Check if a password is valid prior to taking any additional action to ensure the user is legitimate. Where possible, send an email or notification instead of blocking the user. + +**Inputs** + +| Field | Type | Description | +| --------- | --------- | ----------------------------------------------------------------------------------------------- | +| `user_id` | `string` | Unique identifier for the user attempting to sign in. Correlate this to the `auth.users` table. | +| `valid` | `boolean` | Whether the password verification attempt was valid. | + + + + +```json +{ + "user_id": "3919cb6e-4215-4478-a960-6d3454326cec", + "valid": true +} +``` + + + + +```json +{ + "type": "object", + "properties": { + "user_id": { + "type": "string", + "x-faker": "random.uuid" + }, + "valid": { + "type": "boolean", + "x-faker": "random.boolean" + } + }, + "required": ["user_id", "valid"] +} +``` + + + + +**Outputs** + +Return these only if your hook processed the input without errors. + +| Field | Type | Description | +| -------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `decision` | `string` | The decision on whether to allow authentication to move forward. Use `reject` to deny the verification attempt and log the user out of all active sessions. Use `continue` to use the default Supabase Auth behavior. | +| `message` | `string` | The message to show the user if the decision was `reject`. | +| `should_logout_user` | `boolean` | Whether to log out the user if a `reject` decision is issued. Has no effect when a `continue` decision is issued. | + +```json +{ + "decision": "reject", + "message": "You have exceeded maximum number of password sign-in attempts.", + "should_logout_user": "false" +} +``` + + + + + + +As part of new security measures within the company, users can only input an incorrect password every 10 seconds and not more than that. You want to write a hook to enforce this. + +Create a table to record each user's last incorrect password verification attempt. + +```sql +create table public.password_failed_verification_attempts ( + user_id uuid not null, + last_failed_at timestamp not null default now(), + primary key (user_id) +); +``` + +Create a hook to read and write information to this table. For example: + +```sql +create function public.hook_password_verification_attempt(event jsonb) +returns jsonb +language plpgsql +as $$ + declare + last_failed_at timestamp; + begin + if event->'valid' is true then + -- password is valid, accept it + return jsonb_build_object('decision', 'continue'); + end if; + + select last_failed_at into last_failed_at + from public.password_failed_verification_attempts + where + user_id = event->'user_id'; + + if last_failed_at is not null and now() - last_failed_at < interval '10 seconds' then + -- last attempt was done too quickly + return jsonb_build_object( + 'error', jsonb_build_object( + 'http_code', 429, + 'message', 'Please wait a moment before trying again.' + ) + ); + end if; + + -- record this failed attempt + insert into public.password_failed_verification_attempts + ( + user_id, + last_failed_at + ) + values + ( + event->'user_id', + now() + ) + on conflict do update + set last_failed_at = now(); + + -- finally let Supabase Auth do the default behavior for a failed attempt + return jsonb_build_object('decision', 'continue'); + end; +$$; + +-- Assign appropriate permissions +grant all + on table public.password_failed_verification_attempts + to supabase_auth_admin; + +revoke all + on table public.password_failed_verification_attempts + from authenticated, anon, public; +``` + + + + +You can notify a user via email instead of blocking the user. To do so, make use of [Supabase Vault](/docs/guides/database/vault) to store the API Key of our mail provider and use [`pg_net`](/docs/guides/database/extensions/pg_net) to send a HTTP request to our email provider to send the email. Ensure that you have configured a sender signature for the email account which you are sending emails from. + +First, create a table to track sign in attempts. + +```sql +create table public.password_sign_in_attempts ( + user_id uuid not null, + attempt_id uuid not null, + last_attempt_at timestamp not null default now(), + attempt_successful boolean not null, + primary key (user_id, attempt_id) +); +``` + +Next, store the API key of our email API provider: + +```sql +select vault.create_secret('my_api_key', 'my_api_key_name', 'description_of_my_api_key'); +``` + +Create the hook: + +```sql +create or replace function public.hook_notify_user_on_failed_attempts(event jsonb) +returns jsonb +language plpgsql +as $$ + declare + user_id uuid; + server_token text; + user_email_address text; + email_body jsonb; + response_id int; -- Variable to store the response ID + http_code int; + error_message jsonb; + attempt_count int; + max_attempts int := 5; -- Set the threshold for failed attempts + begin + user_id := (event->>'user_id')::uuid; + + -- Record the attempt + insert into public.password_sign_in_attempts (user_id, attempt_id, last_attempt_at, attempt_successful) + values (user_id, (event->>'attempt_id')::uuid, now(), (event->>'valid')::boolean) + on conflict (user_id, attempt_id) + do update set last_attempt_at = now(), attempt_successful = (event->>'valid')::boolean; + + -- Check failed attempts and fetch user email + select count(*), u.email into attempt_count, user_email_address + from public.password_sign_in_attempts a + join auth.users u on a.user_id = u.id + where a.user_id = user_id and attempt_successful = false and last_attempt_at > (now() - interval '1 day'); + + -- Notify user if the number of failed attempts exceeds the threshold + if attempt_count >= max_attempts then + -- Fetch the server token + select decrypted_secret into server_token from vault.decrypted_secrets where name = 'my_api_key_name'; + + -- Prepare the email body + email_body := format('{ + "from": "yoursenderemail@example.com", + "to": "%s", + "subject": "Security Alert: Repeated Login Attempts Detected", + "textbody": "We have detected repeated login attempts for your account. If this was not you, please secure your account.", + "htmlbody": "Security Alert: We have detected repeated login attempts for your account. If this was not you, please secure your account.", + "messagestream": "outbound" + }', user_email_address)::jsonb; + + -- Perform the HTTP POST request using Postmark + select id into response_id from net.http_post( + 'https://api.youremailprovider.com/email', + email_body, + 'application/json', + array['Accept: application/json', 'X-Postmark-Server-Token: ' || server_token] + ); + + -- Fetch the response from net._http_response using the obtained id + select status_code, content into http_code, error_message from net._http_response where id = response_id; + + -- Handle email sending errors + if http_code is null or (http_code < 200 or http_code >= 300) then + return jsonb_build_object( + 'error', jsonb_build_object( + 'http_code', coalesce(http_code, 0), + 'message', coalesce(error_message ->> 'message', 'error sending email') + ) + ); + end if; + end if; + + -- Continue with default behavior + return jsonb_build_object('decision', 'continue'); + end; +$$; + +-- Assign appropriate permissions +grant execute + on function public.hook_notify_user_on_failed_attempts + to supabase_auth_admin; + +revoke execute + on function public.hook_notify_user_on_failed_attempts + from authenticated, anon, public; + +grant all + on table public.password_sign_in_attempts + to supabase_auth_admin; + +revoke all + on table public.password_sign_in_attempts + from authenticated, anon, public; +``` + + + + + diff --git a/apps/docs/content/guides/auth/auth-hooks/send-email-hook.mdx b/apps/docs/content/guides/auth/auth-hooks/send-email-hook.mdx new file mode 100644 index 00000000000..369a742fa03 --- /dev/null +++ b/apps/docs/content/guides/auth/auth-hooks/send-email-hook.mdx @@ -0,0 +1,839 @@ +--- +id: 'send-email-hook' +title: 'Send Email Hook' +subtitle: 'Use a custom email provider to send authentication messages' +--- + +The Send Email Hook runs before an email is sent and allows for flexibility around email sending. You can use this hook to configure a back-up email provider or add internationalization to your emails. + +**Inputs** + +| Field | Type | Description | +| ------- | ------------------------------------------------- | ---------------------------------------------------------------------------------- | +| `user` | [`User`](/docs/guides/auth/users#the-user-object) | The user attempting to sign in. | +| `email` | `object` | Metadata specific to the email sending process. Includes the OTP and `token_hash`. | + + + + +```json +{ + "user": { + "id": "8484b834-f29e-4af2-bf42-80644d154f76", + "aud": "authenticated", + "role": "authenticated", + "email": "john@soupbase.io", + "phone": "", + "app_metadata": { + "provider": "email", + "providers": ["email"] + }, + "user_metadata": { + "email": "john@soupbase.io", + "email_verified": false, + "phone_verified": false, + "sub": "8484b834-f29e-4af2-bf42-80644d154f76" + }, + "identities": [ + { + "identity_id": "bc26d70b-517d-4826-bce4-413a5ff257e7", + "id": "8484b834-f29e-4af2-bf42-80644d154f76", + "user_id": "8484b834-f29e-4af2-bf42-80644d154f76", + "identity_data": { + "email": "john@soupbase.io", + "email_verified": false, + "phone_verified": false, + "sub": "8484b834-f29e-4af2-bf42-80644d154f76" + }, + "provider": "email", + "last_sign_in_at": "2024-05-14T12:56:33.824231484Z", + "created_at": "2024-05-14T12:56:33.824261Z", + "updated_at": "2024-05-14T12:56:33.824261Z", + "email": "john@soupbase.io" + } + ], + "created_at": "2024-05-14T12:56:33.821567Z", + "updated_at": "2024-05-14T12:56:33.825595Z", + "is_anonymous": false + }, + "email_data": { + "token": "305805", + "token_hash": "7d5b7b1964cf5d388340a7f04f1dbb5eeb6c7b52ef8270e1737a58d0", + "redirect_to": "http://localhost:3000/", + "email_action_type": "signup", + "site_url": "http://localhost:9999", + "token_new": "", + "token_hash_new": "" + } +} +``` + + + + +```json +{ + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "id": { + "type": "string", + "x-faker": "random.uuid" + }, + "aud": { + "type": "string", + "enum": ["authenticated"] + }, + "role": { + "type": "string", + "enum": ["anon", "authenticated"] + }, + "email": { + "type": "string", + "x-faker": "internet.email" + }, + "phone": { + "type": "string", + "x-faker": { + "fake": "{{phone.phoneNumber('+1##########')}}" + } + }, + "app_metadata": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": ["email"] + }, + "providers": { + "type": "array", + "items": { + "type": "string", + "enum": ["email"] + }, + "minItems": 1, + "maxItems": 1 + } + } + }, + "user_metadata": { + "type": "object", + "properties": { + "email": { + "type": "string", + "x-faker": "internet.email" + }, + "email_verified": { + "type": "boolean", + "x-faker": "random.boolean" + }, + "phone_verified": { + "type": "boolean", + "x-faker": "random.boolean" + }, + "sub": { + "type": "string", + "x-faker": "random.uuid" + } + } + }, + "identities": { + "type": "array", + "items": { + "type": "object", + "properties": { + "identity_id": { + "type": "string", + "x-faker": "random.uuid" + }, + "id": { + "type": "string", + "x-faker": "random.uuid" + }, + "user_id": { + "type": "string", + "x-faker": "random.uuid" + }, + "identity_data": { + "type": "object", + "properties": { + "email": { + "type": "string", + "x-faker": "internet.email" + }, + "email_verified": { + "type": "boolean", + "x-faker": "random.boolean" + }, + "phone_verified": { + "type": "boolean", + "x-faker": "random.boolean" + }, + "sub": { + "type": "string", + "x-faker": "random.uuid" + } + } + }, + "provider": { + "type": "string", + "enum": ["email"] + }, + "last_sign_in_at": { + "type": "string", + "format": "date-time", + "x-faker": "date.recent" + }, + "created_at": { + "type": "string", + "format": "date-time", + "x-faker": "date.recent" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "x-faker": "date.recent" + }, + "email": { + "type": "string", + "x-faker": "internet.email" + } + }, + "required": [ + "identity_id", + "id", + "user_id", + "identity_data", + "provider", + "last_sign_in_at", + "created_at", + "updated_at", + "email" + ] + } + }, + "created_at": { + "type": "string", + "format": "date-time", + "x-faker": "date.recent" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "x-faker": "date.recent" + }, + "is_anonymous": { + "type": "boolean", + "x-faker": "random.boolean" + } + }, + "required": [ + "id", + "aud", + "role", + "email", + "phone", + "app_metadata", + "user_metadata", + "identities", + "created_at", + "updated_at", + "is_anonymous" + ] + }, + "email_data": { + "type": "object", + "properties": { + "token": { + "type": "string", + "pattern": "^[0-9]{6}$", + "x-faker": { + "fake": "{{helpers.replaceSymbols('######')}}" + } + }, + "token_hash": { + "type": "string", + "minLength": 16, + "maxLength": 30, + "x-faker": { + "fake": "{{random.alphaNumeric(30)}}" + } + }, + "redirect_to": { + "type": "string", + "x-faker": "internet.url" + }, + "email_action_type": { + "type": "string", + "enum": ["signup", "login", "reset_password"] + }, + "site_url": { + "type": "string", + "x-faker": "internet.url" + }, + "token_new": { + "type": "string", + "minLength": 16, + "maxLength": 30, + "x-faker": { + "fake": "{{random.alphaNumeric(30)}}" + } + }, + "token_hash_new": { + "type": "string", + "minLength": 16, + "maxLength": 30, + "x-faker": { + "fake": "{{random.alphaNumeric(30)}}" + } + } + }, + "required": [ + "token", + "token_hash", + "redirect_to", + "email_action_type", + "site_url", + "token_new", + "token_hash_new" + ] + } + }, + "required": ["user", "email_data"] +} +``` + + + + +**Outputs** + +- No outputs are required. An empty response with a status code of 200 is taken as a successful response. + + + + + +Your company uses a worker to manage all emails related jobs. For performance reasons, the messaging system sends emails in batches via a job queue. Instead of sending a message immediately, messages are queued and sent in periodic intervals via `pg_cron`. + +Create a table to store jobs + +```sql +create table job_queue ( + job_id uuid primary key default gen_random_uuid(), + job_data jsonb not null, + created_at timestamp default now(), + status text default 'pending', + priority int default 0, + retry_count int default 0, + max_retries int default 2, + scheduled_at timestamp default now() +); +``` + +Create the hook + +```sql +create or replace function send_email(event jsonb) returns void as $$ +declare + job_data jsonb; + scheduled_time timestamp; + priority int; +begin + -- Extract email details from the event JSON + job_data := jsonb_build_object( + 'email_action_type', event->'email_data'->>'email_action_type', + 'token_hash', event->'email_data'->>'token_hash', + 'token', event->'email_data'->>'token', + 'email', event->'user'->>'email' + ); + + -- Calculate the nearest 5-minute window for scheduled_time + scheduled_time := date_trunc('minute', now()) + interval '5 minute' * floor(extract('epoch' from (now() - date_trunc('minute', now())) / 60) / 5); + + -- Assign priority dynamically (example logic: higher priority for earlier scheduled time) + priority := extract('epoch' from (scheduled_time - now()))::int; + + insert into job_queue (job_data, priority, scheduled_at, max_retries) + values (job_data, priority, scheduled_time, 2); +end; +$$ language plpgsql; + +grant all + on table public.job_queue + to supabase_auth_admin; + +revoke all + on table public.job_queue + from authenticated, anon; +``` + +Create a function to periodically run and dequeue all jobs + +```sql +create or replace function dequeue_and_run_jobs() returns void as $$ +declare + job record; +begin + for job in + select * from job_queue + where status = 'pending' + and scheduled_at <= now() + order by priority desc, created_at + for update skip locked + loop + begin + -- add job processing logic here. + -- for demonstration, we'll just update the job status to 'completed'. + update job_queue + set status = 'completed' + where job_id = job.job_id; + + exception when others then + -- handle job failure and retry logic + if job.retry_count < job.max_retries then + update job_queue + set retry_count = retry_count + 1, + scheduled_at = now() + interval '1 minute' -- delay retry by 1 minute + where job_id = job.job_id; + else + update job_queue + set status = 'failed' + where job_id = job.job_id; + end if; + end; + end loop; +end; +$$ language plpgsql; + +grant execute + on function public.dequeue_and_run_jobs + to supabase_auth_admin; + +revoke execute + on function public.dequeue_and_run_jobs + from authenticated, anon; +``` + +Configure `pg_cron` to run the job on an interval. You can use a tool like [crontab.guru](https://crontab.guru/) to check that your job is running on an appropriate schedule. Ensure that `pg_cron` is enabled under `Database > Extensions` + +```sql +select + cron.schedule( + '* * * * *', -- this cron expression means every minute. + 'select dequeue_and_run_jobs();' + ); +``` + + + + + + + +You can configure [Resend](https://resend.com/) as the custom email provider through the "Send Email" hook. This allows you to take advantage of Resend's developer-friendly APIs to send emails and leverage [React Email](https://react.email/) for managing your email templates. If you want to send emails through the Supabase Resend integration, which uses Resend's SMTP server, check out [this integration](/partners/integrations/resend) instead. + +Create a `.env` file with the following environment variables: + +```bash +RESEND_API_KEY=your_resend_api_key +SEND_EMAIL_HOOK_SECRET= +``` + +Set the secrets in your Supabase project: + +```bash +$ supabase secret set --env-file .env +``` + +Create a new edge function: + +```bash +$ supabase functions new send-email +``` + +Add the following code to your edge function: + +```javascript +import { Webhook } from "https://esm.sh/standardwebhooks@1.0.0"; +import { Resend } from "npm:resend"; + +const resend = new Resend(Deno.env.get("RESEND_API_KEY") as string); +const hookSecret = Deno.env.get("SEND_EMAIL_HOOK_SECRET") as string; + +Deno.serve(async (req) => { + const payload = await req.text(); + const headers = Object.fromEntries(req.headers); + const wh = new Webhook(hookSecret); + try { + const { user, email_data } = wh.verify(payload, headers) as { + user: { + email: string; + }; + email_data: { + token: string; + token_hash: string; + redirect_to: string; + email_action_type: string; + site_url: string; + token_new: string; + token_hash_new: string; + }; + }; + + const { error } = await resend.emails.send({ + from: "welcome ", + to: [user.email], + subject: "Welcome to my site!", + text: `Confirm you signup with this code: ${email_data.token}`, + }); + if (error) { + throw error; + } + } catch (error) { + return new Response( + JSON.stringify({ + error: { + http_code: error.code, + message: error.message, + }, + }), + { + status: 401, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const responseHeaders = new Headers(); + responseHeaders.set("Content-Type", "application/json"); + return new Response(JSON.stringify({}), { + status: 200, + headers: responseHeaders, + }); +}); +``` + +Deploy your edge function and [configure it as a hook](/dashboard/project/_/auth/hooks): + +```bash +supabase functions deploy send-email --no-verify-jwt +``` + + + +Your company is expanding to France and Spain. As part of expansion efforts, the company would like to deliver internationalized email templates to best support local users in their native language. Ensure that you have configured `POSTMARK_SERVER_TOKEN` and `AUTH_SEND_EMAIL_HOOK_SECRET` in your `.env` file. + +```javascript +import { Webhook } from 'https://esm.sh/standardwebhooks@1.0.0' +import { readAll } from 'https://deno.land/std/io/read_all.ts' + +const postmarkEndpoint = 'https://api.postmarkapp.com/email' +// Replace this with your email +const FROM_EMAIL = 'myemail@gmail.com' + +// Email Subjects +const subjects = { + en: { + signup: 'Confirm Your Email', + recovery: 'Reset Your Password', + invite: 'You have been invited', + magic_link: 'Your Magic Link', + email_change: 'Confirm Email Change', + email_change_new: 'Confirm New Email Address', + reauthentication: 'Confirm Reauthentication', + }, + es: { + signup: 'Confirma tu correo electrónico', + recovery: 'Restablece tu contraseña', + invite: 'Has sido invitado', + magic_link: 'Tu enlace mágico', + email_change: 'Confirma el cambio de correo electrónico', + email_change_new: 'Confirma la Nueva Dirección de Correo', + reauthentication: 'Confirma la reautenticación', + }, + fr: { + signup: 'Confirmez votre adresse e-mail', + recovery: 'Réinitialisez votre mot de passe', + invite: 'Vous avez été invité', + magic_link: 'Votre Lien Magique', + email_change: 'Confirmez le changement d’adresse e-mail', + email_change_new: 'Confirmez la nouvelle adresse e-mail', + reauthentication: 'Confirmez la réauthentification', + }, +} + +// HTML Body +const templates = { + en: { + signup: `

    Confirm your email

    Follow this link to confirm your email:

    Confirm your email address

    Alternatively, enter the code: {{token}}

    `, + recovery: `

    Reset password

    Follow this link to reset the password for your user:

    Reset password

    Alternatively, enter the code: {{token}}

    `, + invite: `

    You have been invited

    You have been invited to create a user on {{site_url}}. Follow this link to accept the invite:

    Accept the invite

    Alternatively, enter the code: {{token}}

    `, + magic_link: `

    Magic Link

    Follow this link to login:

    Log In

    Alternatively, enter the code: {{token}}

    `, + email_change: `

    Confirm email address change

    Follow this link to confirm the update of your email address from {{old_email}} to {{new_email}}:

    Change email address

    Alternatively, enter the codes: {{token}} and {{new_token}}

    `, + email_change_new: `

    Confirm New Email Address

    Follow this link to confirm your new email address:

    Confirm new email address

    Alternatively, enter the code: {{new_token}}

    `, + reauthentication: `

    Confirm reauthentication

    Enter the code: {{token}}

    `, + }, + es: { + signup: `

    Confirma tu correo electrónico

    Sigue este enlace para confirmar tu correo electrónico:

    Confirma tu correo electrónico

    Alternativamente, ingresa el código: {{token}}

    `, + recovery: `

    Restablece tu contraseña

    Sigue este enlace para restablecer la contraseña de tu usuario:

    Restablece tu contraseña

    Alternativamente, ingresa el código: {{token}}

    `, + invite: `

    Has sido invitado

    Has sido invitado para crear un usuario en {{site_url}}. Sigue este enlace para aceptar la invitación:

    Aceptar la invitación

    Alternativamente, ingresa el código: {{token}}

    `, + magic_link: `

    Tu enlace mágico

    Sigue este enlace para iniciar sesión:

    Iniciar sesión

    Alternativamente, ingresa el código: {{token}}

    `, + email_change: `

    Confirma el cambio de correo electrónico

    Sigue este enlace para confirmar la actualización de tu correo electrónico de {{old_email}} a {{new_email}}:

    Cambiar correo electrónico

    Alternativamente, ingresa los códigos: {{token}} y {{new_token}}

    `, + email_change_new: `

    Confirma la Nueva Dirección de Correo

    Sigue este enlace para confirmar tu nueva dirección de correo electrónico:

    Confirma la nueva dirección de correo

    Alternativamente, ingresa el código: {{new_token}}

    `, + reauthentication: `

    Confirma la reautenticación

    Ingresa el código: {{token}}

    `, + }, + fr: { + signup: `

    Confirmez votre adresse e-mail

    Suivez ce lien pour confirmer votre adresse e-mail :

    Confirmez votre adresse e-mail

    Vous pouvez aussi saisir le code : {{token}}

    `, + recovery: `

    Réinitialisez votre mot de passe

    Suivez ce lien pour réinitialiser votre mot de passe :

    Réinitialisez votre mot de passe

    Vous pouvez aussi saisir le code : {{token}}

    `, + invite: `

    Vous avez été invité

    Vous avez été invité à créer un utilisateur sur {{site_url}}. Suivez ce lien pour accepter l'invitation :

    Acceptez l'invitation

    Vous pouvez aussi saisir le code : {{token}}

    `, + magic_link: `

    Votre Lien Magique

    Suivez ce lien pour vous connecter :

    Connectez-vous

    Vous pouvez aussi saisir le code : {{token}}

    `, + email_change: `

    Confirmez le changement d’adresse e-mail

    Suivez ce lien pour confirmer la mise à jour de votre adresse e-mail de {{old_email}} à {{new_email}} :

    Changez d’adresse e-mail

    Vous pouvez aussi saisir les codes : {{token}} et {{new_token}}

    `, + email_change_new: `

    Confirmez la nouvelle adresse e-mail

    Suivez ce lien pour confirmer votre nouvelle adresse e-mail :

    Confirmez la nouvelle adresse e-mail

    Vous pouvez aussi saisir le code : {{new_token}}

    `, + reauthentication: `

    Confirmez la réauthentification

    Saisissez le code : {{token}}

    `, + }, +} + +Deno.serve(async (req) => { + const payload = await req.text() + const serverToken = Deno.env.get('POSTMARK_SERVER_TOKEN') + const headers = Object.fromEntries(req.headers) + const base64_secret = Deno.env.get('AUTH_SEND_EMAIL_HOOK_SECRET') + const wh = new Webhook(base64_secret) + const { user, email_data } = wh.verify(payload, headers) + + const language = (user.user_metadata && user.user_metadata.i18n) || 'en' + const subject = subjects[language][email_data.email_action_type] || 'Notification' + + let template = templates[language][email_data.email_action_type] + let htmlBody = template + .replace('{{confirmation_url}}', email_data.confirmation_url) + .replace('{{token}}', email_data.token || '') + .replace('{{new_token}}', email_data.new_token || '') + .replace('{{site_url}}', email_data.site_url || '') + .replace('{{old_email}}', email_data.email || '') + .replace('{{new_email}}', email_data.new_email || '') + + const requestOptions = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-Postmark-Server-Token': serverToken, + }, + body: JSON.stringify({ + From: FROM_EMAIL, + To: user.email, + Subject: subject, + HtmlBody: htmlBody, + }), + } + + try { + const response = await fetch(postmarkEndpoint, requestOptions) + if (!response.ok) { + const errorData = await response.json() + throw new Error(`Failed to send email: ${errorData.Message}`) + } + return new Response( + JSON.stringify({ + message: 'Email sent successfully.', + }), + { + headers: { + 'Content-Type': 'application/json', + }, + } + ) + } catch (error) { + return new Response( + JSON.stringify({ + error: `Failed to process the request: ${error.message}`, + }), + { + status: 500, + headers: { + 'Content-Type': 'application/json', + }, + } + ) + } +}) +``` + +
    +{/* +Your company is rapidly growing and depends heavily on email signups. You'd like to configure a backup email provider in case the email provider runs out of credits during your new product launch. Postmark and Sengrid are used as examples but in practice you can use any email provider. +Ensure that you have configured `POSTMARK_SERVER_TOKEN`, `SENDGRID_API_KEY` and `AUTH_SEND_EMAIL_HOOK_SECRET` in your `.env` file. + +```javascript +import { + Webhook +} from "https://esm.sh/standardwebhooks@1.0.0"; +import { + readAll +} from "https://deno.land/std/io/read_all.ts"; + +const postmarkEndpoint = 'https://api.postmarkapp.com/email'; +const sendGridEndpoint = 'https://api.sendgrid.com/v3/mail/send'; +const FROM_EMAIL = 'myemail@gmail.com' + +// Email Subjects +const subjects = { + signup: 'Confirm Your Email', + recovery: 'Reset Your Password', + invite: 'You have been invited', + magic_link: 'Your Magic Link', + email_change: 'Confirm Email Change', + email_change_new: 'Confirm New Email Address', + reauthentication: 'Confirm Reauthentication' +}; + +// HTML Body +const templates = { + signup: `

    Confirm your email

    Follow this link to confirm your email:

    Confirm your email address

    Alternatively, enter the code: {{token}}

    `, + recovery: `

    Reset password

    Follow this link to reset the password for your user:

    Reset password

    Alternatively, enter the code: {{token}}

    `, + invite: `

    You have been invited

    You have been invited to create a user on {{site_url}}. Follow this link to accept the invite:

    Accept the invite

    Alternatively, enter the code: {{token}}

    `, + magic_link: `

    Magic Link

    Follow this link to login:

    Log In

    Alternatively, enter the code: {{token}}

    `, + email_change: `

    Confirm email address change

    Follow this link to confirm the update of your email address from {{old_email}} to {{new_email}}:

    Change email address

    Alternatively, enter the codes: {{token}} and {{new_token}}

    `, + email_change_new: `

    Confirm New Email Address

    Follow this link to confirm your new email address:

    Confirm new email address

    Alternatively, enter the code: {{new_token}}

    `, + reauthentication: `

    Confirm reauthentication

    Enter the code: {{token}}

    ` +}; + +async function sendEmailWithPostmark(user: any, email_data: any, serverToken: string): Promise { + const subject = subjects[email_data.email_action_type] || 'Notification'; + let template = templates[email_data.email_action_type]; + let htmlBody = template.replace('{{confirmation_url}}', email_data.confirmation_url) + .replace('{{token}}', email_data.token || '') + .replace('{{new_token}}', email_data.new_token || '') + .replace('{{site_url}}', email_data.site_url || '') + .replace('{{old_email}}', email_data.email || '') + .replace('{{new_email}}', email_data.new_email || ''); + + const requestOptions = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Postmark-Server-Token': serverToken + }, + body: JSON.stringify({ + From: FROM_EMAIL, + To: user.email, + Subject: subject, + HtmlBody: htmlBody + }) + }; + + return await fetch(postmarkEndpoint, requestOptions); +} + +async function sendEmailWithSendGrid(user: any, email_data: any, apiKey: string): Promise { + const subject = subjects[email_data.email_action_type] || 'Notification'; + let template = templates[email_data.email_action_type]; + let htmlBody = template.replace('{{confirmation_url}}', email_data.confirmation_url) + .replace('{{token}}', email_data.token || '') + .replace('{{new_token}}', email_data.new_token || '') + .replace('{{site_url}}', email_data.site_url || '') + .replace('{{old_email}}', email_data.email || '') + .replace('{{new_email}}', email_data.new_email || ''); + + const requestOptions = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}` + }, + body: JSON.stringify({ + personalizations: [{ + to: [{ + email: user.email + }], + subject: subject + }], + from: { + email: FROM_EMAIL + }, + content: [{ + type: "text/html", + value: htmlBody + }] + }) + }; + + return await fetch(sendGridEndpoint, requestOptions); +} + +Deno.serve(async (req) => { + const payload = await req.text(); + const postmarkServerToken = Deno.env.get("POSTMARK_SERVER_TOKEN"); + const sendGridApiKey = Deno.env.get("SENDGRID_API_KEY"); + const headers = Object.fromEntries(req.headers); + const base64_secret = Deno.env.get('AUTH_SEND_EMAIL_HOOK_SECRET'); + const wh = new Webhook(base64_secret); + const { + user, + email_data + } = wh.verify(payload, headers); + + try { + // Try sending email using Postmark + let response = await sendEmailWithPostmark(user, email_data, postmarkServerToken!); + + if (!response.ok) { + // If Postmark fails, try SendGrid + console.error(`Primary email send failed: ${await response.text()}`); + response = await sendEmailWithSendGrid(user, email_data, sendGridApiKey!); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(`Failed to send email via backup: ${errorData.errors[0].message}`); + } + } + + return new Response(JSON.stringify({ + message: "Email sent successfully." + }), { + headers: { + "Content-Type": "application/json" + } + }); + } catch (error) { + return new Response(JSON.stringify({ + error: `Failed to process the request: ${error.message}` + }), { + status: 500, + headers: { + "Content-Type": "application/json" + } + }); + } +}); +``` + +
    */} +
    +
    +
    diff --git a/apps/docs/content/guides/auth/auth-hooks/send-sms-hook.mdx b/apps/docs/content/guides/auth/auth-hooks/send-sms-hook.mdx new file mode 100644 index 00000000000..3a9a27cc63f --- /dev/null +++ b/apps/docs/content/guides/auth/auth-hooks/send-sms-hook.mdx @@ -0,0 +1,645 @@ +--- +id: 'send-sms-hook' +title: 'Send SMS Hook' +subtitle: 'Use a custom SMS provider to send authentication messages' +--- + +Runs before a message is sent. Use the hook to: + +- Use a regional SMS Provider +- Use alternate messaging channels such as WhatsApp +- Adjust the message body to include platform specific fields such as the [AppHash](https://developers.google.com/identity/sms-retriever/overview) + +**Inputs** + +| Field | Type | Description | +| ------ | ------------------------------------------------- | --------------------------------------------------------------- | +| `user` | [`User`](/docs/guides/auth/users#the-user-object) | The user attempting to sign in. | +| `sms` | `object` | Metadata specific to the SMS sending process. Includes the OTP. | + + + + +```json +{ + "user": { + "id": "6481a5c1-3d37-4a56-9f6a-bee08c554965", + "aud": "authenticated", + "role": "authenticated", + "email": "", + "phone": "+1333363128", + "phone_confirmed_at": "2024-05-13T11:52:48.157306Z", + "confirmation_sent_at": "2024-05-14T12:31:52.824573Z", + "confirmed_at": "2024-05-13T11:52:48.157306Z", + "phone_change_sent_at": "2024-05-13T11:47:02.183064Z", + "last_sign_in_at": "2024-05-13T11:52:48.162518Z", + "app_metadata": { + "provider": "phone", + "providers": ["phone"] + }, + "user_metadata": {}, + "identities": [ + { + "identity_id": "3be5e552-65aa-41d9-9db9-2a502f845459", + "id": "6481a5c1-3d37-4a56-9f6a-bee08c554965", + "user_id": "6481a5c1-3d37-4a56-9f6a-bee08c554965", + "identity_data": { + "email_verified": false, + "phone": "+1612341244428", + "phone_verified": true, + "sub": "6481a5c1-3d37-4a56-9f6a-bee08c554965" + }, + "provider": "phone", + "last_sign_in_at": "2024-05-13T11:52:48.155562Z", + "created_at": "2024-05-13T11:52:48.155599Z", + "updated_at": "2024-05-13T11:52:48.159391Z" + } + ], + "created_at": "2024-05-13T11:45:33.7738Z", + "updated_at": "2024-05-14T12:31:52.82475Z", + "is_anonymous": false + }, + "sms": { + "otp": "561166" + } +} +``` + + + + +```json +{ + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "id": { + "type": "string", + "x-faker": "random.uuid" + }, + "aud": { + "type": "string", + "enum": ["authenticated"] + }, + "role": { + "type": "string", + "enum": ["anon", "authenticated"] + }, + "email": { + "type": "string", + "x-faker": "internet.email" + }, + "phone": { + "type": "string", + "x-faker": { + "fake": "{{phone.phoneNumber('+1##########')}}" + } + }, + "phone_confirmed_at": { + "type": "string", + "format": "date-time", + "x-faker": "date.recent" + }, + "confirmation_sent_at": { + "type": "string", + "format": "date-time", + "x-faker": "date.recent" + }, + "confirmed_at": { + "type": "string", + "format": "date-time", + "x-faker": "date.recent" + }, + "phone_change_sent_at": { + "type": "string", + "format": "date-time", + "x-faker": "date.recent" + }, + "last_sign_in_at": { + "type": "string", + "format": "date-time", + "x-faker": "date.recent" + }, + "app_metadata": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": ["phone"] + }, + "providers": { + "type": "array", + "items": { + "type": "string", + "enum": ["phone"] + } + } + } + }, + "user_metadata": { + "type": "object", + "x-faker": "random.objectElement" + }, + "identities": { + "type": "array", + "items": { + "type": "object", + "properties": { + "identity_id": { + "type": "string", + "x-faker": "random.uuid" + }, + "id": { + "type": "string", + "x-faker": "random.uuid" + }, + "user_id": { + "type": "string", + "x-faker": "random.uuid" + }, + "identity_data": { + "type": "object", + "properties": { + "email_verified": { + "type": "boolean", + "x-faker": "random.boolean" + }, + "phone": { + "type": "string", + "x-faker": { + "fake": "{{phone.phoneNumber('+1##########')}}" + } + }, + "phone_verified": { + "type": "boolean", + "x-faker": "random.boolean" + }, + "sub": { + "type": "string", + "x-faker": "random.uuid" + } + } + }, + "provider": { + "type": "string", + "enum": ["phone", "email", "google"] + }, + "last_sign_in_at": { + "type": "string", + "format": "date-time", + "x-faker": "date.recent" + }, + "created_at": { + "type": "string", + "format": "date-time", + "x-faker": "date.recent" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "x-faker": "date.recent" + } + }, + "required": [ + "identity_id", + "id", + "user_id", + "identity_data", + "provider", + "last_sign_in_at", + "created_at", + "updated_at" + ] + } + }, + "created_at": { + "type": "string", + "format": "date-time", + "x-faker": "date.recent" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "x-faker": "date.recent" + }, + "is_anonymous": { + "type": "boolean", + "x-faker": "random.boolean" + } + }, + "required": [ + "id", + "aud", + "role", + "email", + "phone", + "phone_confirmed_at", + "confirmation_sent_at", + "confirmed_at", + "phone_change_sent_at", + "last_sign_in_at", + "app_metadata", + "user_metadata", + "identities", + "created_at", + "updated_at", + "is_anonymous" + ] + }, + "sms": { + "type": "object", + "properties": { + "otp": { + "type": "string", + "pattern": "^[0-9]{6}$", + "x-faker": { + "fake": "{{helpers.replaceSymbols(######)}}" + } + } + }, + "required": ["otp"] + } + }, + "required": ["user", "sms"] +} +``` + + + + +**Outputs** + +- No outputs are required. An empty response with a status code of 200 is taken as a successful response. + + + + + + +Your company uses a worker to manage all messaging related jobs. For performance reasons, the messaging system sends messages in intervals via a job queue. Instead of sending a message immediately, messages are queued and sent in periodic intervals via `pg_cron`. + +Create a table to store jobs + +```sql +create table job_queue ( + job_id uuid primary key default gen_random_uuid(), + job_data jsonb not null, + created_at timestamp default now(), + status text default 'pending', + priority int default 0, + retry_count int default 0, + max_retries int default 2, + scheduled_at timestamp default now() +); +``` + +Create the hook: + +```sql +create or replace function send_sms(event jsonb) returns void as $$ +declare + job_data jsonb; + scheduled_time timestamp; + priority int; +begin + -- extract phone and otp from the event json + job_data := jsonb_build_object( + 'phone', event->'user'->>'phone', + 'otp', event->'sms'->>'otp' + ); + + -- calculate the nearest 5-minute window for scheduled_time + scheduled_time := date_trunc('minute', now()) + interval '5 minute' * floor(extract('epoch' from (now() - date_trunc('minute', now())) / 60) / 5); + + -- assign priority dynamically (example logic: higher priority for earlier scheduled time) + priority := extract('epoch' from (scheduled_time - now()))::int; + + -- insert the job into the job_queue table + insert into job_queue (job_data, priority, scheduled_at, max_retries) + values (job_data, priority, scheduled_time, 2); +end; +$$ language plpgsql; + +grant all + on table public.job_queue + to supabase_auth_admin; + +revoke all + on table public.job_queue + from authenticated, anon; +``` + +Create a function to periodically run and dequeue all jobs + +```sql +create or replace function dequeue_and_run_jobs() returns void as $$ +declare + job record; +begin + for job in + select * from job_queue + where status = 'pending' + and scheduled_at <= now() + order by priority desc, created_at + for update skip locked + loop + begin + -- add job processing logic here. + -- for demonstration, we'll just update the job status to 'completed'. + update job_queue + set status = 'completed' + where job_id = job.job_id; + + exception when others then + -- handle job failure and retry logic + if job.retry_count < job.max_retries then + update job_queue + set retry_count = retry_count + 1, + scheduled_at = now() + interval '1 minute' -- delay retry by 1 minute + where job_id = job.job_id; + else + update job_queue + set status = 'failed' + where job_id = job.job_id; + end if; + end; + end loop; +end; +$$ language plpgsql; + +grant execute + on function public.dequeue_and_run_jobs + to supabase_auth_admin; + +revoke execute + on function public.dequeue_and_run_jobs + from authenticated, anon; +``` + +Configure `pg_cron` to run the job on an interval. You can use a tool like [crontab.guru](https://crontab.guru/) to check that your job is running on an appropriate schedule. Ensure that `pg_cron` is enabled under `Database > Extensions` + +```sql +select + cron.schedule( + '* * * * *', -- this cron expression means every minute. + 'select dequeue_and_run_jobs();' + ); +``` + + + + + + + +Your company would like to use an alternate message provider. Some examples of alternate message providers include [Msg91](https://msg91.com/) for India and [Africa's Talking](https://africastalking.com/). The example uses Twilio as it is widely available and does not require a regional number. + +```javascript +import { Webhook } from 'https://esm.sh/standardwebhooks@1.0.0' +import { readAll } from 'https://deno.land/std/io/read_all.ts' +import { Twilio } from 'https://cdn.skypack.dev/twilio' +import * as base64 from 'https://denopkg.com/chiefbiiko/base64/mod.ts' + +const accountSid: string | undefined = Deno.env.get('TWILIO_ACCOUNT_SID') +const authToken: string | undefined = Deno.env.get('TWILIO_AUTH_TOKEN') +const fromNumber: string = Deno.env.get('TWILIO_PHONE_NUMBER') + +const sendTextMessage = async ( + messageBody: string, + accountSid: string | undefined, + authToken: string | undefined, + fromNumber: string, + toNumber: string +): Promise => { + if (!accountSid || !authToken) { + console.log('Your Twilio account credentials are missing. Please add them.') + return + } + const url: string = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json` + + const encodedCredentials: string = base64.fromUint8Array( + new TextEncoder().encode(`${accountSid}:${authToken}`) + ) + + const body: URLSearchParams = new URLSearchParams({ + To: `+${toNumber}`, + From: fromNumber, + // Uncomment when testing with a fixed number + Body: messageBody, + }) + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${encodedCredentials}`, + }, + body, + }) + + return response.json() +} + +Deno.serve(async (req) => { + const payload = await req.text() + const base64_secret = Deno.env.get('SEND_SMS_HOOK_SECRET') + const headers = Object.fromEntries(req.headers) + const wh = new Webhook(base64_secret) + try { + const { user, sms } = wh.verify(payload, headers) + const messageBody = `Your OTP is: ${sms.otp}` + const response = await sendTextMessage( + messageBody, + accountSid, + authToken, + fromNumber, + user.phone + ) + if (response.status !== 'queued') { + return new Response( + JSON.stringify({ + error: { + http_code: response.code, + message: `Failed to send SMS: ${response.message}. More info: ${response.more_info}`, + }, + }), + { + status: response.status, + headers: { + 'Content-Type': 'application/json', + }, + } + ) + } + return new Response( + {}, + { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + } + ) + } catch (error) { + return new Response( + JSON.stringify({ + error: { + http_code: 500, + message: `Failed to send sms: ${JSON.stringify(error)}`, + } + }), + { + status: 500, + headers: { + 'Content-Type': 'application/json', + }, + } + ) + } +}) +``` + + + + +Your company is expanding into Latin America and would like to use WhatsApp for higher deliverability. Write a hook to send WhatsApp messages to requests from the continent and SMS messages to all other numbers. + +```javascript +import { + Webhook +} from "https://esm.sh/standardwebhooks@1.0.0"; +import { + readAll +} from "https://deno.land/std/io/read_all.ts"; +import * as base64 from "https://denopkg.com/chiefbiiko/base64/mod.ts"; + +const accountSid: string | undefined = Deno.env.get("TWILIO_ACCOUNT_SID"); +const authToken: string | undefined = Deno.env.get("TWILIO_AUTH_TOKEN"); +const fromNumber: string = Deno.env.get("TWILIO_WHATSAPP_NUMBER"); +const smsFromNumber: string = Deno.env.get("TWILIO_SMS_NUMBER"); + +const latinAmericanCountryCodes = ['54', '55', '56', '57', '58', '501', '502', '503', '504', '505', '506', '507', '508', '509', '51', '52', '53', '591', '592', '593', '594', '595', '596', '597', '598', '599']; + +const sendMessage = async ( + messageBody: string, + accountSid: string | undefined, + authToken: string | undefined, + fromNumber: string, + toNumber: string, + useWhatsApp: boolean, +): Promise < any > => { + if (!accountSid || !authToken) { + console.log("Your Twilio account credentials are missing. Please add them."); + return; + } + const url: string = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`; + + const encodedCredentials: string = base64.fromUint8Array( + new TextEncoder().encode(`${accountSid}:${authToken}`), + ); + + const body: URLSearchParams = new URLSearchParams({ + To: useWhatsApp ? `whatsapp:${toNumber}` : toNumber, + From: useWhatsApp ? `whatsapp:${fromNumber}` : smsFromNumber, + Body: messageBody, + }); + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": `Basic ${encodedCredentials}`, + }, + body, + }); + + return response.json(); +}; + +Deno.serve(async (req) => { + const payload = await req.text(); + const base64_secret = Deno.env.get("SEND_SMS_HOOK_SECRET"); + const headers = Object.fromEntries(req.headers); + const wh = new Webhook(base64_secret); + try { + const { + user, + sms + } = wh.verify(payload, headers); + const messageBody = `Your OTP is: ${sms.otp}`; + const userPhoneNumber = user.phone; + const countryCode = userPhoneNumber.substring(1, userPhoneNumber.indexOf(userPhoneNumber.match(/\d/)!)); + + const useWhatsApp = latinAmericanCountryCodes.includes(countryCode); + + const response = await sendMessage( + messageBody, + accountSid, + authToken, + fromNumber, + userPhoneNumber, + useWhatsApp, + ); + + if (response.status !== "queued") { + return new Response( + JSON.stringify({ + error: `Failed to send message, Error Code: ${response.code} ${response.message} ${response.more_info}`, + }), { + status: response.status, + headers: { + "Content-Type": "application/json", + }, + }, + ); + } + return new Response( + JSON.stringify({ + message: "Message sent successfully." + }), { + headers: { + "Content-Type": "application/json", + }, + }, + ); + } catch (error) { + return new Response( + JSON.stringify({ + error: `Failed to process the request: ${error}` + }), { + status: 500, + headers: { + "Content-Type": "application/json", + }, + }, + ); + } +}); +``` + + + + + diff --git a/apps/docs/content/guides/auth/auth-mfa.mdx b/apps/docs/content/guides/auth/auth-mfa.mdx index ca678317549..e2146375370 100644 --- a/apps/docs/content/guides/auth/auth-mfa.mdx +++ b/apps/docs/content/guides/auth/auth-mfa.mdx @@ -13,11 +13,11 @@ Users with weak passwords or compromised social login accounts are prone to mali - Something they know. Password, or access to a social-login account. - Something they have. - Access to an authenticator app (a.k.a. TOTP), mobile phone or recovery code. + Access to an authenticator app (a.k.a. TOTP) or a mobile phone. ## Overview -Supabase Auth implements only Time-based One Time Password(TOTP) multi-factor authentication. This type of multi-factor authentication uses a timed one-time password generated from an authenticator app in the control of users. The [MFA API](/docs/reference/javascript/auth-mfa-api) is enabled on all Supabase projects by default. +Supabase Auth implements MFA via two methods: App Authenticator, which makes use of a Time based-one Time Password, and phone messaging, which makes use of a code generated by Supabase Auth. Applications using MFA require two important flows: @@ -32,16 +32,7 @@ Supabase Auth provides: - **Challenge and Verify APIs** - securely verify that the user has access to a factor. - **List Factors API** - build rich user interfaces for signing in with additional factors. -Below is a flow chart illustrating how these APIs work together to enable MFA features in your app. - -Diagram showing the flow of Multi-Factor authentication +You can control access to the Enrollment API as well as the Challenge and Verify APIs via the Supabase Dashboard. A setting of `Verification Disabled` will disable both the challenge API and the verification API. These sets of APIs let you control the MFA experience that works for you. You can create flows where MFA is optional, mandatory for all, or only specific groups of users. @@ -55,7 +46,7 @@ This information is represented by an [Authenticator Assurance Level](https://pa login. 2. **Assurance Level 2: `aal2`** Means that the user's identity was additionally verified using at least one - second factor, such as a TOTP code. + second factor, such as a TOTP code or One-Time Password code. This assurance level is encoded in the `aal` claim in the JWT associated with the user. By decoding this value you can create custom authorization rules in your frontend, backend, and database that will enforce the MFA policy that works for your application. JWTs without an `aal` claim are at the `aal1` level. @@ -67,7 +58,7 @@ Adding MFA to your app involves these four steps: You need to provide a UI within your app that your users will be able to set-up MFA in. You can add this right after sign-up, or as part of a separate flow in the settings portion of your app. -2. **Add unenrollment flow.** +2. **Add unenroll flow.** You need to support a UI through which users can see existing devices and unenroll devices which are no longer relevant. 3. **Add challenge step to login.** @@ -79,140 +70,39 @@ Adding MFA to your app involves these four steps: authorization rules across your app: on the frontend, backend, API servers or Row-Level Security policies. -### Add enrollment flow +The enrollment flow and the challenge steps differ by factor and are covered on a separate page. Visit the [Phone](/docs/guides/auth/auth-mfa/phone) or [App Authenticator](/docs/guides/auth/auth-mfa/totp) pages to see how to add the flows for the respective factors. You can combine both flows and allow for use of both Phone and App Authenticator Factors. -An enrollment flow provides a UI for users to set up additional authentication factors. Most applications add the enrollment flow in two places within their app: +### Add unenroll flow -1. Right after login or sign up. - This lets users quickly set up MFA immediately after they log in or create an - account. We recommend encouraging all users to set up MFA if that makes sense - for your application. Many applications offer this as an opt-in step in an - effort to reduce onboarding friction. -2. From within a settings page. - Allows users to set up, disable or modify their MFA settings. +The unenroll process is the same for both Phone and TOTP factors. -We recommend building one generic flow that you can reuse in both cases with minor modifications. +An unenroll flow provides a UI for users to manage and unenroll factors linked to their accounts. Most applications do so via a factor management page where users can view and unlink selected factors. -Enrolling a factor for use with MFA takes three steps: +When a user unenrolls a factor, call `supabase.auth.mfa.unenroll()` with the ID of the factor. For example, call: -1. Call `supabase.auth.mfa.enroll()`. - This method returns a QR code and a secret. Display the QR - code to the user and ask them to scan it with their authenticator application. - If they are unable to scan the QR code, show the secret in plain text which - they can type or paste into their authenticator app. -2. Calling the `supabase.auth.mfa.challenge()` API. - This prepares Supabase Auth to accept a verification code from the user - and returns a challenge ID. -3. Calling the `supabase.auth.mfa.verify()` API. - This verifies that the user has indeed added the secret from step (1) into - their app and is working correctly. If the verification succeeds, the factor - immediately becomes active for the user account. If not, you should repeat - steps 2 and 3. - -#### Example: React - -Below is an example that creates a new `EnrollMFA` component that illustrates the important pieces of the MFA enrollment flow. - -- When the component appears on screen, the `supabase.auth.mfa.enroll()` API is - called once to start the process of enrolling a new factor for the current - user. -- This API returns a QR code in the SVG format, which is shown on screen using - a normal `` tag by encoding the SVG as a data URL. -- Once the user has scanned the QR code with their authenticator app, they - should enter the verification code within the `verifyCode` input field and - click on `Enable`. -- A challenge is created using the `supabase.auth.mfa.challenge()` API and the - code from the user is submitted for verification using the - `supabase.auth.mfa.verify()` challenge. -- `onEnabled` is a callback that notifies the other components that enrollment - has completed. -- `onCancelled` is a callback that notifies the other components that the user - has clicked the `Cancel` button. - -```tsx -/** - * EnrollMFA shows a simple enrollment dialog. When shown on screen it calls - * the `enroll` API. Each time a user clicks the Enable button it calls the - * `challenge` and `verify` APIs to check if the code provided by the user is - * valid. - * When enrollment is successful, it calls `onEnrolled`. When the user clicks - * Cancel the `onCancelled` callback is called. - */ -export function EnrollMFA({ - onEnrolled, - onCancelled, -}: { - onEnrolled: () => void - onCancelled: () => void -}) { - const [factorId, setFactorId] = useState('') - const [qr, setQR] = useState('') // holds the QR code image SVG - const [verifyCode, setVerifyCode] = useState('') // contains the code entered by the user - const [error, setError] = useState('') // holds an error message - - const onEnableClicked = () => { - setError('') - ;(async () => { - const challenge = await supabase.auth.mfa.challenge({ factorId }) - if (challenge.error) { - setError(challenge.error.message) - throw challenge.error - } - - const challengeId = challenge.data.id - - const verify = await supabase.auth.mfa.verify({ - factorId, - challengeId, - code: verifyCode, - }) - if (verify.error) { - setError(verify.error.message) - throw verify.error - } - - onEnrolled() - })() - } - - useEffect(() => { - ;(async () => { - const { data, error } = await supabase.auth.mfa.enroll({ - factorType: 'totp', - }) - if (error) { - throw error - } - - setFactorId(data.id) - - // Supabase Auth returns an SVG QR code which you can convert into a data - // URL that you can place in an tag. - setQR(data.totp.qr_code) - })() - }, []) - - return ( - <> - {error &&
    {error}
    } - - setVerifyCode(e.target.value.trim())} - /> - - - - ) -} +``` +supabase.auth.mfa.unenroll({factorId: "d30fd651-184e-4748-a928-0a4b9be1d429"}) ``` -### Add unenrollment flow +to unenroll a factor with ID `d30fd651-184e-4748-a928-0a4b9be1d429`. -An unenrollment flow provides a UI for users to manage and unenroll factors linked to their accounts. Most applications do so via a factor management page where users can view and unlink selected factors. +### Enforce rules for MFA logins -When a user unenrolls a factor, call `supabase.auth.mfa.unenroll()` with the ID of the factor. For example, call `supabase.auth.mfa.unenroll({factorId: "d30fd651-184e-4748-a928-0a4b9be1d429"})` to unenroll a factor with ID `d30fd651-184e-4748-a928-0a4b9be1d429`. +Adding MFA to your app's UI does not in-and-of-itself offer a higher level of security to your users. You also need to enforce the MFA rules in your application's database, APIs, and server-side rendering. + +Depending on your application's needs, there are three ways you can choose to enforce MFA. + +1. **Enforce for all users (new and existing).** + Any user account will have to enroll MFA to continue using your app. + The application will not allow access without going through MFA first. +2. **Enforce for new users only.** + Only new users will be forced to enroll MFA, while old users will be encouraged + to do so. + The application will not allow access for new users without going through MFA + first. +3. **Enforce only for users that have opted-in.** + Users that want MFA can enroll in it and the application will not allow access + without going through MFA first. #### Example: React @@ -248,7 +138,7 @@ export function UnenrollMFA() { throw error } - setFactors(data.totp) + setFactors([...data.totp, ...data.phone]) })() }, []) @@ -260,6 +150,7 @@ export function UnenrollMFA() { Factor ID Friendly Name Factor Status + Phone Number {factors.map((factor) => ( @@ -267,6 +158,7 @@ export function UnenrollMFA() { {factor.friendly_name} {factor.factor_type} {factor.status} + {factor.phone} ))} @@ -277,164 +169,6 @@ export function UnenrollMFA() { } ``` -### Add challenge step to login - -Once a user has logged in via their first factor (email+password, magic link, one time password, social login etc.) you need to perform a check if any additional factors need to be verified. - -This can be done by using the `supabase.auth.mfa.getAuthenticatorAssuranceLevel()` API. When the user signs in and is redirected back to your app, you should call this method to extract the user's current and next authenticator assurance level (AAL). - -Therefore if you receive a `currentLevel` which is `aal1` but a `nextLevel` of `aal2`, the user should be given the option to go through MFA. - -Below is a table that explains the combined meaning. - -| Current Level | Next Level | Meaning | -| ------------: | :--------- | :------------------------------------------------------- | -| `aal1` | `aal1` | User does not have MFA enrolled. | -| `aal1` | `aal2` | User has an MFA factor enrolled but has not verified it. | -| `aal2` | `aal2` | User has verified their MFA factor. | -| `aal2` | `aal1` | User has disabled their MFA factor. (Stale JWT.) | - -#### Example: React - -Adding the challenge step to login depends heavily on the architecture of your app. However, a fairly common way to structure React apps is to have a large component (often named `App`) which contains most of the authenticated application logic. - -This example will wrap this component with logic that will show an MFA challenge screen if necessary, before showing the full application. This is illustrated in the `AppWithMFA` example below. - -```tsx -function AppWithMFA() { - const [readyToShow, setReadyToShow] = useState(false) - const [showMFAScreen, setShowMFAScreen] = useState(false) - - useEffect(() => { - ;(async () => { - try { - const { data, error } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel() - if (error) { - throw error - } - - console.log(data) - - if (data.nextLevel === 'aal2' && data.nextLevel !== data.currentLevel) { - setShowMFAScreen(true) - } - } finally { - setReadyToShow(true) - } - })() - }, []) - - if (readyToShow) { - if (showMFAScreen) { - return - } - - return - } - - return <> -} -``` - -- `supabase.auth.mfa.getAuthenticatorAssuranceLevel()` does return a promise. - Don't worry, this is a very fast method (microseconds) as it rarely uses the - network. -- `readyToShow` only makes sure the AAL check completes before showing any - application UI to the user. -- If the current level can be upgraded to the next one, the MFA screen is - shown. -- Once the challenge is successful, the `App` component is finally rendered on - screen. - -Below is the component that implements the challenge and verify logic. - -```tsx -function AuthMFA() { - const [verifyCode, setVerifyCode] = useState('') - const [error, setError] = useState('') - - const onSubmitClicked = () => { - setError('') - ;(async () => { - const factors = await supabase.auth.mfa.listFactors() - if (factors.error) { - throw factors.error - } - - const totpFactor = factors.data.totp[0] - - if (!totpFactor) { - throw new Error('No TOTP factors found!') - } - - const factorId = totpFactor.id - - const challenge = await supabase.auth.mfa.challenge({ factorId }) - if (challenge.error) { - setError(challenge.error.message) - throw challenge.error - } - - const challengeId = challenge.data.id - - const verify = await supabase.auth.mfa.verify({ - factorId, - challengeId, - code: verifyCode, - }) - if (verify.error) { - setError(verify.error.message) - throw verify.error - } - })() - } - - return ( - <> -
    Please enter the code from your authenticator app.
    - {error &&
    {error}
    } - setVerifyCode(e.target.value.trim())} - /> - - - ) -} -``` - -- You can extract the available MFA factors for the user by calling - `supabase.auth.mfa.listFactors()`. Don't worry this method is also very quick - and rarely uses the network. -- If `listFactors()` returns more than one factor (or of a different type) you - should present the user with a choice. For simplicity this is not shown in - the example. -- Each time the user presses the "Submit" button a new challenge is created for - the chosen factor (in this case the first one) and it is immediately - verified. Any errors are displayed to the user. -- On successful verification, the client library will refresh the session in - the background automatically and finally call the `onSuccess` callback, which - will show the authenticated `App` component on screen. - -### Enforce rules for MFA logins - -Adding MFA to your app's UI does not in-and-of-itself offer a higher level of security to your users. You also need to enforce the MFA rules in your application's database, APIs, and server-side rendering. - -Depending on your application's needs, there are three ways you can choose to enforce MFA. - -1. **Enforce for all users (new and existing).** - Any user account will have to enroll MFA to continue using your app. - The application will not allow access without going through MFA first. -2. **Enforce for new users only.** - Only new users will be forced to enroll MFA, while old users will be encouraged - to do so. - The application will not allow access for new users without going through MFA - first. -3. **Enforce only for users that have opted-in.** - Users that want MFA can enroll in it and the application will not allow access - without going through MFA first. - #### Database Your app should sufficiently deny or allow access to tables or rows based on the user's current and possible authenticator levels. @@ -568,33 +302,6 @@ If your application uses the Supabase Database, Storage or Edge Functions, just className="text-foreground-light mt-8 mb-6 [&>div]:space-y-4" > -Why is there a challenge and verify API when challenge does not do much?} -id="why-is-there-a-challenge-and-verify-api" -> - -TOTP is not going to be the only MFA factor Supabase Auth is going to support in the future. By separating out the challenge and verify steps, we're making the library forward compatible with new factors we may add in the future -- such as SMS or WebAuthn. For example, for SMS the `challenge` endpoint would actually send out the SMS with the authentication code. For convenience, you may use `challengeAndVerify` to create and verify a challenge in a single step. - - - -What's inside the QR code?} -id="what-is-inside-the-qr-code" -> - -The TOTP QR code encodes a URI with the `otpauth` scheme. It was [initially introduced by Google Authenticator](https://github.com/google/google-authenticator/wiki/Key-Uri-Format) but is now universally accepted by all authenticator apps. - - - -How long is the TOTP code valid for?} -id="how-long-is-the-totp-code-valid-for" -> - -In our TOTP implementation, each generated code remains valid for one interval, which spans 30 seconds. To account for minor time discrepancies, we allow for a one-interval clock skew. This ensures that users can successfully authenticate within this timeframe, even if there are slight variations in system clocks. - - - How do I check when a user went through MFA?} id="how-do-i-check-when-a-user-went-through-mfa" diff --git a/apps/docs/content/guides/auth/auth-mfa/phone.mdx b/apps/docs/content/guides/auth/auth-mfa/phone.mdx new file mode 100644 index 00000000000..b4e39019e59 --- /dev/null +++ b/apps/docs/content/guides/auth/auth-mfa/phone.mdx @@ -0,0 +1,313 @@ +--- +id: 'auth-mfa-phone' +title: 'Multi-Factor Authentication (Phone)' +description: 'Add an additional layer of security with phone (SMS or WhatsApp) multi-factor-authentication.' +--- + +## How does phone multi-factor-authentication? + +Phone multi-factor authentication involves a shared code generated by Supabase Auth and the end user. The code is delivered via a messaging channel, such as SMS or WhatsApp, and the user uses the code to authenticate to Supabase Auth. + +The phone messaging configuration for MFA is shared with [phone auth login](/docs/guides/auth/phone-login). The same provider configuration that is used for phone login is used for MFA. You can also use the [Send SMS Hook](/docs/guides/auth/auth-hooks/send-sms-hook) if you need to use an MFA (Phone) messaging provider different from what is supported natively. + +Below is a flow chart illustrating how the Enrollment and Verify APIs work in the context of MFA (Phone). + +Diagram showing the flow of Multi-Factor authentication + +Phone MFA is part of the Auth Advanced MFA Add-on and costs an additional $75 per month for the first project in the organization and an additional $10 per month for additional projects. + +### Add enrollment flow + +An enrollment flow provides a UI for users to set up additional authentication factors. Most applications add the enrollment flow in two places within their app: + +1. Right after login or sign up. + This allows users quickly set up Multi Factor Authentication (MFA) post login or account creation. Where possible, encourage all users to set up MFA. Many applications offer this as an opt-in step in an + effort to reduce onboarding friction. +2. From within a settings page. + Allows users to set up, disable or modify their MFA settings. + +As far as possible, maintain a generic flow that you can reuse in both cases with minor modifications. + +Enrolling a factor for use with MFA takes three steps for phone MFA: + +1. Call `supabase.auth.mfa.enroll()`. +2. Calling the `supabase.auth.mfa.challenge()` API. This sends a code via SMS or WhatsApp and prepares Supabase Auth to accept a verification code from the user. +3. Calling the `supabase.auth.mfa.verify()` API. `supabase.auth.mfa.challenge()` returns a challenge ID. + This verifies that the code issued by Supabase Auth matches the code input by the user. If the verification succeeds, the factor + immediately becomes active for the user account. If not, you should repeat + steps 2 and 3. + +#### Example: React + +Below is an example that creates a new `EnrollMFA` component that illustrates the important pieces of the MFA enrollment flow. + +- When the component appears on screen, the `supabase.auth.mfa.enroll()` API is + called once to start the process of enrolling a new factor for the current + user. +- A challenge is created using the `supabase.auth.mfa.challenge()` API and the + code from the user is submitted for verification using the + `supabase.auth.mfa.verify()` challenge. +- `onEnabled` is a callback that notifies the other components that enrollment + has completed. +- `onCancelled` is a callback that notifies the other components that the user + has clicked the `Cancel` button. + +```tsx +export function EnrollMFA({ + onEnrolled, + onCancelled, +}: { + onEnrolled: () => void + onCancelled: () => void +}) { + const [phoneNumber, setPhoneNumber] = useState('') + const [factorId, setFactorId] = useState('') + const [verifyCode, setVerifyCode] = useState('') + const [error, setError] = useState('') + const [challengeId, setChallengeId] = useState('') + + const onEnableClicked = () => { + setError('') + ;(async () => { + const verify = await auth.mfa.verify({ + factorId, + challengeId, + code: verifyCode, + }) + if (verify.error) { + setError(verify.error.message) + throw verify.error + } + + onEnrolled() + })() + } + const onEnrollClicked = async () => { + setError('') + try { + const factor = await auth.mfa.enroll({ + phone: phoneNumber, + factorType: 'phone', + }) + if (factor.error) { + setError(factor.error.message) + throw factor.error + } + + setFactorId(factor.data.id) + } catch (error) { + setError('Failed to Enroll the Factor.') + } + } + + const onSendOTPClicked = async () => { + setError('') + try { + const challenge = await auth.mfa.challenge({ factorId }) + if (challenge.error) { + setError(challenge.error.message) + throw challenge.error + } + + setChallengeId(challenge.data.id) + } catch (error) { + setError('Failed to resend the code.') + } + } + + return ( + <> + {error &&
    {error}
    } + setPhoneNumber(e.target.value.trim())} + /> + setVerifyCode(e.target.value.trim())} + /> + + + + + + ) +} +``` + +### Add a challenge step to login + +Once a user has logged in via their first factor (email+password, magic link, one time password, social login etc.) you need to perform a check if any additional factors need to be verified. + +This can be done by using the `supabase.auth.mfa.getAuthenticatorAssuranceLevel()` API. When the user signs in and is redirected back to your app, you should call this method to extract the user's current and next authenticator assurance level (AAL). + +Therefore if you receive a `currentLevel` which is `aal1` but a `nextLevel` of `aal2`, the user should be given the option to go through MFA. + +Below is a table that explains the combined meaning. + +| Current Level | Next Level | Meaning | +| ------------: | :--------- | :------------------------------------------------------- | +| `aal1` | `aal1` | User does not have MFA enrolled. | +| `aal1` | `aal2` | User has an MFA factor enrolled but has not verified it. | +| `aal2` | `aal2` | User has verified their MFA factor. | +| `aal2` | `aal1` | User has disabled their MFA factor. (Stale JWT.) | + +#### Example: React + +Adding the challenge step to login depends heavily on the architecture of your app. However, a fairly common way to structure React apps is to have a large component (often named `App`) which contains most of the authenticated application logic. + +This example will wrap this component with logic that will show an MFA challenge screen if necessary, before showing the full application. This is illustrated in the `AppWithMFA` example below. + +```tsx +function AppWithMFA() { + const [readyToShow, setReadyToShow] = useState(false) + const [showMFAScreen, setShowMFAScreen] = useState(false) + + useEffect(() => { + ;(async () => { + try { + const { data, error } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel() + if (error) { + throw error + } + + console.log(data) + + if (data.nextLevel === 'aal2' && data.nextLevel !== data.currentLevel) { + setShowMFAScreen(true) + } + } finally { + setReadyToShow(true) + } + })() + }, []) + + if (readyToShow) { + if (showMFAScreen) { + return + } + + return + } + + return <> +} +``` + +- `supabase.auth.mfa.getAuthenticatorAssuranceLevel()` does return a promise. + Don't worry, this is a very fast method (microseconds) as it rarely uses the + network. +- `readyToShow` only makes sure the AAL check completes before showing any + application UI to the user. +- If the current level can be upgraded to the next one, the MFA screen is + shown. +- Once the challenge is successful, the `App` component is finally rendered on + screen. + +Below is the component that implements the challenge and verify logic. + +```tsx +function AuthMFA() { + const [verifyCode, setVerifyCode] = useState('') + const [error, setError] = useState('') + const [factorId, setFactorId] = useState('') + const [challengeId, setChallengeId] = useState('') + const [phoneNumber, setPhoneNumber] = useState('') + + const startChallenge = async () => { + setError('') + try { + const factors = await supabase.auth.mfa.listFactors() + if (factors.error) { + throw factors.error + } + + const phoneFactor = factors.data.phone[0] + + if (!phoneFactor) { + throw new Error('No phone factors found!') + } + + const factorId = phoneFactor.id + setFactorId(factorId) + setPhoneNumber(phoneFactor.phone) + + const challenge = await supabase.auth.mfa.challenge({ factorId }) + if (challenge.error) { + setError(challenge.error.message) + throw challenge.error + } + + setChallengeId(challenge.data.id) + } catch (error) { + setError(error.message) + } + } + + const verifyCode = async () => { + setError('') + try { + const verify = await supabase.auth.mfa.verify({ + factorId, + challengeId, + code: verifyCode, + }) + if (verify.error) { + setError(verify.error.message) + throw verify.error + } + } catch (error) { + setError(error.message) + } + } + + return ( + <> +
    Please enter the code sent to your phone.
    + {phoneNumber &&
    Phone number: {phoneNumber}
    } + {error &&
    {error}
    } + setVerifyCode(e.target.value.trim())} + /> + {!challengeId ? ( + + ) : ( + + )} + + ) +} +``` + +- You can extract the available MFA factors for the user by calling + `supabase.auth.mfa.listFactors()`. Don't worry this method is also very quick + and rarely uses the network. +- If `listFactors()` returns more than one factor (or of a different type) you + should present the user with a choice. For simplicity this is not shown in + the example. +- Phone numbers are unique per user. Users can only have one verified phone factor with a given phone number. + Attempting to enroll a new phone factor alongside an existing verified factor with the same number will result in an error. +- Each time the user presses the "Submit" button a new challenge is created for + the chosen factor (in this case the first one) +- On successful verification, the client library will refresh the session in + the background automatically and finally call the `onSuccess` callback, which + will show the authenticated `App` component on screen. + +### Security Configuration + +Each code is valid for up to 5 minutes, after which a new one can be sent. Successive codes remain valid until expiry. When possible choose the longest code length acceptable to your use case, at a minimum of 6. This can be configured in the [Authentication Settings](/dashboard/project/_/settings/auth). + +Please be aware that Phone MFA is vulnerable to SIM swap attacks where an attacker will call a mobile provider and ask to port the target's phone number to a new SIM card and then use the said SIM card to intercept an MFA code. Please evaluate the your application's tolerance for such an attack. You can read more about SIM swapping attacks [here](https://en.wikipedia.org/wiki/SIM_swap_scam) diff --git a/apps/docs/content/guides/auth/auth-mfa/totp.mdx b/apps/docs/content/guides/auth/auth-mfa/totp.mdx new file mode 100644 index 00000000000..cc6eed42b88 --- /dev/null +++ b/apps/docs/content/guides/auth/auth-mfa/totp.mdx @@ -0,0 +1,320 @@ +--- +id: 'auth-mfa-totp' +title: 'Multi-Factor Authentication (TOTP)' +description: 'Add an additional layer of security to your apps with TOTP multi-factor authentication.' +--- + +## How does app authenticator multi-factor authentication work? + +App Authenticator (TOTP) multi-factor authentication involves a timed one-time password generated from an authenticator app in the control of users. It uses a QR Code which to transmit a shared secret used to generate a One Time Password. A user can scan a QR code with their phone to capture a shared secret required for subsequent authentication. + +The use of a QR code was [initially introduced by Google Authenticator](https://github.com/google/google-authenticator/wiki/Key-Uri-Format) but is now universally accepted by all authenticator apps. The QR code has an alternate representation in URI form following the `otpauth` scheme such as: `otpauth://totp/supabase:alice@supabase.com?secret=&issuer=supabase` which a user can manually input in cases where there is difficulty rendering a QR Code. + +Below is a flow chart illustrating how the Enrollment, Challenge, and Verify APIs work in the context of MFA (TOTP). + +Diagram showing the flow of Multi-Factor authentication + +[TOTP MFA API](/docs/reference/javascript/auth-mfa-api) is free to use and is enabled on all Supabase projects by default. + +### Add enrollment flow + +An enrollment flow provides a UI for users to set up additional authentication factors. Most applications add the enrollment flow in two places within their app: + +1. Right after login or sign up. + This lets users quickly set up MFA immediately after they log in or create an + account. We recommend encouraging all users to set up MFA if that makes sense + for your application. Many applications offer this as an opt-in step in an + effort to reduce onboarding friction. +2. From within a settings page. + Allows users to set up, disable or modify their MFA settings. + +Enrolling a factor for use with MFA takes three steps: + +1. Call `supabase.auth.mfa.enroll()`. + This method returns a QR code and a secret. Display the QR + code to the user and ask them to scan it with their authenticator application. + If they are unable to scan the QR code, show the secret in plain text which + they can type or paste into their authenticator app. +2. Calling the `supabase.auth.mfa.challenge()` API. + This prepares Supabase Auth to accept a verification code from the user + and returns a challenge ID. In the case of Phone MFA this step also sends the verification code to the user. +3. Calling the `supabase.auth.mfa.verify()` API. + This verifies that the user has indeed added the secret from step (1) into + their app and is working correctly. If the verification succeeds, the factor + immediately becomes active for the user account. If not, you should repeat + steps 2 and 3. + +#### Example: React + +Below is an example that creates a new `EnrollMFA` component that illustrates the important pieces of the MFA enrollment flow. + +- When the component appears on screen, the `supabase.auth.mfa.enroll()` API is + called once to start the process of enrolling a new factor for the current + user. +- This API returns a QR code in the SVG format, which is shown on screen using + a normal `` tag by encoding the SVG as a data URL. +- Once the user has scanned the QR code with their authenticator app, they + should enter the verification code within the `verifyCode` input field and + click on `Enable`. +- A challenge is created using the `supabase.auth.mfa.challenge()` API and the + code from the user is submitted for verification using the + `supabase.auth.mfa.verify()` challenge. +- `onEnabled` is a callback that notifies the other components that enrollment + has completed. +- `onCancelled` is a callback that notifies the other components that the user + has clicked the `Cancel` button. + +```tsx +/** + * EnrollMFA shows a simple enrollment dialog. When shown on screen it calls + * the `enroll` API. Each time a user clicks the Enable button it calls the + * `challenge` and `verify` APIs to check if the code provided by the user is + * valid. + * When enrollment is successful, it calls `onEnrolled`. When the user clicks + * Cancel the `onCancelled` callback is called. + */ +export function EnrollMFA({ + onEnrolled, + onCancelled, +}: { + onEnrolled: () => void + onCancelled: () => void +}) { + const [factorId, setFactorId] = useState('') + const [qr, setQR] = useState('') // holds the QR code image SVG + const [verifyCode, setVerifyCode] = useState('') // contains the code entered by the user + const [error, setError] = useState('') // holds an error message + + const onEnableClicked = () => { + setError('') + ;(async () => { + const challenge = await supabase.auth.mfa.challenge({ factorId }) + if (challenge.error) { + setError(challenge.error.message) + throw challenge.error + } + + const challengeId = challenge.data.id + + const verify = await supabase.auth.mfa.verify({ + factorId, + challengeId, + code: verifyCode, + }) + if (verify.error) { + setError(verify.error.message) + throw verify.error + } + + onEnrolled() + })() + } + + useEffect(() => { + ;(async () => { + const { data, error } = await supabase.auth.mfa.enroll({ + factorType: 'totp', + }) + if (error) { + throw error + } + + setFactorId(data.id) + + // Supabase Auth returns an SVG QR code which you can convert into a data + // URL that you can place in an tag. + setQR(data.totp.qr_code) + })() + }, []) + + return ( + <> + {error &&
    {error}
    } + + setVerifyCode(e.target.value.trim())} + /> + + + + ) +} +``` + +### Add a challenge step to login + +Once a user has logged in via their first factor (email+password, magic link, one time password, social login etc.) you need to perform a check if any additional factors need to be verified. + +This can be done by using the `supabase.auth.mfa.getAuthenticatorAssuranceLevel()` API. When the user signs in and is redirected back to your app, you should call this method to extract the user's current and next authenticator assurance level (AAL). + +Therefore if you receive a `currentLevel` which is `aal1` but a `nextLevel` of `aal2`, the user should be given the option to go through MFA. + +Below is a table that explains the combined meaning. + +| Current Level | Next Level | Meaning | +| ------------: | :--------- | :------------------------------------------------------- | +| `aal1` | `aal1` | User does not have MFA enrolled. | +| `aal1` | `aal2` | User has an MFA factor enrolled but has not verified it. | +| `aal2` | `aal2` | User has verified their MFA factor. | +| `aal2` | `aal1` | User has disabled their MFA factor. (Stale JWT.) | + +#### Example: React + +Adding the challenge step to login depends heavily on the architecture of your app. However, a fairly common way to structure React apps is to have a large component (often named `App`) which contains most of the authenticated application logic. + +This example will wrap this component with logic that will show an MFA challenge screen if necessary, before showing the full application. This is illustrated in the `AppWithMFA` example below. + +```tsx +function AppWithMFA() { + const [readyToShow, setReadyToShow] = useState(false) + const [showMFAScreen, setShowMFAScreen] = useState(false) + + useEffect(() => { + ;(async () => { + try { + const { data, error } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel() + if (error) { + throw error + } + + console.log(data) + + if (data.nextLevel === 'aal2' && data.nextLevel !== data.currentLevel) { + setShowMFAScreen(true) + } + } finally { + setReadyToShow(true) + } + })() + }, []) + + if (readyToShow) { + if (showMFAScreen) { + return + } + + return + } + + return <> +} +``` + +- `supabase.auth.mfa.getAuthenticatorAssuranceLevel()` does return a promise. + Don't worry, this is a very fast method (microseconds) as it rarely uses the + network. +- `readyToShow` only makes sure the AAL check completes before showing any + application UI to the user. +- If the current level can be upgraded to the next one, the MFA screen is + shown. +- Once the challenge is successful, the `App` component is finally rendered on + screen. + +Below is the component that implements the challenge and verify logic. + +```tsx +function AuthMFA() { + const [verifyCode, setVerifyCode] = useState('') + const [error, setError] = useState('') + + const onSubmitClicked = () => { + setError('') + ;(async () => { + const factors = await supabase.auth.mfa.listFactors() + if (factors.error) { + throw factors.error + } + + const totpFactor = factors.data.totp[0] + + if (!totpFactor) { + throw new Error('No TOTP factors found!') + } + + const factorId = totpFactor.id + + const challenge = await supabase.auth.mfa.challenge({ factorId }) + if (challenge.error) { + setError(challenge.error.message) + throw challenge.error + } + + const challengeId = challenge.data.id + + const verify = await supabase.auth.mfa.verify({ + factorId, + challengeId, + code: verifyCode, + }) + if (verify.error) { + setError(verify.error.message) + throw verify.error + } + })() + } + + return ( + <> +
    Please enter the code from your authenticator app.
    + {error &&
    {error}
    } + setVerifyCode(e.target.value.trim())} + /> + + + ) +} +``` + +- You can extract the available MFA factors for the user by calling + `supabase.auth.mfa.listFactors()`. Don't worry this method is also very quick + and rarely uses the network. +- If `listFactors()` returns more than one factor (or of a different type) you + should present the user with a choice. For simplicity this is not shown in + the example. +- Each time the user presses the "Submit" button a new challenge is created for + the chosen factor (in this case the first one) and it is immediately + verified. Any errors are displayed to the user. +- On successful verification, the client library will refresh the session in + the background automatically and finally call the `onSuccess` callback, which + will show the authenticated `App` component on screen. + +## Frequently asked questions + + + +What's inside the QR code?} +id="what-is-inside-the-qr-code" +> + + + +How long is the TOTP code valid for?} +id="how-long-is-the-totp-code-valid-for" +> + +In our TOTP implementation, each generated code remains valid for one interval, which spans 30 seconds. To account for minor time discrepancies, we allow for a one-interval clock skew. This ensures that users can successfully authenticate within this timeframe, even if there are slight variations in system clocks. + + + + diff --git a/apps/docs/content/guides/auth/quickstarts/nextjs.mdx b/apps/docs/content/guides/auth/quickstarts/nextjs.mdx index 33bb93927d5..9a7117247af 100644 --- a/apps/docs/content/guides/auth/quickstarts/nextjs.mdx +++ b/apps/docs/content/guides/auth/quickstarts/nextjs.mdx @@ -35,6 +35,8 @@ hideToc: true - [TypeScript](https://www.typescriptlang.org/) - [Tailwind CSS](https://tailwindcss.com/) + [See GitHub repo](https://github.com/vercel/next.js/tree/canary/examples/with-supabase) + diff --git a/apps/docs/content/guides/auth/server-side/nextjs.mdx b/apps/docs/content/guides/auth/server-side/nextjs.mdx index d321df09c18..6c0ccda7b1c 100644 --- a/apps/docs/content/guides/auth/server-side/nextjs.mdx +++ b/apps/docs/content/guides/auth/server-side/nextjs.mdx @@ -672,7 +672,7 @@ export function createClient() { ``` ```ts utils/supabase/api.ts -import { createServerClient, type CookieOptions, serialize } from '@supabase/ssr' +import { createServerClient, serializeCookieHeader } from '@supabase/ssr' import { type NextApiRequest, type NextApiResponse } from 'next' export default function createClient(req: NextApiRequest, res: NextApiResponse) { @@ -687,7 +687,9 @@ export default function createClient(req: NextApiRequest, res: NextApiResponse) setAll(cookiesToSet) { res.setHeader( 'Set-Cookie', - cookiesToSet.map(({ name, value, options }) => serialize(name, value, options)) + cookiesToSet.map(({ name, value, options }) => + serializeCookieHeader(name, value, options) + ) ) }, }, diff --git a/apps/docs/content/guides/auth/social-login/auth-google.mdx b/apps/docs/content/guides/auth/social-login/auth-google.mdx index 4cea739098d..3d864727e99 100644 --- a/apps/docs/content/guides/auth/social-login/auth-google.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-google.mdx @@ -68,7 +68,7 @@ To use Google's pre-built signin buttons: 1. Go to the [API Credentials page](https://console.cloud.google.com/apis/credentials). 1. Click `Create credentials` and choose `OAuth Client ID`. 1. For application type, choose `Web application`. -1. Under **Authorized JavaScript origins** and **Authorized redirect URLs**, add your site URL. This is the URL of the website where the signin button will appear, _not_ your Supabase project domain. +1. Under **Authorized JavaScript origins** and **Authorized redirect URLs**, add your site URL. This is the URL of the website where the signin button will appear, _not_ your Supabase project domain. If you're testing in localhost, ensure that you have `http://localhost` set in the **Authorized JavaScript origins** section as well. This is important when integrating with Google One-Tap to ensure you can use it locally. 1. When you finish configuring your credentials, you will be shown your client ID. Add this to the **Authorized Client IDs** field in the [Google Auth Provider section of the Supabase Dashboard](/dashboard/project/_/auth/providers). Leave the OAuth client ID and secret blank. You don't need them when using Google's pre-built approach.
    @@ -329,22 +329,108 @@ Most web apps and websites can utilize Google's [personalized sign-in buttons](h ```js // Adapted from https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#converting_a_digest_to_a_hex_string - const nonce = btoa( - String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32))), - ); - const encoder = new TextEncoder(); - const encodedNonce = encoder.encode(nonce); - crypto.subtle.digest("SHA-256", encodedNonce).then((hashBuffer) => { - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashedNonce = hashArray - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); - } + const nonce = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32)))) + const encoder = new TextEncoder() + const encodedNonce = encoder.encode(nonce) + crypto.subtle.digest('SHA-256', encodedNonce).then((hashBuffer) => { + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const hashedNonce = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') + }) // Use 'hashedNonce' when making the authentication request to Google // Use 'nonce' when invoking the supabase.auth.signInWithIdToken() method ``` +### One-tap with NextJS + +If you're integrating Google One-Tap with your NextJS application, you can refer to the example below to get started: + +```tsx +'use client' + +import Script from 'next/script' +import { createClient } from '@/utils/supabase/client' +import { CredentialResponse } from 'google-one-tap' +import { useRouter } from 'next/navigation' +import { useEffect } from 'react' + +const OneTapComponent = () => { + const supabase = createClient() + const router = useRouter() + + // generate nonce to use for google id token sign-in + const generateNonce = async (): Promise => { + const nonce = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32)))) + const encoder = new TextEncoder() + const encodedNonce = encoder.encode(nonce) + const hashBuffer = await crypto.subtle.digest('SHA-256', encodedNonce) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const hashedNonce = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') + + return [nonce, hashedNonce] + } + + useEffect(() => { + const initializeGoogleOneTap = () => { + console.log('Initializing Google One Tap') + window.addEventListener('load', async () => { + const [nonce, hashedNonce] = await generateNonce() + console.log('Nonce: ', nonce, hashedNonce) + + // check if there's already an existing session before initializing the one-tap UI + const { data, error } = await supabase.auth.getSession() + if (error) { + console.error('Error getting session', error) + } + if (data.session) { + router.push('/') + return + } + + /* global google */ + google.accounts.id.initialize({ + client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID, + callback: async (response: CredentialResponse) => { + try { + // send id token returned in response.credential to supabase + const { data, error } = await supabase.auth.signInWithIdToken({ + provider: 'google', + token: response.credential, + nonce, + }) + + if (error) throw error + console.log('Session data: ', data) + console.log('Successfully logged in with Google One Tap') + + // redirect to protected page + router.push('/') + } catch (error) { + console.error('Error logging in with Google One Tap', error) + } + }, + nonce: hashedNonce, + // with chrome's removal of third-party cookiesm, we need to use FedCM instead (https://developers.google.com/identity/gsi/web/guides/fedcm-migration) + use_fedcm_for_prompt: true, + }) + google.accounts.id.prompt() // Display the One Tap UI + }) + } + initializeGoogleOneTap() + return () => window.removeEventListener('load', initializeGoogleOneTap) + }, []) + + return ( + <> + - - - + diff --git a/examples/realtime/flutter-figma-clone/web/manifest.json b/examples/realtime/flutter-figma-clone/web/manifest.json index 2c8de133299..aad92e819ea 100644 --- a/examples/realtime/flutter-figma-clone/web/manifest.json +++ b/examples/realtime/flutter-figma-clone/web/manifest.json @@ -1,6 +1,6 @@ { - "name": "design", - "short_name": "design", + "name": "canvas", + "short_name": "canvas", "start_url": ".", "display": "standalone", "background_color": "#0175C2", diff --git a/package-lock.json b/package-lock.json index ec9f240db55..a4ed5f9edd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -988,7 +988,8 @@ "unist-util-filter": "^4.0.1", "unist-util-visit": "^4.1.2", "uuid": "^9.0.1", - "valtio": "^1.12.0" + "valtio": "^1.12.0", + "yaml": "^2.4.5" }, "devDependencies": { "@aws-sdk/client-secrets-manager": "^3.410.0", @@ -999,6 +1000,7 @@ "@types/unist": "^2.0.6", "acorn": "^8.11.3", "api-types": "*", + "cheerio": "^1.0.0-rc.12", "config": "*", "dotenv": "^16.0.3", "ejs": "^3.1.10", @@ -1657,6 +1659,7 @@ "monaco-editor": "0.33.0", "next": "^14.2.3", "next-themes": "^0.3.0", + "nuqs": "^1.17.8", "openai": "^4.20.1", "openapi-fetch": "^0.6.2", "papaparse": "^5.3.1", @@ -17684,6 +17687,44 @@ "node": "*" } }, + "node_modules/cheerio": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", + "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "dev": true, + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "htmlparser2": "^8.0.1", + "parse5": "^7.0.0", + "parse5-htmlparser2-tree-adapter": "^7.0.0" + }, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/chokidar": { "version": "3.5.3", "funding": [ @@ -18677,6 +18718,22 @@ "hyphenate-style-name": "^1.0.3" } }, + "node_modules/css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "dev": true, + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/css-to-react-native": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", @@ -19415,6 +19472,20 @@ "csstype": "^3.0.2" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, "node_modules/domelementtype": { "version": "2.3.0", "dev": true, @@ -19437,6 +19508,35 @@ "node": ">=12" } }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dev": true, + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dot-case": { "version": "3.0.4", "dev": true, @@ -23387,6 +23487,25 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "node_modules/http-cache-semantics": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", @@ -31237,6 +31356,11 @@ "version": "4.0.0", "license": "ISC" }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==" + }, "node_modules/mixin-deep": { "version": "1.3.2", "license": "MIT", @@ -32190,6 +32314,17 @@ "version": "1.1.1", "license": "MIT" }, + "node_modules/nuqs": { + "version": "1.17.8", + "resolved": "https://registry.npmjs.org/nuqs/-/nuqs-1.17.8.tgz", + "integrity": "sha512-JqsnzO+hJyjJE7ebuhpHMLA2iGY48e2xr0oJQFhj7kjUmDABL2XOup47rxF5TL/5b9jEsmU2t0lAKin1VdK1/A==", + "dependencies": { + "mitt": "^3.0.1" + }, + "peerDependencies": { + "next": ">=13.4 <14.0.2 || ^14.0.3" + } + }, "node_modules/nwsapi": { "version": "2.2.7", "devOptional": true, @@ -32817,6 +32952,19 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz", + "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==", + "dev": true, + "dependencies": { + "domhandler": "^5.0.2", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/pascal-case": { "version": "3.1.2", "license": "MIT", @@ -38823,22 +38971,6 @@ "url": "https://opencollective.com/svgo" } }, - "node_modules/svgo/node_modules/css-select": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", - "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", - "dev": true, - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^6.1.0", - "domhandler": "^5.0.2", - "domutils": "^3.0.1", - "nth-check": "^2.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, "node_modules/svgo/node_modules/css-tree": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", @@ -38852,49 +38984,6 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, - "node_modules/svgo/node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "dev": true, - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/svgo/node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "dev": true, - "dependencies": { - "domelementtype": "^2.3.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/svgo/node_modules/domutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", - "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", - "dev": true, - "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, "node_modules/svgo/node_modules/mdn-data": { "version": "2.0.30", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", @@ -42708,8 +42797,12 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, "node_modules/yaml": { - "version": "2.3.2", - "license": "ISC", + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.5.tgz", + "integrity": "sha512-aBx2bnqDzVOyNKfsysjA2ms5ZlnjSAW2eG3/L5G/CSujfjLJTJsEw1bGw8kCf04KodQWk1pxlGnZ56CRxiawmg==", + "bin": { + "yaml": "bin.mjs" + }, "engines": { "node": ">= 14" } diff --git a/packages/shared-data/config.ts b/packages/shared-data/config.ts index 1cdda69a3f0..220c6f74a91 100644 --- a/packages/shared-data/config.ts +++ b/packages/shared-data/config.ts @@ -98,6 +98,15 @@ const config = { }, }, }, + hook_timeouts: { + postgres_hooks: { + value: 2, + }, + http_hooks: { + value: 5, + + }, + } }, branching: { inactivity_period_in_minutes: { diff --git a/packages/shared-data/pricing.ts b/packages/shared-data/pricing.ts index fd900c7af1c..cd65a9e0595 100644 --- a/packages/shared-data/pricing.ts +++ b/packages/shared-data/pricing.ts @@ -90,9 +90,9 @@ export const pricing: Pricing = { }, plans: { free: false, - pro: '$100 per 7 days', - team: '$100 per 7 days', - enterprise: '$100 per 7 days, >28 days available', + pro: '$100 per month per 7 days retention', + team: '$100 per month per 7 days retention', + enterprise: '$100 per month per 7 days retention, >28 days retention available', }, usage_based: false, }, @@ -122,7 +122,7 @@ export const pricing: Pricing = { { title: 'Bandwidth', tooltips: { - main: 'Billing is based on the total sum of all outgoing traffic (includes Database, Storage, Realtime, Auth, API, Edge Functions, Supavisor) in GB throughout your billing period.', + main: 'Billing is based on the total sum of all outgoing traffic (includes Database, Storage, Realtime, Auth, API, Edge Functions, Supavisor, Log Drains) in GB throughout your billing period.', }, plans: { free: '5 GB included', @@ -162,6 +162,19 @@ export const pricing: Pricing = { }, usage_based: true, }, + { + title: 'User data ownership', + tooltips: { + main: 'Full ownership and access to the underlying user data including encrypted passwords.', + }, + plans: { + free: true, + pro: true, + team: true, + enterprise: true, + }, + usage_based: false, + }, { title: 'Anonymous Sign-ins', tooltips: { @@ -175,6 +188,7 @@ export const pricing: Pricing = { }, usage_based: false, }, + { title: 'Social OAuth providers', plans: { @@ -215,6 +229,45 @@ export const pricing: Pricing = { }, usage_based: false, }, + { + title: 'Basic Multi-Factor Auth', + plans: { + free: true, + pro: true, + team: true, + enterprise: true, + }, + tooltips: { + main: 'Multi-factor authentication (MFA), sometimes called two-factor authentication (2FA), using Time-based One Time Passwords (TOTP) via an Authenticator App.', + }, + usage_based: false, + }, + { + title: 'Advanced Multi-Factor Auth - Phone', + plans: { + free: false, + pro: ['$75 per month for first project', 'then $10 per month per additional projects'], + team: ['$75 per month for first project', 'then $10 per month per additional projects'], + enterprise: 'Custom', + }, + tooltips: { + main: 'Multi-factor authentication (MFA), sometimes called two-factor authentication (2FA), using SMS or WhatsApp messages.\nAdditional fees apply based on your provider.', + }, + usage_based: false, + }, + { + title: 'Third-Party MAUs', + plans: { + free: '50 included', + pro: ['50 included', 'then $0.00325 per MAU'], + team: ['50 included', 'then $0.00325 per MAU'], + enterprise: 'Custom', + }, + tooltips: { + main: 'Users who use the Supabase platform through a third-party authentication provider (Firebase Auth, Auth0 or Cognito).\nBilling is based on the sum of distinct third-party users requesting your API through the billing period. Resets every billing cycle.', + }, + usage_based: true, + }, { title: 'Single Sign-On (SAML 2.0)', plans: { @@ -223,6 +276,7 @@ export const pricing: Pricing = { team: ['50 included', 'then $0.015 per MAU'], enterprise: 'Contact Us', }, + usage_based: false, }, { @@ -258,8 +312,8 @@ export const pricing: Pricing = { { title: 'Auth Hooks', plans: { - free: 'Custom Access Token (JWT)', - pro: 'Custom Access Token (JWT)', + free: 'Custom Access Token (JWT), Send custom email/SMS', + pro: 'Custom Access Token (JWT), Send custom email/SMS', team: 'All', enterprise: 'All', }, @@ -384,7 +438,7 @@ export const pricing: Pricing = { { title: 'Number of functions', plans: { - free: '10 included', + free: '25 included', pro: '500 included', team: '1000 included', enterprise: 'Unlimited', @@ -506,14 +560,21 @@ export const pricing: Pricing = { usage_based: false, }, { - title: 'Log drain', + title: 'Log Drain', + tooltips: { + main: 'Only events processed and sent to destinations are counted. Bandwidth required to export logs count towards usage.\nEgress through Log Drains is rolled up into the unified egress and benefits from the unified egress quota.', + }, plans: { free: false, pro: false, - team: 'Coming soon', - enterprise: 'Coming soon', + team: [ + '$60 per drain per month', + 'and $0.20 per Million processed', + 'and $0.09 per GB bandwidth', + ], + enterprise: 'Custom', }, - usage_based: false, + usage_based: true, }, { title: 'Metrics endpoint', diff --git a/packages/ui-patterns/AssistantChat/AssistantChatForm.tsx b/packages/ui-patterns/AssistantChat/AssistantChatForm.tsx index 86a9b43d558..71885449bd0 100644 --- a/packages/ui-patterns/AssistantChat/AssistantChatForm.tsx +++ b/packages/ui-patterns/AssistantChat/AssistantChatForm.tsx @@ -78,7 +78,6 @@ const AssistantChatForm = React.forwardRef( ref={textAreaRef} autoFocus disabled={disabled} - contentEditable className={cn(icon ? 'pl-12' : '', 'text-sm pr-10 rounded-[18px]')} placeholder={placeholder} spellCheck={false} diff --git a/packages/ui-patterns/Cmdk/APIKeys.tsx b/packages/ui-patterns/Cmdk/APIKeys.tsx deleted file mode 100644 index 87e117432e1..00000000000 --- a/packages/ui-patterns/Cmdk/APIKeys.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { CommandGroup } from 'cmdk' -import { useCommandMenu } from './CommandMenuContext' -import { Badge } from 'ui/src/components/shadcn/ui/badge' -import { CommandItem, copyToClipboard } from './Command.utils' -import { IconAlertCircle } from 'ui/src/components/Icon/icons/IconAlertCircle' -import ChildItem from './ChildItem' - -const APIKeys = ({ isSubItem = false }) => { - const { setIsOpen, project } = useCommandMenu() - const { apiKeys } = project ?? {} - - return ( - - {apiKeys?.anon !== undefined && ( - { - copyToClipboard(apiKeys?.anon ?? '') - setIsOpen(false) - }} - className="space-x-2" - > -

    Copy anonymous key

    - Public -
    - )} - {apiKeys?.service !== undefined && ( - { - copyToClipboard(apiKeys?.service ?? '') - setIsOpen(false) - }} - className="space-x-2" - > -

    Copy service key

    - Secret -
    - )} - {apiKeys?.anon === undefined && apiKeys?.service === undefined && ( - - -
    -

    No API keys available

    -

    - You may not have the necessary permissions to view the project's API keys -

    -
    -
    - )} -
    - ) -} - -export default APIKeys diff --git a/packages/ui-patterns/Cmdk/AiCommand.tsx b/packages/ui-patterns/Cmdk/AiCommand.tsx deleted file mode 100644 index 3f49cfd288b..00000000000 --- a/packages/ui-patterns/Cmdk/AiCommand.tsx +++ /dev/null @@ -1,530 +0,0 @@ -'use client' - -import type OpenAI from 'openai' -import { - Dispatch, - SetStateAction, - useCallback, - useEffect, - useReducer, - useRef, - useState, -} from 'react' -import ReactMarkdown from 'react-markdown' -import remarkGfm from 'remark-gfm' -import { SSE } from 'sse.js' - -import { isBrowser } from 'common' -import { - AiIconAnimation, - Button, - cn, - IconCornerDownLeft, - IconUser, - Input, - markdownComponents, - StatusIcon, -} from 'ui' -import { AiWarning } from './Command.alerts' -import { AiIconChat } from './Command.icons' -import { CommandGroup, CommandItem, useAutoInputFocus, useHistoryKeys } from './Command.utils' -import { useCommandMenu } from './CommandMenuContext' - -const questions = [ - 'How do I get started with Supabase?', - 'How do I run Supabase locally?', - 'How do I connect to my database?', - 'How do I run migrations? ', - 'How do I listen to changes in a table?', - 'How do I set up authentication?', -] - -export const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? '' - -export enum MessageRole { - User = 'user', - Assistant = 'assistant', -} - -export enum MessageStatus { - Pending = 'pending', - InProgress = 'in-progress', - Complete = 'complete', -} - -export interface Message { - role: MessageRole - content: string - status: MessageStatus -} - -interface NewMessageAction { - type: 'new' - message: Message -} - -interface UpdateMessageAction { - type: 'update' - index: number - message: Partial -} - -interface AppendContentAction { - type: 'append-content' - index: number - content: string -} - -interface ResetAction { - type: 'reset' -} - -type MessageAction = NewMessageAction | UpdateMessageAction | AppendContentAction | ResetAction - -function messageReducer(state: Message[], messageAction: MessageAction) { - let current = [...state] - const { type } = messageAction - - switch (type) { - case 'new': { - const { message } = messageAction - current.push(message) - break - } - case 'update': { - const { index, message } = messageAction - if (current[index]) { - Object.assign(current[index], message) - } - break - } - case 'append-content': { - const { index, content } = messageAction - if (current[index]) { - current[index].content += content - } - break - } - case 'reset': { - current = [] - break - } - default: { - throw new Error(`Unknown message action '${type}'`) - } - } - - return current -} - -export interface UseAiChatOptions { - messageTemplate?: (message: string) => string - setIsLoading?: Dispatch> -} - -export function useAiChat({ - messageTemplate = (message) => message, - setIsLoading, -}: UseAiChatOptions) { - const eventSourceRef = useRef() - - const [isResponding, setIsResponding] = useState(false) - const [hasError, setHasError] = useState(false) - - const [currentMessageIndex, setCurrentMessageIndex] = useState(1) - const [messages, dispatchMessage] = useReducer(messageReducer, []) - - const submit = useCallback( - async (query: string) => { - dispatchMessage({ - type: 'new', - message: { - status: MessageStatus.Complete, - role: MessageRole.User, - content: query, - }, - }) - dispatchMessage({ - type: 'new', - message: { - status: MessageStatus.Pending, - role: MessageRole.Assistant, - content: '', - }, - }) - setIsResponding(false) - setHasError(false) - setIsLoading?.(true) - - const eventSource = new SSE(`${BASE_PATH}/api/ai/docs`, { - headers: { - apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? '', - Authorization: `Bearer ${process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY}`, - 'Content-Type': 'application/json', - }, - payload: JSON.stringify({ - messages: messages - .filter(({ status }) => status === MessageStatus.Complete) - .map(({ role, content }) => ({ role, content })) - .concat({ role: MessageRole.User, content: messageTemplate(query) }), - }), - }) - - function handleError(err: T) { - setIsLoading?.(false) - setIsResponding(false) - setHasError(true) - console.error(err) - } - - eventSource.addEventListener('error', handleError) - eventSource.addEventListener('message', (e: MessageEvent) => { - try { - setIsLoading?.(false) - - if (e.data === '[DONE]') { - setIsResponding(false) - dispatchMessage({ - type: 'update', - index: currentMessageIndex, - message: { - status: MessageStatus.Complete, - }, - }) - setCurrentMessageIndex((x) => x + 2) - return - } - - dispatchMessage({ - type: 'update', - index: currentMessageIndex, - message: { - status: MessageStatus.InProgress, - }, - }) - - setIsResponding(true) - - const completionChunk: OpenAI.Chat.Completions.ChatCompletionChunk = JSON.parse(e.data) - const [ - { - delta: { content }, - }, - ] = completionChunk.choices - - if (content) { - dispatchMessage({ - type: 'append-content', - index: currentMessageIndex, - content, - }) - } - } catch (err) { - handleError(err) - } - }) - - eventSource.stream() - - eventSourceRef.current = eventSource - - setIsLoading?.(true) - }, - [currentMessageIndex, messages, messageTemplate] - ) - - function reset() { - eventSourceRef.current?.close() - eventSourceRef.current = undefined - setIsResponding(false) - setHasError(false) - dispatchMessage({ - type: 'reset', - }) - } - - return { - submit, - reset, - messages, - isResponding, - hasError, - } -} - -/** - * Perform a one-off query to AI based on a snapshot of messages - */ -export function queryAi(messages: Message[], timeout = 0) { - return new Promise((resolve, reject) => { - const eventSource = new SSE(`${BASE_PATH}/api/ai/docs`, { - headers: { - apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? '', - Authorization: `Bearer ${process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY}`, - 'Content-Type': 'application/json', - }, - payload: JSON.stringify({ - messages: messages.map(({ role, content }) => ({ role, content })), - }), - }) - - let timeoutId: number | undefined - - function handleError(err: T) { - if (timeoutId) { - clearTimeout(timeoutId) - } - console.error(err) - reject(err) - } - - if (timeout > 0) { - timeoutId = window.setTimeout(() => { - handleError(new Error('AI query timed out')) - }, timeout) - } - - let answer = '' - - eventSource.addEventListener('error', handleError) - eventSource.addEventListener('message', (e: MessageEvent) => { - try { - if (e.data === '[DONE]') { - if (timeoutId) { - clearTimeout(timeoutId) - } - resolve(answer) - return - } - - const completionChunk: OpenAI.Chat.Completions.ChatCompletionChunk = JSON.parse(e.data) - const [ - { - delta: { content }, - }, - ] = completionChunk.choices - - if (content) { - answer += content - } - } catch (err) { - handleError(err) - } - }) - - eventSource.stream() - }) -} - -const AiCommand = () => { - const { isLoading, setIsLoading, search, setSearch } = useCommandMenu() - const isMobile = isBrowser && window.screen.width <= 640 - - const { submit, reset, messages, isResponding, hasError } = useAiChat({ - setIsLoading, - }) - - /** - * Disable autofocusing on the input on mobile - * since it messes up with the dialog height and positioning - * */ - const inputRef = useAutoInputFocus(!isMobile) - - useHistoryKeys({ - enable: !isResponding, - messages: messages - .filter(({ role }) => role === MessageRole.User) - .map(({ content }) => content), - setPrompt: setSearch, - }) - - const handleSubmit = useCallback( - (message: string) => { - setSearch('') - submit(message) - }, - [submit] - ) - - const handleReset = useCallback(() => { - setSearch('') - reset() - }, [reset]) - - useEffect(() => { - if (search) { - handleSubmit(search) - } - }, []) - // Detect an IME composition (so that we can ignore Enter keypress) - const [isImeComposing, setIsImeComposing] = useState(false) - const showSubmitButton = !isLoading && !isResponding && search - - return ( -
    e.stopPropagation()}> -
    - - {showSubmitButton ? ( - - ) : null} - - } - onChange={(e) => { - if (!isLoading || !isResponding) { - setSearch(e.target.value) - } - }} - onCompositionStart={() => setIsImeComposing(true)} - onCompositionEnd={() => setIsImeComposing(false)} - onKeyDown={(e) => { - switch (e.key) { - case 'Enter': - if (!search || isLoading || isResponding || isImeComposing) { - return - } - return handleSubmit(search) - default: - return - } - }} - /> -
    -
    - {messages.length > 0 && !hasError && } -
    - {!hasError && - messages.map((message, index) => { - switch (message.role) { - case MessageRole.User: - return ( -
    -
    - -
    -
    {message.content}
    -
    - ) - case MessageRole.Assistant: - return ( - - ) - } - })} - - {messages.length === 0 && !hasError && ( - - {questions.map((question) => { - const key = question.replace(/\s+/g, '_') - return ( - { - if (!search) { - handleSubmit(question) - } - }} - key={key} - > - - {question} - - ) - })} - - )} - {hasError && ( -
    - -
    -

    - Sorry, looks like Supabase AI is having a hard time! -

    -

    - Please try again in a bit. -

    -
    - -
    - )} - -
    -
    -
    -
    - ) -} - -export default AiCommand diff --git a/packages/ui-patterns/Cmdk/ChildItem.tsx b/packages/ui-patterns/Cmdk/ChildItem.tsx deleted file mode 100644 index 53e29cbb68e..00000000000 --- a/packages/ui-patterns/Cmdk/ChildItem.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { useCommandState } from 'cmdk' -import { CommandItem } from './Command.utils' -import React from 'react' - -export default function ChildItem({ children, isSubItem, ...props }: any) { - const search = useCommandState((state) => state.search) - // if search is empty & items is marked as a subItem, don't show it - // ie: only show these items in search results, not top level - if (!search && isSubItem) return null - return {children} -} diff --git a/packages/ui-patterns/Cmdk/Command.Badges.tsx b/packages/ui-patterns/Cmdk/Command.Badges.tsx deleted file mode 100644 index fb1686e6def..00000000000 --- a/packages/ui-patterns/Cmdk/Command.Badges.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Badge } from 'ui' -import { Microscope } from 'lucide-react' - -export const BadgeExperimental = () => { - return ( - - Experimental - - ) -} diff --git a/packages/ui-patterns/Cmdk/Command.alerts.tsx b/packages/ui-patterns/Cmdk/Command.alerts.tsx deleted file mode 100644 index 7c11bfd4651..00000000000 --- a/packages/ui-patterns/Cmdk/Command.alerts.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Alert } from 'ui' -import { Microscope } from 'lucide-react' - -export const AiWarning = ({ className }: { className?: string }) => ( - } - > -

    Always verify the output before executing.

    -
    -) - -export const IncludeSchemaAlert = () => ( - - Start a new conversation to change this configuration - -) - -export const ExcludeSchemaAlert = () => ( - - Start a new conversation to change this configuration - -) diff --git a/packages/ui-patterns/Cmdk/Command.icons.tsx b/packages/ui-patterns/Cmdk/Command.icons.tsx deleted file mode 100644 index e9a81ab6608..00000000000 --- a/packages/ui-patterns/Cmdk/Command.icons.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { AiIconAnimation } from 'ui/src/layout/ai-icon-animation/ai-icon-animation' - -interface AiIconProps { - className?: string -} - -export const AiIcon = ({ className }: AiIconProps) => ( - -) - -export const AiIconChat = ({ loading = false }) => ( - -) diff --git a/packages/ui-patterns/Cmdk/Command.types.ts b/packages/ui-patterns/Cmdk/Command.types.ts deleted file mode 100644 index 476ebc3c504..00000000000 --- a/packages/ui-patterns/Cmdk/Command.types.ts +++ /dev/null @@ -1,14 +0,0 @@ - -export interface NavItem { - label: string; - icon: string; - url: string; - sites: Site[]; - subItems?: NavItem[]; -} - -export type Site = "docs" | "dashboard"; - -export interface NavItems { - navItems: NavItem[]; -} diff --git a/packages/ui-patterns/Cmdk/Command.utils.tsx b/packages/ui-patterns/Cmdk/Command.utils.tsx deleted file mode 100644 index ee561c3cb89..00000000000 --- a/packages/ui-patterns/Cmdk/Command.utils.tsx +++ /dev/null @@ -1,463 +0,0 @@ -import * as React from 'react' -import { Command as CommandPrimitive } from 'cmdk' -import { ErrorBoundary } from 'react-error-boundary' - -import { cn } from 'ui/src/lib/utils' - -import { AlertTriangle } from 'lucide-react' -import { DetailedHTMLProps, HTMLAttributes, KeyboardEventHandler } from 'react' -import { Dialog, DialogContent } from 'ui' -import { Button } from 'ui/src/components/Button' -import { LoadingLine } from 'ui/src/components/LoadingLine/LoadingLine' -import { useCommandMenu } from './CommandMenuContext' -import { useBreakpoint } from 'common' - -type CommandPrimitiveElement = React.ElementRef -type CommandPrimitiveProps = React.ComponentPropsWithoutRef - -export const copyToClipboard = (str: string, callback = () => {}) => { - const focused = window.document.hasFocus() - if (focused) { - window.navigator?.clipboard?.writeText(str).then(callback) - } else { - console.warn('Unable to copy to clipboard') - } -} - -export const Command = React.forwardRef( - ({ className, ...props }, ref) => ( - - ) -) - -Command.displayName = CommandPrimitive.displayName - -export function CommandError({ resetErrorBoundary }: { resetErrorBoundary: () => void }) { - return ( -
    -
    - -

    - Sorry, looks like we're having some issues with the command menu! -

    -

    Please try again in a bit.

    - -
    -
    - ) -} - -interface CommandDialogProps extends React.ComponentProps { - onKeyDown?: KeyboardEventHandler - page?: number | string - visible?: boolean - setIsOpen: (open: boolean) => void -} - -type CommandPrimitiveDialogElement = React.ElementRef - -export const CommandDialog = React.forwardRef( - ({ children, onKeyDown, page, setIsOpen, ...props }: CommandDialogProps, ref) => { - const isOpen = props.visible || props.open - const isMobile = useBreakpoint() - - return ( - - isMobile && e.preventDefault()} - onInteractOutside={(e) => { - // Only hide menu when clicking outside, not focusing outside - // Prevents Firefox dropdown issue that immediately closes menu after opening - if (e.type === 'dismissableLayer.pointerDownOutside') { - setIsOpen(!open) - } - }} - hideClose - size="xlarge" - dialogOverlayProps={{ - className: cn('overflow-hidden flex data-closed:delay-100'), - }} - className={cn( - 'relative my-0 mx-auto rounded-t-lg overflow-y-scroll', - 'h-[85dvh] mt-[15vh] md:max-h-[500px] md:mt-0 left-0 bottom-0 md:bottom-auto', - 'place-self-start md:place-self-auto', - isOpen && '!animate-in !slide-in-from-bottom !duration-300', - 'data-[state=closed]:!animate-out data-[state=closed]:!slide-out-to-bottom', - 'md:data-[state=open]:!animate-in md:data-[state=closed]:!animate-out', - 'md:data-[state=closed]:!zoom-out-95 md:data-[state=open]:!zoom-in-95', - 'md:data-[state=closed]:!slide-out-to-left-[0%] md:data-[state=closed]:!slide-out-to-top-[0%]', - 'md:data-[state=open]:!slide-in-from-left-[0%] md:data-[state=open]:!slide-in-from-top-[0%]' - )} - > - - - {children} - - - - - ) - } -) - -CommandDialog.displayName = 'CommandDialog' - -type CommandPrimitiveInputElement = React.ElementRef -type CommandPrimitiveInputProps = React.ComponentPropsWithoutRef - -export const CommandInput = React.forwardRef< - CommandPrimitiveInputElement, - CommandPrimitiveInputProps ->(({ className, value, onValueChange, ...props }, ref) => { - const { isLoading } = useCommandMenu() - - return ( -
    - - -
    - ) -}) - -CommandInput.displayName = CommandPrimitive.Input.displayName - -type CommandPrimitiveListElement = React.ElementRef -type CommandPrimitiveListProps = React.ComponentPropsWithoutRef - -export const CommandList = React.forwardRef( - ({ className, ...props }, ref) => ( - - ) -) - -CommandList.displayName = CommandPrimitive.List.displayName - -type CommandPrimitiveEmptyElement = React.ElementRef -type CommandPrimitiveEmptyProps = React.ComponentPropsWithoutRef - -export const CommandEmpty = React.forwardRef< - CommandPrimitiveEmptyElement, - CommandPrimitiveEmptyProps ->((props, ref) => ( - -)) - -CommandEmpty.displayName = CommandPrimitive.Empty.displayName - -type CommandPrimitiveGroupElement = React.ElementRef -type CommandPrimitiveGroupProps = React.ComponentPropsWithoutRef - -export const CommandGroup = React.forwardRef< - CommandPrimitiveGroupElement, - CommandPrimitiveGroupProps ->(({ className, ...props }, ref) => ( - -)) - -CommandGroup.displayName = CommandPrimitive.Group.displayName - -type CommandPrimitiveSeparatorElement = React.ElementRef -type CommandPrimitiveSeparatorProps = React.ComponentPropsWithoutRef< - typeof CommandPrimitive.Separator -> - -export const CommandSeparator = React.forwardRef< - CommandPrimitiveSeparatorElement, - CommandPrimitiveSeparatorProps ->(({ className, ...props }, ref) => ( - -)) - -CommandSeparator.displayName = CommandPrimitive.Separator.displayName - -type CommandPrimitiveItemElement = React.ElementRef -type CommandPrimitiveItemProps = React.ComponentPropsWithoutRef - -export interface CommandItemProps extends CommandPrimitiveItemProps { - type: 'link' | 'block-link' | 'command' - badge?: React.ReactNode -} - -export const CommandItem = React.forwardRef( - ({ className, type, children, badge, ...props }, ref) => ( - -
    -
    {children}
    - {badge} -
    -
    - ) -) - -CommandItem.displayName = CommandPrimitive.Item.displayName - -export const CommandItemStale = React.forwardRef( - ({ className, ...props }, ref) => ( - - ) -) - -CommandItemStale.displayName = 'CommandItemStale' - -interface CommandShortcutProps { - className?: string - children?: React.ReactNode - onClick?: () => void - type?: 'default' | 'breadcrumb' -} - -export const CommandShortcut = ({ - className, - children, - onClick, - type = 'default', -}: CommandShortcutProps) => { - return ( - - ) -} - -CommandShortcut.displayName = 'CommandShortcut' - -export const CommandLabel = ({ className, ...props }: React.HTMLAttributes) => { - return -} - -CommandShortcut.displayName = 'CommandLabel' - -export interface TextHighlighterProps - extends DetailedHTMLProps, HTMLSpanElement> { - text: string - query: string -} - -export const TextHighlighter = ({ text, query, ...props }: TextHighlighterProps) => { - // Wrap all instances of `query` in a span to make them bold - const elements = text.split(query).flatMap((part, index, parts) => { - const returnValue = [<>{part}] - - // Add back the wrapped `query` (if it's not the last element) - if (index !== parts.length - 1) { - returnValue.push({query}) - } - - return returnValue - }) - - return {elements} -} - -TextHighlighter.displayName = 'TextHighlighter' - -export interface UseHistoryKeysOptions { - enable: boolean - messages: string[] - setPrompt: (prompt: string) => void -} - -/** - * Enables a shell-style message history when hitting - * up/down on the keyboard - */ -export function useHistoryKeys({ enable, messages, setPrompt }: UseHistoryKeysOptions) { - // Message index when hitting up/down on the keyboard (shell style) - const [, setMessageSelectionIndex] = React.useState(0) - - React.useEffect(() => { - if (enable) { - return - } - - // Note: intentionally setting index to 1 greater than max index - setMessageSelectionIndex(messages.length) - }, [messages, enable]) - - React.useEffect(() => { - function onKeyDown(e: KeyboardEvent) { - switch (e.key) { - case 'ArrowUp': - setMessageSelectionIndex((index) => { - const newIndex = Math.max(index - 1, 0) - const newMessage = messages[newIndex] - if (newMessage) { - setPrompt(newMessage) - } - return newIndex - }) - return - case 'ArrowDown': - setMessageSelectionIndex((index) => { - const newIndex = Math.min(index + 1, messages.length) - const newMessage = messages[newIndex] - if (newMessage) { - setPrompt(newMessage) - } - return newIndex - }) - return - default: - return - } - } - - window.addEventListener('keydown', onKeyDown) - - return () => { - window.removeEventListener('keydown', onKeyDown) - } - }, [messages]) -} - -/** - * Automatically focuses an input on key press - * and on load (after the call stack) - * - * @returns An input ref for the input to focus - */ -export function useAutoInputFocus(isEnabled: boolean = true) { - const [input, setInput] = React.useState() - - // Use a callback-style ref to access the element when it mounts - const inputRef = React.useCallback((inputElement: HTMLInputElement) => { - if (isEnabled && inputElement) { - setInput(inputElement) - - // We need to delay the focus until the end of the call stack - // due to order of operations - setTimeout(() => { - inputElement.focus() - }, 0) - } - }, []) - - // Focus the input when typing from anywhere - React.useEffect(() => { - function onKeyDown(e: KeyboardEvent) { - if (!e.ctrlKey && !e.altKey && !e.metaKey && e.key !== 'Tab') { - input?.focus() - } - } - - isEnabled && window.addEventListener('keydown', onKeyDown) - - return () => { - window.removeEventListener('keydown', onKeyDown) - } - }, [input]) - - return inputRef -} diff --git a/packages/ui-patterns/Cmdk/CommandMenu.tsx b/packages/ui-patterns/Cmdk/CommandMenu.tsx deleted file mode 100644 index 63a09ff37a5..00000000000 --- a/packages/ui-patterns/Cmdk/CommandMenu.tsx +++ /dev/null @@ -1,355 +0,0 @@ -'use client' - -import { useParams } from 'common' -import { useRouter } from 'next/navigation' -import * as React from 'react' - -import { IconArrowRight } from 'ui/src/components/Icon/icons/IconArrowRight' -import { IconBook } from 'ui/src/components/Icon/icons/IconBook' -import { IconColumns } from 'ui/src/components/Icon/icons/IconColumns' -import { IconHome } from 'ui/src/components/Icon/icons/IconHome' -import { IconInbox } from 'ui/src/components/Icon/icons/IconInbox' -import { IconKey } from 'ui/src/components/Icon/icons/IconKey' -import { IconLifeBuoy } from 'ui/src/components/Icon/icons/IconLifeBuoy' -import { IconLink } from 'ui/src/components/Icon/icons/IconLink' -import { IconMonitor } from 'ui/src/components/Icon/icons/IconMonitor' -import { IconPhone } from 'ui/src/components/Icon/icons/IconPhone' -import { IconUser } from 'ui/src/components/Icon/icons/IconUser' -import { AiIconAnimation } from 'ui/src/layout/ai-icon-animation/ai-icon-animation' -import APIKeys from './APIKeys' -import AiCommand from './AiCommand' -import ChildItem from './ChildItem' -import { BadgeExperimental } from './Command.Badges' -import { COMMAND_ROUTES } from './Command.constants' -import { AiIcon } from './Command.icons' -import { - CommandDialog, - CommandGroup, - CommandInput, - CommandItem, - CommandLabel, - CommandList, - copyToClipboard, -} from './Command.utils' -import { useCommandMenu } from './CommandMenuContext' -import CommandMenuShortcuts from './CommandMenuShortcuts' -import DocsSearch from './DocsSearch' -import GenerateSQL from './GenerateSQL' -import SearchableStudioItems from './SearchableStudioItems' -import ThemeOptions from './ThemeOptions' -import sharedItems from './utils/shared-nav-items.json' -import useDragToClose from 'common/hooks/useDragToClose' -import { DialogHeader } from 'ui' - -export const CHAT_ROUTES = [ - COMMAND_ROUTES.AI, // this one is temporary - COMMAND_ROUTES.AI_ASK_ANYTHING, - COMMAND_ROUTES.AI_RLS_POLICY, - COMMAND_ROUTES.GENERATE_SQL, -] - -const iconPicker: { [key: string]: React.ReactNode } = { - arrowRight: , - book: , - inbox: , - mobile: , - person: , - services: , - contact: , - icon: , - products: , -} - -const CommandMenu = () => { - const router = useRouter() - const { ref: projectRef } = useParams() - - const { - isOpen, - setIsOpen, - search, - setSearch, - pages, - setPages, - currentPage, - site, - project, - inputRef: commandInputRef, - } = useCommandMenu() - const showCommandInput = !currentPage || !CHAT_ROUTES.includes(currentPage) - - // This function has been added to prevent the use of double quotes in the search docs input due to an issue with the cmdk-supabase module. This function can be removed when we transition to using cmdk. - const handleInputChange = (value: string) => { - const newValue = value.replace(/"/g, '') // Remove double quotes - setSearch(newValue) - } - - const commandListMaxHeight = - currentPage === COMMAND_ROUTES.DOCS_SEARCH || - currentPage === COMMAND_ROUTES.AI || - currentPage === COMMAND_ROUTES.GENERATE_SQL - ? 'min(600px, 70vh)' - : 'auto' - - const { - ref: dialogRef, - handleTouchStart, - handleTouchMove, - handleTouchEnd, - } = useDragToClose({ - onClose: () => setIsOpen(!open), - }) - - return ( - <> - { - setIsOpen(!isOpen) - }} - > - - {pages.length > 0 && } - {showCommandInput && ( - - )} - - - {!currentPage && ( - <> - - setPages([...pages, COMMAND_ROUTES.DOCS_SEARCH])} - forceMount={true} - > - - - - Search the docs - {search ? ( - <> - {': '} - {search} - - ) : ( - '...' - )} - - - { - setPages([...pages, COMMAND_ROUTES.AI]) - }} - forceMount={true} - > - - - Ask Supabase AI - {search ? ( - <> - {': '} - {search} - - ) : ( - '...' - )} - - - - - {site === 'docs' && ( - - {sharedItems.quickstarts.map((item) => ( - router.push(item.url)}> - - - Start with {item.label} - - - ))} - - )} - - {site === 'docs' && ( - - {sharedItems.projectTools.map((item) => ( - - window.open(`https://supabase.com/dashboard${item.url}`, '_blank') - } - > - - - {item.label} - - - ))} - - )} - - {site === 'docs' && ( - - {sharedItems.tools.map((item) => ( - - window.open(`https://supabase.com/dashboard${item.url}`, '_blank') - } - > - - - Go to {item.label} - - - ))} - - )} - - {site === 'studio' && ( - - } - onSelect={() => setPages([...pages, COMMAND_ROUTES.GENERATE_SQL])} - > - - Generate SQL with Supabase AI - - - )} - - {site === 'studio' && projectRef !== undefined && ( - - { - setSearch('') - setPages([...pages, COMMAND_ROUTES.API_KEYS]) - }} - > - - Get API keys - - {project?.apiUrl !== undefined && ( - { - copyToClipboard(project?.apiUrl ?? '') - setIsOpen(false) - }} - className="space-x-2" - > - - Copy API URL - - )} - - )} - - {site === 'studio' && ( - - {sharedItems.tools.map((item) => { - const itemUrl = projectRef ? item.url.replace('_', projectRef) : item.url - - return ( - { - router.push(itemUrl) - setIsOpen(false) - }} - > - - - Go to {item.label} - - - ) - })} - - )} - - {/* */} - - - {sharedItems.support.map((item) => ( - router.push(item.url)}> - - - Go to {item.label} - - - ))} - - - {site === 'docs' && ( - - {sharedItems.docsGeneral.map((item) => ( - router.push(item.url)}> - {item?.icon && iconPicker[item.icon]} - {item.label} - - ))} - - )} - - - { - setSearch('') - setPages([...pages, 'Theme']) - }} - > - - Change theme - - - - - {site === 'studio' && search && } - - )} - {currentPage === COMMAND_ROUTES.AI && } - {currentPage === COMMAND_ROUTES.DOCS_SEARCH && } - {currentPage === COMMAND_ROUTES.GENERATE_SQL && } - {currentPage === COMMAND_ROUTES.THEME && } - {currentPage === COMMAND_ROUTES.API_KEYS && } - - - - ) -} - -export default CommandMenu diff --git a/packages/ui-patterns/Cmdk/CommandMenuContext.tsx b/packages/ui-patterns/Cmdk/CommandMenuContext.tsx deleted file mode 100644 index 60a41c4d876..00000000000 --- a/packages/ui-patterns/Cmdk/CommandMenuContext.tsx +++ /dev/null @@ -1,43 +0,0 @@ -'use client' -import { createContext, useContext } from 'react' - -export interface CommandMenuContextValue { - isOpen: boolean - setIsOpen: React.Dispatch> - isLoading: boolean - setIsLoading: React.Dispatch> - search: string - setSearch: React.Dispatch> - pages: string[] - setPages: React.Dispatch> - currentPage?: string - inputRef: React.RefObject - site: 'studio' | 'docs' | 'website' - - /** - * Project metadata for easy retrieval - */ - project?: { ref?: string; apiKeys?: { anon?: string; service?: string }; apiUrl?: string } - /** - * Any additional metadata that CMDK component can use in its AI prompts - */ - metadata?: { definitions?: string; flags?: { [key: string]: string } } - /** - * Opt in flag to use additional metadata in AI prompts - */ - isOptedInToAI: boolean - - // Optional callback to save a generated SQL output - saveGeneratedSQL?: (answer: string, title: string) => Promise -} - -export const CommandMenuContext = createContext(undefined) -export const useCommandMenu = () => { - const context = useContext(CommandMenuContext) - - if (context === undefined) { - throw new Error('useCommandMenu was used outside of CommandMenuProvider') - } - - return context -} diff --git a/packages/ui-patterns/Cmdk/CommandMenuProvider.tsx b/packages/ui-patterns/Cmdk/CommandMenuProvider.tsx deleted file mode 100644 index 7a98b385f33..00000000000 --- a/packages/ui-patterns/Cmdk/CommandMenuProvider.tsx +++ /dev/null @@ -1,123 +0,0 @@ -'use client' - -import dynamic from 'next/dynamic' -import { ElementRef, PropsWithChildren, useEffect, useRef, useState } from 'react' -import { CommandInput } from './Command.utils' -import { CommandMenuContext } from './CommandMenuContext' - -// `CommandMenu` is heavy - code split to reduce app bundle size -const CommandMenu = dynamic(() => import('./CommandMenu'), { - loading: () =>

    Loading...

    , -}) - -export interface CommandMenuProviderProps { - site: 'studio' | 'docs' | 'website' - projectRef?: string - /** - * Project's API keys, for easy access through CMDK - */ - apiKeys?: { anon?: string; service?: string } - apiUrl?: string - /** - * Opt in flag to use additional metadata in AI prompts - */ - isOptedInToAI?: boolean - /** - * Any additional metadata that CMDK component can use in its AI prompts - */ - metadata?: { definitions?: string; flags?: { [key: string]: string } } - /** - * Call back when save SQL snippet button is selected - */ - saveGeneratedSQL?: (answer: string, title: string) => Promise -} - -const CommandMenuProvider = ({ - children, - site, - projectRef, - apiKeys, - apiUrl, - metadata, - isOptedInToAI = false, - saveGeneratedSQL, -}: PropsWithChildren) => { - const [isOpen, setIsOpen] = useState(false) - const [isLoading, setIsLoading] = useState(false) - const [search, setSearch] = useState('') - const [pages, setPages] = useState([]) - const inputRef = useRef>(null) - const currentPage = pages[pages.length - 1] - - const project = projectRef !== undefined ? { ref: projectRef, apiKeys, apiUrl } : undefined - - useKeyboardEvents({ setIsOpen, currentPage, setSearch, setPages }) - - return ( - - {children} - - - ) -} -function useKeyboardEvents({ - setIsOpen, - currentPage, - setSearch, - setPages, -}: { - setIsOpen: (isOpen: boolean) => void - setSearch: React.Dispatch> - setPages: React.Dispatch> - currentPage?: string -}) { - useEffect(() => { - function onKeyDown(event: KeyboardEvent) { - switch (event.key) { - case 'Escape': - event.preventDefault() - // if on homepage, close the command palette - if (!currentPage) setIsOpen(false) - setSearch('') - // if NOT on homepage, return to last page - setPages((pages) => pages.slice(0, -1)) - return - case 'k': - case '/': - if (event.metaKey || event.ctrlKey) { - // Some browsers (ie. firefox) will focus the address bar by default - event.preventDefault() - - setIsOpen(true) - } - return - } - } - - window.addEventListener('keydown', onKeyDown) - - return () => { - window.removeEventListener('keydown', onKeyDown) - } - }, [setIsOpen, currentPage]) -} - -export default CommandMenuProvider diff --git a/packages/ui-patterns/Cmdk/CommandMenuShortcuts.tsx b/packages/ui-patterns/Cmdk/CommandMenuShortcuts.tsx deleted file mode 100644 index 3a0eed9b84f..00000000000 --- a/packages/ui-patterns/Cmdk/CommandMenuShortcuts.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { IconArrowLeft } from 'ui/src/components/Icon/icons/IconArrowLeft' - -import { BadgeExperimental } from './Command.Badges' -import { COMMAND_ROUTES } from './Command.constants' -import { CommandShortcut } from './Command.utils' -import { useCommandMenu } from './CommandMenuContext' - -export default function CommandMenuShortcuts() { - const { setPages, pages, currentPage } = useCommandMenu() - - return ( -
    -
    - setPages([])}> -
    - -
    -
    - - {pages.map((page, index) => ( - { - if (index === pages.length - 1) { - return - } - setPages(pages.slice(0, index - 1)) - }} - > - {page} - - ))} - - {currentPage && isExperimental(currentPage) && } -
    -
    - ) -} - -/** - * TODO: remove this function and store this info on each page - * Temporary function to determine whether or not to show 'Experimental' badge - */ -function isExperimental(page: string) { - switch (page) { - case COMMAND_ROUTES.AI: - case COMMAND_ROUTES.GENERATE_SQL: - return true - default: - return false - } -} diff --git a/packages/ui-patterns/Cmdk/DocsSearch.tsx b/packages/ui-patterns/Cmdk/DocsSearch.tsx deleted file mode 100644 index deaa3ca67d3..00000000000 --- a/packages/ui-patterns/Cmdk/DocsSearch.tsx +++ /dev/null @@ -1,343 +0,0 @@ -'use client' - -import { useSupabaseClient } from '@supabase/auth-helpers-react' -import { - type DocsSearchResult as Page, - type DocsSearchResultSection as PageSection, - DocsSearchResultType as PageType, - useDocsSearch, -} from 'common' -import { Book, ChevronRight, Github, Hash, Loader2, MessageSquare, Search } from 'lucide-react' -import { useRouter } from 'next/navigation' -import { useEffect, useRef } from 'react' -import { Button, StatusIcon } from 'ui' -import { CommandGroup, CommandItem, CommandLabel, TextHighlighter } from './Command.utils' -import { useCommandMenu } from './CommandMenuContext' - -const questions = [ - 'How do I get started with Supabase?', - 'How do I run Supabase locally?', - 'How do I connect to my database?', - 'How do I run migrations? ', - 'How do I listen to changes in a table?', - 'How do I set up authentication?', -] - -const DocsSearch = () => { - const supabaseClient = useSupabaseClient() - const { - searchState: state, - handleDocsSearch: handleSearch, - handleDocsSearchDebounced: debouncedSearch, - resetSearch, - } = useDocsSearch(supabaseClient) - const { search, setSearch, inputRef, site, setIsOpen } = useCommandMenu() - const initialLoad = useRef(true) - const router = useRouter() - - async function openLink(pageType: PageType, link: string) { - switch (pageType) { - case PageType.Markdown: - case PageType.Reference: - if (site === 'docs') { - await router.push(link) - return setIsOpen(false) - } else if (site === 'website') { - await router.push(`/docs${link}`) - setIsOpen(false) - } else { - window.open(`https://supabase.com/docs${link}`, '_blank') - setIsOpen(false) - } - break - case PageType.Integration: - if (site === 'website') { - router.push(link) - setIsOpen(false) - } else { - window.open(`https://supabase.com${link}`, '_blank') - setIsOpen(false) - } - break - case PageType.GithubDiscussion: - window.open(link, '_blank') - setIsOpen(false) - break - default: - throw new Error(`Unknown page type '${pageType}'`) - } - } - - const hasResults = - state.status === 'fullResults' || - state.status === 'partialResults' || - (state.status === 'loading' && state.staleResults.length > 0) - - function handleResetPrompt() { - setSearch('') - resetSearch() - } - - useEffect(() => { - if (initialLoad.current) { - // On first navigation into 'docs search' page, search immediately - if (search) { - handleSearch(search) - } - initialLoad.current = false - } else if (search) { - // Else if user is typing, debounce search - debouncedSearch(search) - } else { - // If user clears search, reset results - debouncedSearch.cancel() - resetSearch() - } - }, [search, handleSearch, debouncedSearch, resetSearch]) - - // Immediately run search if user presses enter - // and abort any debounced searches that are waiting - useEffect(() => { - const handleEnter = (event: KeyboardEvent) => { - if ( - event.key === 'Enter' && - document.activeElement === inputRef.current && - search && - // If there are results, cmdk menu will trigger navigation to the highlighted - // result on Enter, even though the active element is the input - !hasResults - ) { - event.preventDefault() - debouncedSearch.cancel() - handleSearch(search) - } - } - - inputRef.current?.addEventListener('keydown', handleEnter) - - return () => inputRef.current?.removeEventListener('keydown', handleEnter) - }, [search, hasResults]) - - const ChevronArrow = () => ( - - ) - - const IconContainer = ( - props: React.DetailedHTMLProps, HTMLDivElement> - ) => ( -
    - ) - - return ( - <> - {hasResults && - ('results' in state ? state.results : state.staleResults).map((page, i) => { - return ( - - { - openLink(page.type, formatPageUrl(page)) - }} - forceMount={true} - > -
    - {getPageIcon(page)} -
    - - - - {(page.description || page.subtitle) && ( -
    - -
    - )} -
    -
    - - -
    - {page.sections.length > 0 && ( -
    - {page.sections.map((section, i) => ( - { - openLink(page.type, formatSectionUrl(page, section)) - }} - key={`${page.path}__${section.heading}-item`} - value={`${encodeURIComponent( - page.title - )}__${encodeURIComponent(section.heading)}-item-index-${i}`} - forceMount={true} - type="block-link" - > -
    - {getPageSectionIcon(page)} -
    - - - - {section.heading && ( - - - - )} -
    -
    - -
    - ))} -
    - )} -
    - ) - })} - {state.status === 'initial' && ( - - {questions.map((question) => { - const key = question.replace(/\s+/g, '_') - return ( - { - if (!search) { - handleSearch(question) - setSearch(question) - } - }} - type="command" - key={key} - > - - {question} - - ) - })} - - )} - {state.status === 'loading' && state.staleResults.length === 0 && ( -
    - -

    Searching for results

    -
    - )} - {state.status === 'noResults' && ( -
    - -

    No results found.

    - -
    - )} - {state.status === 'error' && ( -
    - -
    -

    - Sorry, looks like we're having some issues with search. -

    -

    Please try again in a bit.

    -
    - -
    - )} - - ) -} - -export default DocsSearch - -export function formatPageUrl(page: Page) { - switch (page.type) { - case PageType.Markdown: - case PageType.Reference: - case PageType.Integration: - case PageType.GithubDiscussion: - return page.path - default: - throw new Error(`Unknown page type '${page.type}'`) - } -} - -export function formatSectionUrl(page: Page, section: PageSection) { - switch (page.type) { - case PageType.Markdown: - case PageType.GithubDiscussion: - return `${formatPageUrl(page)}#${section.slug ?? ''}` - case PageType.Reference: - return `${formatPageUrl(page)}/${section.slug ?? ''}` - case PageType.Integration: - // [Charis] Markdown headings on integrations pages don't have slugs yet - return formatPageUrl(page) - default: - throw new Error(`Unknown page type '${page.type}'`) - } -} - -export function getPageIcon(page: Page) { - switch (page.type) { - case PageType.Markdown: - case PageType.Reference: - case PageType.Integration: - return - case PageType.GithubDiscussion: - return - default: - throw new Error(`Unknown page type '${page.type}'`) - } -} - -export function getPageSectionIcon(page: Page) { - switch (page.type) { - case PageType.Markdown: - case PageType.Reference: - case PageType.Integration: - return - case PageType.GithubDiscussion: - return - default: - throw new Error(`Unknown page type '${page.type}'`) - } -} diff --git a/packages/ui-patterns/Cmdk/GenerateSQL/GenerateSQL.tsx b/packages/ui-patterns/Cmdk/GenerateSQL/GenerateSQL.tsx deleted file mode 100644 index 61f82eb2ac1..00000000000 --- a/packages/ui-patterns/Cmdk/GenerateSQL/GenerateSQL.tsx +++ /dev/null @@ -1,339 +0,0 @@ -import React from 'react' -import { format } from 'sql-formatter' -import { useCallback, useEffect, useRef, useState } from 'react' -import { - Button, - CodeBlock, - IconAlertTriangle, - IconCornerDownLeft, - IconUser, - Input, - Toggle, - Tabs, -} from 'ui' -import { MessageRole, MessageStatus, useAiChat, UseAiChatOptions } from './../AiCommand' - -import { cn } from 'ui/src/lib/utils' -import { AiIcon, AiIconChat } from '../Command.icons' -import { CommandItem, useAutoInputFocus, useHistoryKeys } from '../Command.utils' -import { useCommandMenu } from '../CommandMenuContext' -import { SAMPLE_QUERIES } from '../Command.constants' -import SQLOutputActions from './SQLOutputActions' -import { generatePrompt } from './GenerateSQL.utils' -import { ExcludeSchemaAlert, IncludeSchemaAlert, AiWarning } from '../Command.alerts' -import { StatusIcon } from 'ui' - -const GenerateSQL = () => { - const [includeSchemaMetadata, setIncludeSchemaMetadata] = useState(false) - const [selectedCategory, setSelectedCategory] = useState(SAMPLE_QUERIES[0].category) - - const { isLoading, setIsLoading, search, setSearch, isOptedInToAI, metadata, project } = - useCommandMenu() - - const { flags, definitions } = metadata || {} - const allowSendingSchemaMetadata = - project?.ref !== undefined && flags?.allowCMDKDataOptIn && isOptedInToAI - - const messageTemplate = useCallback>( - (message) => - generatePrompt(message, isOptedInToAI && includeSchemaMetadata ? definitions : undefined), - [isOptedInToAI, includeSchemaMetadata, definitions] - ) - - const { submit, reset, messages, isResponding, hasError } = useAiChat({ - messageTemplate, - setIsLoading, - }) - - const inputRef = useAutoInputFocus() - - useHistoryKeys({ - enable: !isResponding, - messages: messages - .filter(({ role }) => role === MessageRole.User) - .map(({ content }) => content), - setPrompt: setSearch, - }) - - const handleSubmit = useCallback( - (message: string) => { - setSearch('') - submit(message) - }, - [submit] - ) - - const handleReset = useCallback(() => { - setSearch('') - reset() - }, [reset]) - - useEffect(() => { - if (search) handleSubmit(search) - }, []) - - // Detect an IME composition (so that we can ignore Enter keypress) - const [isImeComposing, setIsImeComposing] = useState(false) - - const formatAnswer = (answer: string) => { - try { - return format(answer, { - language: 'postgresql', - keywordCase: 'lower', - }) - } catch (error: any) { - return answer - } - } - - return ( -
    e.stopPropagation()}> -
    - {messages.map((message, i) => { - switch (message.role) { - case MessageRole.User: - return ( -
    -
    - -
    -
    - {message.content} -
    -
    - ) - case MessageRole.Assistant: - const unformattedAnswer = message.content - .replace(/```sql/g, '') - .replace(/```.*/gs, '') - .replace(/-- End of SQL query\.*/g, '') - .trim() - - const answer = - message.status === MessageStatus.Complete - ? formatAnswer(unformattedAnswer) - : unformattedAnswer - const cantHelp = - answer.replace(/^-- /, '') === "Sorry, I don't know how to help with that." - - return ( -
    -
    -
    - -
    - <> - {message.status === MessageStatus.Pending ? ( -
    - ) : cantHelp ? ( -
    - -

    - Sorry, I don't know how to help with that. -

    - -
    - ) : ( -
    -
    - - {answer} - - -
    - {message.status === MessageStatus.Complete && ( - - )} -
    - )} - -
    -
    - ) - } - })} - {messages.length === 0 && !hasError && ( -
    -
    -

    - Describe what you need and Supabase AI will try to generate the relevant SQL - statements -

    -

    - Here are some example prompts to try out: -

    -
    -
    - - {SAMPLE_QUERIES.map((sample) => ( - -
    - {SAMPLE_QUERIES.find( - (item) => item.category === sample.category - )?.queries.map((query) => ( - { - if (!search) { - handleSubmit(query) - } - }} - onKeyDown={(e) => { - switch (e.key) { - case 'Enter': - if (!search || isLoading || isResponding || isImeComposing) { - return - } - return handleSubmit(query) - default: - return - } - }} - key={query.replace(/\s+/g, '_')} - > -
    - -

    {query}

    -
    -
    - ))} -
    -
    - ))} -
    -
    -
    - )} - {hasError && ( -
    - -
    -

    - Sorry, looks like Supabase AI is having a hard time! -

    -

    - Please try again in a bit. -

    -
    - -
    - )} -
    -
    - -
    - {/* {messages.length > 0 && !hasError && } */} - {allowSendingSchemaMetadata && ( -
    - {messages.length === 0 ? ( -
    -
    -

    - Include table names, column names and their corresponding data types in - conversation -

    -

    - This will generate answers that are more relevant to your project during the - current conversation -

    -
    - setIncludeSchemaMetadata((prev) => !prev)} - /> -
    - ) : includeSchemaMetadata ? ( - - ) : ( - - )} -
    - )} - - {!isLoading && !isResponding ? ( -
    - Submit message -
    - -
    -
    - ) : null} - - } - onChange={(e) => { - if (!isLoading || !isResponding) { - setSearch(e.target.value) - } - }} - onCompositionStart={() => setIsImeComposing(true)} - onCompositionEnd={() => setIsImeComposing(false)} - onKeyDown={(e) => { - switch (e.key) { - case 'Enter': - if (!search || isLoading || isResponding || isImeComposing) { - return - } - return handleSubmit(search) - default: - return - } - }} - /> -
    -
    - ) -} - -export default GenerateSQL diff --git a/packages/ui-patterns/Cmdk/GenerateSQL/GenerateSQL.utils.ts b/packages/ui-patterns/Cmdk/GenerateSQL/GenerateSQL.utils.ts deleted file mode 100644 index dd4c4ad9635..00000000000 --- a/packages/ui-patterns/Cmdk/GenerateSQL/GenerateSQL.utils.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { stripIndent } from 'common-tags' - -export function getEdgeFunctionUrl() { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.replace(/\/$/, '') - - if (!supabaseUrl) return undefined - - // https://github.com/supabase/supabase-js/blob/10d3423506cbd56345f7f6ab2ec2093c8db629d4/src/SupabaseClient.ts#L96 - const isPlatform = supabaseUrl.match(/(supabase\.co)|(supabase\.in)/) - - if (isPlatform) { - const [schemeAndProjectId, domain, tld] = supabaseUrl.split('.') - return `${schemeAndProjectId}.functions.${domain}.${tld}` - } else { - return `${supabaseUrl}/functions/v1` - } -} - -export function promptDataReducer( - state: any[], - action: { - index?: number - answer?: string | undefined - status?: string - query?: string | undefined - type?: 'remove-last-item' | string - } -) { - // set a standard state to use later - let current = [...state] - - if (action.type) { - switch (action.type) { - case 'remove-last-item': - current.pop() - return [...current] - default: - break - } - } - - // check that an index is present - if (action.index === undefined) return [...state] - - if (!current[action.index]) { - current[action.index] = { query: '', answer: '', status: '' } - } - - current[action.index].answer = action.answer - - if (action.query) { - current[action.index].query = action.query - } - if (action.status) { - current[action.index].status = action.status - } - - return [...current] -} - -export const generatePrompt = (prompt: string, definitions?: any) => { - return stripIndent` -${ - definitions !== undefined - ? ` -Given the following Postgres SQL tables: -${definitions} -` - : '' -} - -Generate a Postgres SQL query based on the following natural language prompt. -- Only output valid SQL - all explanations must be SQL comments -- SQL comments should be short -- Your very last output should be "\`\`\`" -- For primary keys, always use "integer primary key generated always as identity" - -Natural language prompt: -${prompt} - -Postgres SQL query (markdown SQL only): -`.trim() -} - -/** - * Formats a string for use as a title. - * - * Removes punctuation and capitalizes each word - */ -export function formatTitle(value: string) { - let words = value.replace(/\.$/, '').replace(/['"]/g, '').split(' ') - words = words.map((word) => { - // Don't capitalize code - if (/[._\(\)]+/.test(word)) { - return word - } - return word.charAt(0).toUpperCase() + word.slice(1) - }) - return words.join(' ') -} diff --git a/packages/ui-patterns/Cmdk/GenerateSQL/SQLOutputActions.tsx b/packages/ui-patterns/Cmdk/GenerateSQL/SQLOutputActions.tsx deleted file mode 100644 index 3d363d22a52..00000000000 --- a/packages/ui-patterns/Cmdk/GenerateSQL/SQLOutputActions.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { useState, useEffect } from 'react' -import CopyToClipboard from 'react-copy-to-clipboard' -import { Button, IconCheck, IconClipboard, IconSave } from 'ui' -import { useCommandMenu } from '../CommandMenuContext' -import { stripIndent } from 'common-tags' -import { formatTitle } from './GenerateSQL.utils' -import { Message, MessageRole, MessageStatus, queryAi } from './../AiCommand' - -export interface SQLOutputActionsProps { - answer: string - messages: Message[] -} - -const SQLOutputActions = ({ answer, messages }: SQLOutputActionsProps) => { - const [showCopied, setShowCopied] = useState(false) - const [isSaving, setIsSaving] = useState(false) - const [isSaved, setIsSaved] = useState(false) - - const { project, saveGeneratedSQL } = useCommandMenu() - - const onSelectSaveSnippet = async () => { - setIsSaving(true) - - let suggestedTitle - try { - suggestedTitle = await queryAi( - [ - ...messages, - { - role: MessageRole.User, - content: stripIndent` - Generate a title for the above SQL snippet following all of these rules: - - The title is only for the last SQL snippet - - Focus on the main purposes of this snippet - - Use as few words as possible - - Title should be nouns, not verbs - - Do not include word articles (eg. a, the, for, of) - - Do not use words like "SQL" or "snippet" or "title" - - Do not output markdown, quotes, etc - - Do not be too verbose - `, - status: MessageStatus.Complete, - }, - ], - 10000 - ) - } catch (error) { - suggestedTitle = '' - } - - const formattedTitle = formatTitle(suggestedTitle) - await saveGeneratedSQL?.(answer, formattedTitle) - setIsSaved(true) - setIsSaving(false) - } - - useEffect(() => { - if (!showCopied) return - const timer = setTimeout(() => setShowCopied(false), 2000) - return () => clearTimeout(timer) - }, [showCopied]) - - useEffect(() => { - if (!isSaved) return - const timer = setTimeout(() => setIsSaved(false), 2000) - return () => clearTimeout(timer) - }, [isSaved]) - - return ( -
    - - - - {project?.ref !== undefined && saveGeneratedSQL !== undefined && ( - - )} -
    - ) -} - -export default SQLOutputActions diff --git a/packages/ui-patterns/Cmdk/GenerateSQL/index.ts b/packages/ui-patterns/Cmdk/GenerateSQL/index.ts deleted file mode 100644 index 13cc54742fc..00000000000 --- a/packages/ui-patterns/Cmdk/GenerateSQL/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import GenerateSQL from './GenerateSQL' - -export default GenerateSQL diff --git a/packages/ui-patterns/Cmdk/SearchButton.tsx b/packages/ui-patterns/Cmdk/SearchButton.tsx deleted file mode 100644 index aeb0c7153a8..00000000000 --- a/packages/ui-patterns/Cmdk/SearchButton.tsx +++ /dev/null @@ -1,29 +0,0 @@ -'use client' - -import { ButtonHTMLAttributes, DetailedHTMLProps, PropsWithChildren, useRef } from 'react' -import { useCommandMenu } from './CommandMenuContext' - -const SearchButton = ({ - children, - className, - ...props -}: PropsWithChildren< - DetailedHTMLProps, HTMLButtonElement> ->) => { - const searchButtonRef = useRef(null) - const { setIsOpen, site } = useCommandMenu() - - return ( - - ) -} - -export default SearchButton diff --git a/packages/ui-patterns/Cmdk/SearchOnlyItem.tsx b/packages/ui-patterns/Cmdk/SearchOnlyItem.tsx deleted file mode 100644 index dc39952a246..00000000000 --- a/packages/ui-patterns/Cmdk/SearchOnlyItem.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useRouter } from 'next/navigation' -import { CommandItem } from './Command.utils' -import { useCommandMenu } from './CommandMenuContext' - -interface SearchOnlyItemProps { - children: React.ReactNode - url: string -} - -export default function SearchOnlyItem({ children, url }: SearchOnlyItemProps) { - const { project, setIsOpen, setSearch } = useCommandMenu() - const router = useRouter() - - const baseURL = process.env.NEXT_PUBLIC_SITE_URL - function handleRouteChange(url: string) { - const localizedURL = project?.ref - ? `${baseURL}${url.replace('/_/', `/${project?.ref}/`)}` - : `${baseURL}${url}` - - router.push(localizedURL) - setIsOpen(false) - setSearch('') - } - return ( - handleRouteChange(url)}> - {children} - - ) -} diff --git a/packages/ui-patterns/Cmdk/SearchableStudioItems.tsx b/packages/ui-patterns/Cmdk/SearchableStudioItems.tsx deleted file mode 100644 index 9da3925c58e..00000000000 --- a/packages/ui-patterns/Cmdk/SearchableStudioItems.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { CommandGroup } from './Command.utils' -import SearchOnlyItem from './SearchOnlyItem' - -import sharedItems from './utils/shared-nav-items.json' - -export default function SearchableStudioItems() { - return ( - - {sharedItems.tools - .filter((item) => item.subItems) // filter items with subItems - .flatMap((item) => - item.subItems?.map((subItem) => ( - - {subItem.label} - - )) - )} - - ) -} diff --git a/packages/ui-patterns/Cmdk/ThemeOptions.tsx b/packages/ui-patterns/Cmdk/ThemeOptions.tsx deleted file mode 100644 index 94fa8175b99..00000000000 --- a/packages/ui-patterns/Cmdk/ThemeOptions.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { CommandGroup } from 'cmdk' -import { useCommandMenu } from './CommandMenuContext' -import ChildItem from './ChildItem' -import { useTheme } from 'next-themes' -import { Theme, themes } from 'ui/src/components/ThemeProvider/themes' - -const ThemeOptions = ({ isSubItem = false }) => { - const { setIsOpen } = useCommandMenu() - const { setTheme } = useTheme() - return ( - - {themes - .filter((x) => x.name === 'System' || x.name === 'Light' || x.name === 'Dark') - .map((theme: Theme) => ( - { - setTheme(theme.value) - setIsOpen(false) - }} - > - Change Theme to {theme.name === 'System' ? 'System Default' : theme.name} - - ))} - - ) -} - -export default ThemeOptions diff --git a/packages/ui-patterns/Cmdk/dashboard-nav-items.json b/packages/ui-patterns/Cmdk/dashboard-nav-items.json deleted file mode 100644 index c12948d2e88..00000000000 --- a/packages/ui-patterns/Cmdk/dashboard-nav-items.json +++ /dev/null @@ -1,96 +0,0 @@ -{ - "items": [ - { - "label": "Home", - "icon": "home", - "url": "http://www.example.com/home", - "sites": ["docs", "dashboard"], - "subItems": [ - { - "label": "About", - "icon": "book", - "url": "http://www.example.com/about", - "sites": ["docs", "dashboard"] - }, - { - "label": "Services", - "icon": "services", - "url": "http://www.example.com/services", - "sites": ["docs", "dashboard"] - }, - { - "label": "Contact", - "icon": "contact", - "url": "http://www.example.com/contact", - "sites": ["docs", "dashboard"] - } - ] - }, - { - "label": "Products", - "icon": "products", - "url": "http://www.example.com/products", - "sites": ["dashboard"], - "subItems": [ - { - "label": "Mobile", - "icon": "mobile", - "url": "http://www.example.com/products/mobile" - }, - { - "label": "In person", - "icon": "person", - "url": "http://www.example.com/products/in-person" - } - ] - } - ], - "docsTools": [ - { - "label": "Database", - "url": "https://supabase.com/docs/guides/database" - }, - { - "label": "Auth", - "url": "https://supabase.com/docs/guides/auth/overview" - }, - { - "label": "Edge Functions", - "url": "https://supabase.com/docs/guides/functions" - }, - { - "label": "Realtime", - "url": "https://supabase.com/docs/guides/realtime" - }, - { - "label": "Storage", - "url": "https://supabase.com/docs/guides/storage" - } - ], - "docsSupport": [ - { - "label": "Support", - "url": "https://supabase.com/support" - }, - { - "label": "System Status", - "url": "https://status.supabase.com/" - }, - { - "label": "Github Discussions", - "url": "https://github.com/supabase/supabase/discussions" - } - ], - "docsGeneral": [ - { - "icon": "arrowRight", - "label": "Go to the Dashboard", - "url": "https://app.supabase.io" - }, - { - "icon": "inbox", - "label": "Read the Changelog", - "url": "https://supabase.com/changelog" - } - ] -} diff --git a/packages/ui-patterns/Cmdk/index.tsx b/packages/ui-patterns/Cmdk/index.tsx deleted file mode 100644 index 38b0a2e41b0..00000000000 --- a/packages/ui-patterns/Cmdk/index.tsx +++ /dev/null @@ -1,13 +0,0 @@ -export { default as CommandMenuProvider } from './CommandMenuProvider' -export * from './CommandMenuProvider' -export * from './CommandMenuContext' - -export { default as CommandMenu } from './CommandMenu' - -export { default as AiCommand } from './AiCommand' - -export { default as DocsSearch } from './DocsSearch' - -export { default as SearchButton } from './SearchButton' - -export * from './Command.icons' diff --git a/packages/ui-patterns/Cmdk/utils/dashboard-nav-items.json b/packages/ui-patterns/Cmdk/utils/dashboard-nav-items.json deleted file mode 100644 index 86b94adb9fb..00000000000 --- a/packages/ui-patterns/Cmdk/utils/dashboard-nav-items.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "tools": { - "label": "Dashboard Tools", - "items": [ - { - "label": "dashboard - Database", - "url": "https://supabase.com/docs/guides/database", - "subItems": [ - { - "label": "Triggers", - "url": "http://www.example.com/triggers" - }, - { - "label": "Functions", - "icon": "functions", - "url": "http://www.example.com/functions" - } - ] - }, - { - "label": "dashboard - Auth", - "url": "https://supabase.com/docs/guides/auth/overview" - }, - { - "label": "dashboard - Edge Functions", - "url": "https://supabase.com/docs/guides/functions" - }, - { - "label": "dashboard - Realtime", - "url": "https://supabase.com/docs/guides/realtime" - }, - { - "label": "dashboard - Storage", - "url": "https://supabase.com/docs/guides/storage" - } - ] - }, - "settings": { - "label": "Dashboard Settings", - "items": [ - { - "label": "settings - Database general", - "url": "https://supabase.com/docs/guides/database" - }, - { - "label": "settings - Database settings", - "url": "https://supabase.com/docs/guides/auth/overview" - }, - { - "label": "settings - API settings", - "url": "https://supabase.com/docs/guides/functions" - }, - { - "label": "settings - Auth settings", - "url": "https://supabase.com/docs/guides/realtime" - }, - { - "label": "settings - Storage settings", - "url": "https://supabase.com/docs/guides/storage" - }, - { - "label": "settings - subscription settings", - "url": "https://supabase.com/docs/guides/storage" - }, - { - "label": "settings - usage settings", - "url": "https://supabase.com/docs/guides/storage" - }, - { - "label": "settings - invoices settings", - "url": "https://supabase.com/docs/guides/storage" - } - ] - } -} diff --git a/packages/ui-patterns/Cmdk/utils/docs-nav-items.json b/packages/ui-patterns/Cmdk/utils/docs-nav-items.json deleted file mode 100644 index c897ec63ea8..00000000000 --- a/packages/ui-patterns/Cmdk/utils/docs-nav-items.json +++ /dev/null @@ -1,96 +0,0 @@ -{ - "general": [ - { - "label": "Home", - "icon": "home", - "url": "http://www.example.com/home", - "sites": ["docs", "dashboard"], - "subItems": [ - { - "label": "About", - "icon": "book", - "url": "http://www.example.com/about", - "sites": ["docs", "dashboard"] - }, - { - "label": "Services", - "icon": "services", - "url": "http://www.example.com/services", - "sites": ["docs", "dashboard"] - }, - { - "label": "Contact", - "icon": "contact", - "url": "http://www.example.com/contact", - "sites": ["docs", "dashboard"] - } - ] - }, - { - "label": "Products", - "icon": "products", - "url": "http://www.example.com/products", - "sites": ["dashboard"], - "subItems": [ - { - "label": "Mobile", - "icon": "mobile", - "url": "http://www.example.com/products/mobile" - }, - { - "label": "In person", - "icon": "person", - "url": "http://www.example.com/products/in-person" - } - ] - } - ], - "docsTools": [ - { - "label": "Database", - "url": "https://supabase.com/docs/guides/database" - }, - { - "label": "Auth", - "url": "https://supabase.com/docs/guides/auth/overview" - }, - { - "label": "Edge Functions", - "url": "https://supabase.com/docs/guides/functions" - }, - { - "label": "Realtime", - "url": "https://supabase.com/docs/guides/realtime" - }, - { - "label": "Storage", - "url": "https://supabase.com/docs/guides/storage" - } - ], - "docsSupport": [ - { - "label": "Support", - "url": "https://supabase.com/support" - }, - { - "label": "System Status", - "url": "https://status.supabase.com/" - }, - { - "label": "Github Discussions", - "url": "https://github.com/supabase/supabase/discussions" - } - ], - "docsGeneral": [ - { - "icon": "arrowRight", - "label": "Go to the Dashboard", - "url": "https://app.supabase.io" - }, - { - "icon": "inbox", - "label": "Read the Changelog", - "url": "https://supabase.com/changelog" - } - ] -} diff --git a/packages/ui-patterns/Cmdk/utils/shared-nav-items.json b/packages/ui-patterns/Cmdk/utils/shared-nav-items.json deleted file mode 100644 index fa292da7753..00000000000 --- a/packages/ui-patterns/Cmdk/utils/shared-nav-items.json +++ /dev/null @@ -1,310 +0,0 @@ -{ - "tools": [ - { - "label": "Table Editor", - "url": "/project/_/editor" - }, - { - "label": "SQL Editor", - "url": "/project/_/sql" - }, - { - "label": "Database", - "url": "/project/_/database/tables", - "subItems": [ - { - "label": "Tables", - "url": "/project/_/database/tables" - }, - { - "label": "Triggers", - "url": "/project/_/database/triggers" - }, - { - "label": "Functions", - "url": "/project/_/database/functions" - }, - { - "label": "Extensions", - "url": "/project/_/database/extensions" - }, - { - "label": "Roles", - "url": "/project/_/database/roles" - }, - { - "label": "Publications", - "url": "/project/_/database/publications" - }, - { - "label": "Replication", - "url": "/project/_/database/publications" - }, - { - "label": "Webhooks", - "url": "/project/_/database/hooks" - }, - { - "label": "Backups", - "url": "/project/_/database/backups/scheduled" - }, - { - "label": "Wrappers", - "url": "/project/_/database/wrappers" - }, - { - "label": "Migrations", - "url": "/project/_/database/migrations" - } - ] - }, - { - "label": "Auth", - "url": "/project/_/auth/users", - "subItems": [ - { - "label": "Users", - "url": "/project/_/auth/users" - }, - { - "label": "Policies", - "url": "/project/_/auth/policies" - }, - { - "label": "Providers", - "url": "/project/_/auth/providers" - }, - { - "label": "Email Templates", - "url": "/project/_/auth/templates" - }, - { - "label": "URL Configuration", - "url": "/project/_/auth/url-configuration" - } - ] - }, - { - "label": "Storage", - "url": "/project/_/storage" - }, - { - "label": "Edge Functions", - "url": "/project/_/functions" - }, - { - "label": "Logs", - "url": "/project/_/logs/explorer", - "subItems": [ - { - "label": "Postgres logs", - "url": "/project/_/logs/postgres-logs" - }, - { - "label": "PostgREST logs", - "url": "/project/_/logs/postgrest-logs" - }, - { - "label": "Pooler logs", - "url": "/project/_/logs/pooler-logs" - }, - { - "label": "Auth logs", - "url": "/project/_/logs/auth-logs" - }, - { - "label": "Storage logs", - "url": "/project/_/logs/storage-logs" - }, - { - "label": "Realtime logs", - "url": "/project/_/logs/realtime-logs" - } - ] - }, - { - "label": "Reports", - "url": "/project/_/reports", - "subItems": [ - { - "label": "API reports", - "url": "/project/_/reports/api-overview" - }, - { - "label": "Storage reports", - "url": "/project/_/reports/storage" - }, - { - "label": "Database reports", - "url": "/project/_/reports/database" - }, - { - "label": "Query performance reports", - "url": "/project/_/reports/query-performance" - } - ] - }, - { - "label": "API Docs", - "url": "/project/_/api", - "subItems": [ - { - "label": "Auth docs", - "url": "/project/_/api?page=auth" - }, - { - "label": "User management docs", - "url": "/project/_/api?page=users" - }, - { - "label": "GraphQL", - "url": "/project/_/api/graphiql" - } - ] - }, - { - "label": "Project Settings", - "url": "/project/_/settings/general", - "subItems": [ - { - "label": "General settings", - "url": "/project/_/settings/general" - }, - { - "label": "Database settings", - "url": "/project/_/settings/database" - }, - { - "label": "API settings", - "url": "/project/_/settings/api" - }, - { - "label": "Auth settings", - "url": "/project/_/settings/auth" - }, - { - "label": "Storage settings", - "url": "/project/_/settings/storage" - }, - { - "label": "Log Drains", - "url": "/project/_/settings/log-drains" - }, - { - "label": "Custom Domains", - "url": "/project/_/settings/general#custom-domains" - }, - { - "label": "Restart project", - "url": "/project/_/settings/general#restart-project" - }, - { - "label": "Pause project", - "url": "/project/_/settings/general#pause-project" - }, - { - "label": "Transfer project", - "url": "/project/_/settings/general#transfer-project" - }, - { - "label": "Delete project", - "url": "/project/_/settings/general#delete-project" - }, - { - "label": "Database password", - "url": "/project/_/settings/database#database-password" - }, - { - "label": "Connection string", - "url": "/project/_/settings/database#connection-string" - }, - { - "label": "Connection pooling", - "url": "/project/_/settings/database#connection-pooling" - }, - { - "label": "SSL Configuration", - "url": "/project/_/settings/database#ssl-configuration" - }, - { - "label": "Network Restrictions", - "url": "/project/_/settings/database#network-restrictions" - }, - { - "label": "Banned IPs", - "url": "/project/_/settings/database#banned-ips" - } - ] - } - ], - "projectTools": [ - { - "label": "Your Projects", - "url": "/projects" - }, - { - "label": "Project API Keys", - "url": "/project/_/settings/api" - } - ], - "support": [ - { - "label": "Support", - "url": "https://supabase.com/support" - }, - { - "label": "System Status", - "url": "https://status.supabase.com/" - }, - { - "label": "Github Discussions", - "url": "https://github.com/supabase/supabase/discussions" - } - ], - "quickstarts": [ - { - "label": "React", - "url": "https://supabase.com/docs/guides/getting-started/quickstarts/reactjs" - }, - { - "label": "Next.js", - "url": "https://supabase.com/docs/guides/getting-started/quickstarts/nextjs" - }, - { - "label": "Nuxt.js", - "url": "https://supabase.com/docs/guides/getting-started/quickstarts/nuxtjs" - }, - { - "label": "Flutter", - "url": "https://supabase.com/docs/guides/getting-started/quickstarts/flutter" - }, - { - "label": "Kotlin", - "url": "https://supabase.com/docs/guides/getting-started/quickstarts/android" - }, - { - "label": "SvelteKit", - "url": "https://supabase.com/docs/guides/getting-started/quickstarts/sveltekit" - }, - { - "label": "SolidJS", - "url": "https://supabase.com/docs/guides/getting-started/quickstarts/solidjs" - }, - { - "label": "Vue", - "url": "https://supabase.com/docs/guides/getting-started/quickstarts/vue" - } - ], - "docsGeneral": [ - { - "icon": "arrowRight", - "label": "Go to the Dashboard", - "url": "https://app.supabase.io" - }, - { - "icon": "inbox", - "label": "Read the Changelog", - "url": "https://supabase.com/changelog" - } - ] -} diff --git a/packages/ui-patterns/CommandMenu/api/hooks/useCommandFilterState.ts b/packages/ui-patterns/CommandMenu/api/hooks/useCommandFilterState.ts new file mode 100644 index 00000000000..c939ee18979 --- /dev/null +++ b/packages/ui-patterns/CommandMenu/api/hooks/useCommandFilterState.ts @@ -0,0 +1,3 @@ +import { useCommandState } from 'cmdk' + +export const useCommandFilterState = useCommandState diff --git a/packages/ui-patterns/CommandMenu/index.tsx b/packages/ui-patterns/CommandMenu/index.tsx index 26e24efdee0..82a0d154f22 100644 --- a/packages/ui-patterns/CommandMenu/index.tsx +++ b/packages/ui-patterns/CommandMenu/index.tsx @@ -8,6 +8,7 @@ export { TextHighlighter, TextHighlighterBase } from './api/TextHighlighter' export * from './api/hooks/commandsHooks' export * from './api/hooks/pagesHooks' export * from './api/hooks/queryHooks' +export { useCommandFilterState } from './api/hooks/useCommandFilterState' export { useCrossCompatRouter } from './api/hooks/useCrossCompatRouter' export { useHistoryKeys } from './api/hooks/useHistoryKeys' export * from './api/hooks/viewHooks' diff --git a/packages/ui-patterns/CommandMenu/internal/CommandSection.tsx b/packages/ui-patterns/CommandMenu/internal/CommandSection.tsx index 79749b2f09c..ab52fac28b1 100644 --- a/packages/ui-patterns/CommandMenu/internal/CommandSection.tsx +++ b/packages/ui-patterns/CommandMenu/internal/CommandSection.tsx @@ -5,6 +5,7 @@ type ICommandSection = { name: string commands: Array forceMount?: boolean + meta?: any } const toSectionId = (str: string) => str.toLowerCase().replace(/\s+/g, '-') diff --git a/packages/ui-patterns/CommandMenu/internal/state/commandsState.ts b/packages/ui-patterns/CommandMenu/internal/state/commandsState.ts index ad91bfa268c..9c53e8afa89 100644 --- a/packages/ui-patterns/CommandMenu/internal/state/commandsState.ts +++ b/packages/ui-patterns/CommandMenu/internal/state/commandsState.ts @@ -11,6 +11,7 @@ type CommandOptions = { deps?: any[] enabled?: boolean forceMountSection?: boolean + sectionMeta?: any orderSection?: OrderSectionInstruction orderCommands?: OrderCommandsInstruction } @@ -32,6 +33,14 @@ const initCommandsState = () => { if (editIndex === -1) editIndex = state.commandSections.length state.commandSections[editIndex] ??= section$new(sectionName) + if (options?.sectionMeta) { + const oldMeta = state.commandSections[editIndex].meta + if (!!oldMeta && typeof oldMeta === 'object' && typeof options.sectionMeta === 'object') { + state.commandSections[editIndex].meta = { ...oldMeta, ...options.sectionMeta } + } else { + state.commandSections[editIndex].meta = options.sectionMeta + } + } if (options?.forceMountSection) state.commandSections[editIndex].forceMount = true diff --git a/packages/ui-patterns/CommandMenu/prepackaged/Changelog.tsx b/packages/ui-patterns/CommandMenu/prepackaged/Changelog.tsx index 5b8f56fe44b..2827eccd9f3 100644 --- a/packages/ui-patterns/CommandMenu/prepackaged/Changelog.tsx +++ b/packages/ui-patterns/CommandMenu/prepackaged/Changelog.tsx @@ -3,15 +3,19 @@ import { Inbox } from 'lucide-react' import { useRegisterCommands } from '..' import { BASE_PATH } from './shared/constants' -const useChangelogCommand = () => { - useRegisterCommands('Updates', [ - { - id: 'changelog', - name: 'Changelog', - route: BASE_PATH ? 'https://supabase.com/changelog' : '/changelog', - icon: () => , - }, - ]) +const useChangelogCommand = ({ enabled = true }: { enabled?: boolean } = {}) => { + useRegisterCommands( + 'Updates', + [ + { + id: 'changelog', + name: 'Changelog', + route: BASE_PATH ? 'https://supabase.com/changelog' : '/changelog', + icon: () => , + }, + ], + { enabled } + ) } export { useChangelogCommand } diff --git a/packages/ui-patterns/CommandMenu/prepackaged/DocsAi/DocsAiPage.tsx b/packages/ui-patterns/CommandMenu/prepackaged/DocsAi/DocsAiPage.tsx index e2936c20aa9..6f732b18c5e 100644 --- a/packages/ui-patterns/CommandMenu/prepackaged/DocsAi/DocsAiPage.tsx +++ b/packages/ui-patterns/CommandMenu/prepackaged/DocsAi/DocsAiPage.tsx @@ -55,11 +55,6 @@ const DocsAiPage = () => { setIsLoading, }) - useHistoryKeys({ - enable: !isResponding, - stack: messages.filter(({ role }) => role === MessageRole.User).map(({ content }) => content), - }) - const handleSubmit = useCallback( (message: string) => { setQuery('') @@ -73,12 +68,6 @@ const DocsAiPage = () => { reset() }, [reset]) - useEffect(() => { - if (query) { - handleSubmit(query) - } - }, []) - return ( { {isBelowSm && ( {
    {!isBelowSm && ( { function PromptInput({ submit, + reset, messages, isLoading, isResponding, className, }: { - submit: (query: string) => Promise + submit: (query: string) => void + reset: () => void messages: Array isLoading: boolean isResponding: boolean className?: string }) { const query = useQuery() - const setQuery = useSetQuery() useHistoryKeys({ enable: !isResponding, stack: messages.filter(({ role }) => role === MessageRole.User).map(({ content }) => content), }) - const handleSubmit = useCallback( - (message: string) => { - setQuery('') - submit(message) - }, - [submit] - ) - useEffect(() => { if (query) { - handleSubmit(query) + submit(query) } + return reset }, []) // Detect an IME composition (so that we can ignore Enter keypress) @@ -181,7 +166,7 @@ function PromptInput({ if (!query || isLoading || isResponding || isImeComposing) { return } - return handleSubmit(query) + return submit(query) default: return } diff --git a/packages/ui-patterns/CommandMenu/prepackaged/DocsAi/index.tsx b/packages/ui-patterns/CommandMenu/prepackaged/DocsAi/index.tsx index 469a9b4d62d..b7215873082 100644 --- a/packages/ui-patterns/CommandMenu/prepackaged/DocsAi/index.tsx +++ b/packages/ui-patterns/CommandMenu/prepackaged/DocsAi/index.tsx @@ -31,7 +31,6 @@ const useDocsAiCommands = ({ setCommandPage(DOCS_AI_COMMANDS.PAGE_NAME, true) }, icon: () => , - className: 'text-brand', }, ].map(modify), options diff --git a/packages/ui-patterns/CommandMenu/prepackaged/Support.tsx b/packages/ui-patterns/CommandMenu/prepackaged/Support.tsx index 119d7ac035c..b7d9bf3530c 100644 --- a/packages/ui-patterns/CommandMenu/prepackaged/Support.tsx +++ b/packages/ui-patterns/CommandMenu/prepackaged/Support.tsx @@ -4,7 +4,7 @@ import { useMemo } from 'react' import { useRegisterCommands, useSetCommandMenuOpen, type ICommand } from '..' import { BASE_PATH } from './shared/constants' -const useSupportCommands = () => { +const useSupportCommands = ({ enabled = true }: { enabled?: boolean } = {}) => { const setOpen = useSetCommandMenuOpen() const commands = useMemo( @@ -41,7 +41,7 @@ const useSupportCommands = () => { [setOpen] ) - useRegisterCommands('Support', commands) + useRegisterCommands('Support', commands, { enabled }) } export { useSupportCommands } diff --git a/packages/ui-patterns/CommandMenu/prepackaged/ai/useAiChat.ts b/packages/ui-patterns/CommandMenu/prepackaged/ai/useAiChat.ts index f2af9154855..87d9e92316b 100644 --- a/packages/ui-patterns/CommandMenu/prepackaged/ai/useAiChat.ts +++ b/packages/ui-patterns/CommandMenu/prepackaged/ai/useAiChat.ts @@ -172,6 +172,7 @@ const useAiChat = ({ messageTemplate = (message) => message, setIsLoading }: Use eventSourceRef.current = undefined setIsResponding(false) setHasError(false) + setCurrentMessageIndex(1) dispatchMessage({ type: 'reset', }) diff --git a/packages/ui-patterns/ConsentToast/index.tsx b/packages/ui-patterns/ConsentToast/index.tsx index 3a46bf60797..40eb313b48d 100644 --- a/packages/ui-patterns/ConsentToast/index.tsx +++ b/packages/ui-patterns/ConsentToast/index.tsx @@ -95,19 +95,21 @@ export const useConsent = () => { useEffect(() => { if (isClient && consentValue === null) { - consentToastId.current = toast( - handleConsent('true')} - onOptOut={() => handleConsent('false')} - />, - { - id: 'consent-toast', - position: 'bottom-right', - duration: Infinity, - className: - '!w-screen !-m-4 !border-t !rounded-none !max-w-none !bg-overlay !text sm:!m-0 sm:!rounded-lg sm:!w-auto sm:!max-w-[400px] sm:border', - } - ) + setTimeout(() => { + consentToastId.current = toast( + handleConsent('true')} + onOptOut={() => handleConsent('false')} + />, + { + id: 'consent-toast', + position: 'bottom-right', + duration: Infinity, + className: + '!w-screen !-m-4 !border-t !rounded-none !max-w-none !bg-overlay !text sm:!m-0 sm:!rounded-lg sm:!w-auto sm:!max-w-[400px] sm:border', + } + ) + }, 300) } }, [consentValue]) diff --git a/packages/ui-patterns/PromoToast/PromoToast.tsx b/packages/ui-patterns/PromoToast/PromoToast.tsx index 12bcb573d7c..32d9a58f054 100644 --- a/packages/ui-patterns/PromoToast/PromoToast.tsx +++ b/packages/ui-patterns/PromoToast/PromoToast.tsx @@ -6,9 +6,9 @@ import { useEffect, useState } from 'react' import { cn } from 'ui/src/lib/utils/cn' import { Button } from 'ui/src/components/Button/Button' import { LOCAL_STORAGE_KEYS } from 'common' -import CountdownComponent from 'ui/src/layout/banners/LW12CountdownBanner/Countdown' -import announcement from 'ui/src/layout/banners/data/Announcement.json' +// import CountdownComponent from 'ui/src/layout/banners/LW12CountdownBanner/Countdown' import { useTheme } from 'next-themes' +import announcement from 'ui/src/layout/banners/data/Announcement.json' const LW12BGDark = 'https://xguihxuzqibwxjnimxev.supabase.co/storage/v1/object/public/images/launch-week/lw12/assets/bg-dark.svg?t=2024-07-26T09%3A59%3A25.373Z' @@ -40,22 +40,22 @@ const PromoToast = () => { return (

    Launch Week 12

    -
    - AUGUST 12-16 / 7AM PT - +
    + Explore what we shipped + {/* */}
    )} {className ? ( -
    +
    {/* @ts-ignore */} ( aria-selected={active ? 'true' : 'false'} data-state={active ? 'active' : 'inactive'} className={cn( - 'inline-flex items-center justify-center whitespace-nowrap text-sm ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground text-foreground-lighter hover:text-foreground data-[state=active]:border-foreground border-b-2 border-transparent *:py-1.5', + 'inline-flex items-center justify-center whitespace-nowrap text-sm ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-foreground text-foreground-lighter hover:text-foreground data-[state=active]:border-foreground border-b-2 border-transparent *:py-1.5', className )} {...props} diff --git a/packages/ui/src/components/shadcn/ui/resizable.tsx b/packages/ui/src/components/shadcn/ui/resizable.tsx index 9bfbfaf1721..02ea512c18c 100644 --- a/packages/ui/src/components/shadcn/ui/resizable.tsx +++ b/packages/ui/src/components/shadcn/ui/resizable.tsx @@ -3,6 +3,7 @@ import { GripVertical } from 'lucide-react' import * as ResizablePrimitive from 'react-resizable-panels' +import { ImperativePanelHandle } from 'react-resizable-panels' import { cn } from '../../../lib/utils/cn' const ResizablePanelGroup = ({ @@ -51,4 +52,4 @@ const ResizableHandle = ({ ) -export { ResizablePanelGroup, ResizablePanel, ResizableHandle } +export { ResizableHandle, ResizablePanel, ResizablePanelGroup, type ImperativePanelHandle } diff --git a/packages/ui/src/components/shadcn/ui/text-area.tsx b/packages/ui/src/components/shadcn/ui/text-area.tsx index 3b2f26bb5f2..3f24b347fea 100644 --- a/packages/ui/src/components/shadcn/ui/text-area.tsx +++ b/packages/ui/src/components/shadcn/ui/text-area.tsx @@ -23,6 +23,6 @@ const TextArea = React.forwardRef( } ) -TextArea.displayName = 'Input' +TextArea.displayName = 'TextArea' export { TextArea } diff --git a/packages/ui/src/layout/banners/Announcement.tsx b/packages/ui/src/layout/banners/Announcement.tsx index c5718c408bb..24de1ee3f7b 100644 --- a/packages/ui/src/layout/banners/Announcement.tsx +++ b/packages/ui/src/layout/banners/Announcement.tsx @@ -64,7 +64,7 @@ const Announcement = ({
    {dismissable && !isLaunchWeekSection && (
    diff --git a/packages/ui/src/layout/banners/LW12CountdownBanner/LW12CountdownBanner.tsx b/packages/ui/src/layout/banners/LW12CountdownBanner/LW12CountdownBanner.tsx index ab64900de00..d383d60b439 100644 --- a/packages/ui/src/layout/banners/LW12CountdownBanner/LW12CountdownBanner.tsx +++ b/packages/ui/src/layout/banners/LW12CountdownBanner/LW12CountdownBanner.tsx @@ -5,7 +5,7 @@ import { usePathname } from 'next/navigation' import { Button } from '../../../components/Button/Button' import { cn } from '../../../lib/utils/cn' import announcement from '../data/Announcement.json' -import Countdown from './Countdown' +// import Countdown from './Countdown' export function LW12CountdownBanner() { const pathname = usePathname() @@ -27,11 +27,8 @@ export function LW12CountdownBanner() { )} >

    {announcement.text}

    -
    - -
    diff --git a/packages/ui/src/layout/banners/data/Announcement.json b/packages/ui/src/layout/banners/data/Announcement.json index ce6a1000b0b..27f1d5fd6d1 100644 --- a/packages/ui/src/layout/banners/data/Announcement.json +++ b/packages/ui/src/layout/banners/data/Announcement.json @@ -2,6 +2,6 @@ "show": true, "text": "Launch Week 12", "launchDate": "2024-08-12T07:00:00.000-08:00", - "link": "https://supabase.com/launch-week", - "cta": "Claim ticket" + "link": "/launch-week", + "cta": "Learn more" } diff --git a/turbo.json b/turbo.json index ad5c5da3ae3..89d5b715023 100644 --- a/turbo.json +++ b/turbo.json @@ -19,13 +19,7 @@ }, "docs#build": { "dependsOn": ["^build"], - "env": [ - "ANALYZE", - "NEXT_PUBLIC_*", - "NODE_ENV", - "OPENAI_API_KEY", - "SKIP_BUILD_STATIC_GENERATION" - ], + "env": ["ANALYZE", "NEXT_PUBLIC_*", "NODE_ENV", "OPENAI_API_KEY"], "outputs": [".next/**", "!.next/cache/**"] }, "studio#build": { diff --git a/vale/styles/config/vocabularies/Supabase/accept.txt b/vale/styles/config/vocabularies/Supabase/accept.txt index 0aab093205a..2d56cae94d8 100644 --- a/vale/styles/config/vocabularies/Supabase/accept.txt +++ b/vale/styles/config/vocabularies/Supabase/accept.txt @@ -18,6 +18,7 @@ Deno [Dd]otenv [Ee]nv [Ee]nums? +event_message Firestore Hashicorp [Gg]eospatial @@ -56,6 +57,7 @@ plpgsql Prisma [Rr]ealtime Refine's +regexp_contains [Rr]eindex(ed|es|ing)? repo RESTful @@ -68,12 +70,16 @@ Roboflow [Ss]ignouts? [Ss]ignups? SQLAlchemy +subquery +subqueries [Ss]upabase Snaplet Supavisor [Ss]upersets? Terraform [Tt]odos? +unnesting +unnested [Uu]npaus(e|es|ed|ing) [Uu]psert(s|ed|ing)? [Uu]pvot(es|ed|ing)