mirror of
https://github.com/supabase/supabase.git
synced 2026-10-18 07:39:07 +03:00
Merge branch 'vercel-marketplace' of github.com:supabase/supabase into vercel-marketplace
This commit is contained in:
commit
5f68b4485b
557 files changed
+24320
-10210
No files matched your search
+4
-2
@@ -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
|
||||
@@ -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
|
||||
@@ -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<string | undefined>
|
||||
$('h2').map(function () {
|
||||
headings.push($(this).attr('id'))
|
||||
})
|
||||
|
||||
expect(headings.includes('parameters')).toBe(true)
|
||||
expect(headings.includes('examples')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -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<AbbrevCommonClientLibSection & { url: URL }>
|
||||
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 (
|
||||
'<!doctype html><html>' +
|
||||
'<head>' +
|
||||
`<title>${title} | Supabase Docs</title>` +
|
||||
`<meta name="description" content="Supabase API reference for ${libraryName}${section.title ? ': ' + section.title : ''}">` +
|
||||
`<meta name="og:image" content="https://supabase.com/docs/img/supabase-og-image.png">` +
|
||||
`<meta name="twitter:image" content="https://supabase.com/docs/img/supabase-og-image.png">` +
|
||||
`<link rel="canonical" href="https://supabase.com/docs/reference/${lib}` +
|
||||
(version ? '/' + version : '') +
|
||||
(slug ? '/' + slug : '') +
|
||||
`">` +
|
||||
'</head>' +
|
||||
'<body>' +
|
||||
body +
|
||||
'</body></html>'
|
||||
)
|
||||
}
|
||||
|
||||
function libraryNav(sections: Array<AbbrevCommonClientLibSection & { url: URL }>) {
|
||||
return (
|
||||
'<nav><ul>' +
|
||||
sections
|
||||
.map((section) => `<li><a href="${section.url}">${section.title ?? ''}</a></li>`)
|
||||
.join('') +
|
||||
'</ul></nav>'
|
||||
)
|
||||
}
|
||||
|
||||
async function sectionDetails(lib: string, version: string, section: AbbrevCommonClientLibSection) {
|
||||
const libraryName = REFERENCES[lib].name
|
||||
let result = '<h1>' + (libraryName + ': ' + section.title ?? '') + '</h1>'
|
||||
|
||||
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(/(?<!\\)\{/g, '\\{').replace(/(?<!\\)\}/g, '\\}')
|
||||
|
||||
const mdast = fromMarkdown(markdown, {
|
||||
extensions: [mdxjs()],
|
||||
mdastExtensions: [mdxFromMarkdown()],
|
||||
})
|
||||
|
||||
visit(mdast, 'text', (node) => {
|
||||
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 = '<h2 id="parameters">Parameters</h2>'
|
||||
|
||||
if ('overwriteParams' in fn || 'params' in fn) {
|
||||
const params = fn.overwriteParams ?? fn.params
|
||||
if (params.length === 0) return ''
|
||||
|
||||
result +=
|
||||
'<ul>' +
|
||||
params
|
||||
.map(
|
||||
(param) =>
|
||||
'<li>' +
|
||||
`<h3>${param.name}</h3>` +
|
||||
`<span>${param.isOptional ? '(Optional)' : '(Required)'}</span>` +
|
||||
`<p>${param.description}</p>` +
|
||||
'</li>'
|
||||
)
|
||||
.join('') +
|
||||
'</ul>'
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
if (!types?.params || types.params.length === 0) return ''
|
||||
|
||||
result +=
|
||||
'<ul>' +
|
||||
types.params
|
||||
.map(
|
||||
(param) =>
|
||||
'<li>' +
|
||||
`<h3>${String(param.name)}</h3>` +
|
||||
`<span>${param.isOptional ? '(Optional)' : '(Required)'}</span>` +
|
||||
`<p>${param.comment?.shortText ?? ''}</p>` +
|
||||
'</li>'
|
||||
)
|
||||
.join('') +
|
||||
'</ul>'
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function examplesToHtml(fn: any) {
|
||||
if (!fn.examples || fn.examples.length === 0) return ''
|
||||
|
||||
let result = '<h2 id="examples">Examples</h2>'
|
||||
|
||||
result += fn.examples
|
||||
.map((example) => `<h3>${example.name ?? ''}</h3>` + mdxToHtml(example.code ?? ''))
|
||||
.join('')
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string> }
|
||||
}) {
|
||||
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 <ClientSdkReferencePage sdkId={sdkId} libVersion={version} />
|
||||
} else {
|
||||
// Unimplemented -- eventually API and CLI
|
||||
redirect(notFoundLink(slug.join('/')))
|
||||
}
|
||||
}
|
||||
|
||||
export const generateStaticParams = generateReferenceStaticParams
|
||||
export const generateMetadata = generateReferenceMetadata
|
||||
@@ -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 (
|
||||
<SidebarSkeleton>
|
||||
<LayoutMainContent>
|
||||
<article className="prose">
|
||||
<h1>API References</h1>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
Supabase also has a Management API to help with managing your Supabase Platform, and a
|
||||
CLI for local development and CI workflows.
|
||||
</p>
|
||||
<h2 className="mb-8">Client Libraries</h2>
|
||||
<div className="grid col-span-8 grid-cols-12 gap-6 not-prose">
|
||||
{clientSdkIds.map((sdkId) => {
|
||||
return (
|
||||
<Link
|
||||
key={REFERENCES[sdkId].name}
|
||||
href={`/reference/${REFERENCES[sdkId].libPath}`}
|
||||
passHref
|
||||
className="col-span-6 md:col-span-4"
|
||||
>
|
||||
<IconPanelWithIconPicker
|
||||
title={REFERENCES[sdkId].name}
|
||||
icon={REFERENCES[sdkId].icon}
|
||||
/>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<h2 className="mb-8">Management API and CLI</h2>
|
||||
<div className="grid col-span-8 grid-cols-12 gap-6 not-prose">
|
||||
<Link
|
||||
href={`/reference/api/introduction`}
|
||||
passHref
|
||||
className="col-span-6 md:col-span-4"
|
||||
>
|
||||
<IconPanelWithIconPicker title="Management API" icon={REFERENCES['api'].icon} />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/reference/cli/introduction`}
|
||||
passHref
|
||||
className="col-span-6 md:col-span-4"
|
||||
>
|
||||
<IconPanelWithIconPicker title="CLI" icon={REFERENCES['cli'].icon} />
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
</LayoutMainContent>
|
||||
</SidebarSkeleton>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
@@ -13,16 +13,6 @@ export interface NavMenuSection {
|
||||
items: Partial<NavMenuSection>[]
|
||||
}
|
||||
|
||||
export interface References {
|
||||
[key: string]: {
|
||||
name: string
|
||||
library?: string
|
||||
versions: string[]
|
||||
icon: string
|
||||
currentVersion?: string
|
||||
}
|
||||
}
|
||||
|
||||
type MenuItem = {
|
||||
label: string
|
||||
icon?: string
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 <NavigationMenuGuideList id={menu.id} />
|
||||
@@ -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)
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -185,7 +185,6 @@ const NavigationMenuRefListItems = ({
|
||||
<div className="flex items-center gap-3 my-3">
|
||||
<MenuIconPicker icon={menu.icon} width={21} height={21} />
|
||||
<HeaderLink title={menu.title} url={menu.url} id={id} />
|
||||
<RevVersionDropdown />
|
||||
</div>
|
||||
<ul className="function-link-list flex flex-col gap-2 pb-5">
|
||||
{filteredSections.map((section) => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
"
|
||||
>
|
||||
{/* <span>version</span> */}
|
||||
<span className="text-foreground text-sm group-hover:text-foreground transition">
|
||||
{currentVersion}.0
|
||||
</span>
|
||||
<IconChevronDown size={14} strokeWidth={2} />
|
||||
<ChevronDown size={14} strokeWidth={2} />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" side="bottom" className="w-48">
|
||||
|
||||
@@ -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
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="postgres-function"
|
||||
>
|
||||
<TabPanel id="postgres-function" label="Postgres Function">
|
||||
|
||||
### 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.
|
||||
</TabPanel>
|
||||
<TabPanel id="http" label="HTTP Endpoint">
|
||||
|
||||
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).
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
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.
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="sql"
|
||||
queryGroup="language"
|
||||
>
|
||||
<TabPanel id="sql" label="SQL">
|
||||
|
||||
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;
|
||||
```
|
||||
|
||||
<Admonition type="caution" label="Create postgres functions with security invoker">
|
||||
|
||||
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).
|
||||
|
||||
</Admonition>
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="http" label="HTTP">
|
||||
|
||||
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_<standard-base64-secret>`:
|
||||
|
||||
- `v1` denotes the version of the hook
|
||||
- `whsec_` signifies that the secret is symmetric
|
||||
- `<standard-base64-secret>` 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=<standard-base64-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
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
## 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.
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="sql"
|
||||
queryGroup="language"
|
||||
>
|
||||
<TabPanel id="sql" label="SQL">
|
||||
Modify the `auth.hook.<hook_name>` field and set `uri` to a value of `pg-functions://postgres/<schema>/<function_name>`
|
||||
|
||||
```
|
||||
[auth.hook.<hook_name>]
|
||||
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.
|
||||
|
||||
<Admonition type="caution">
|
||||
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.
|
||||
|
||||
</Admonition>
|
||||
|
||||
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;
|
||||
</TabPanel>
|
||||
<TabPanel id="http" label="HTTP">
|
||||
Modify the `auth.hook.<hook_name>` 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_<base64_standard_secret>'
|
||||
```
|
||||
|
||||
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).
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
### 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.
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="sql"
|
||||
queryGroup="language"
|
||||
>
|
||||
<TabPanel id="sql" label="SQL">
|
||||
|
||||
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
|
||||
</TabPanel>
|
||||
|
||||
Ensure that your hooks complete within **2 seconds** to avoid any errors.
|
||||
<TabPanel id="http" label="HTTP">
|
||||
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**
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="limit-failed-mfa-verification-attempts"
|
||||
queryGroup="language"
|
||||
>
|
||||
<TabPanel id="limit-failed-mfa-verification-attempts" label="Limit failed MFA verification 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 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.
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
## 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**
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="limit-failed-password-verification-attempts"
|
||||
queryGroup="language"
|
||||
>
|
||||
<TabPanel id="limit-failed-password-verification-attempts" label="Limit failed password verification attempts">
|
||||
|
||||
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;
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="send-email-on-failed-password-attempt" label="Send email notification on failed password attempts">
|
||||
|
||||
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": "<html><body><strong>Security Alert:</strong> We have detected repeated login attempts for your account. If this was not you, please secure your account.</body></html>",
|
||||
"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;
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
## 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**
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="add-admin-role"
|
||||
queryGroup="language"
|
||||
>
|
||||
<TabPanel id="add-admin-role" label="Add admin role">
|
||||
|
||||
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;
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="add-metadata-claim-plv8" label="Add claim via plv8">
|
||||
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;
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="restrict-access-to-sso-users" label="Restrict access to SSO users">
|
||||
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;
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
## 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.<hook_name>` field and set `uri` to a value of `pg-functions://postgres/<schema>/<function_name>`
|
||||
|
||||
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 <your_function_name>`. 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 <SharedData data="config">auth.hook_timeouts.postgres_hooks</SharedData> seconds to complete processing while HTTP Hooks should complete in <SharedData data="config">auth.hook_timeouts.http_hooks</SharedData> 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.
|
||||
|
||||
<div className="grid md:grid-cols-12 gap-4 not-prose">
|
||||
{[
|
||||
{
|
||||
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) => (
|
||||
<div className="col-span-4" key={x.href}>
|
||||
<Link href={x.href} passHref>
|
||||
<GlassPanel title={x.name}>{x.description}</GlassPanel>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -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`. |
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
>
|
||||
|
||||
<TabPanel id="custom-access-token-json" label="JSON">
|
||||
```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"
|
||||
}
|
||||
```
|
||||
</TabPanel>
|
||||
<TabPanel id="custom-access-token-json-schema" label="JSON Schema">
|
||||
|
||||
```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"]
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
**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. |
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="sql"
|
||||
queryGroup="language"
|
||||
>
|
||||
<TabPanel id="sql" label="SQL">
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="add-admin-role"
|
||||
>
|
||||
<TabPanel id="add-admin-role" label="Add admin role">
|
||||
|
||||
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;
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="add-metadata-claim-plv8" label="Add claim via plv8">
|
||||
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;
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="restrict-access-to-sso-users" label="Restrict access to SSO users">
|
||||
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;
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</TabPanel>
|
||||
<TabPanel id="http" label="HTTP">
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="http-add-metadata-claim"
|
||||
>
|
||||
<TabPanel id="http-add-metadata-claim" label="Add claim">
|
||||
|
||||
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',
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="http-restrict-access-to-sso-users" label="Restrict access to SSO users">
|
||||
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',
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
@@ -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). |
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="mfa-verification-attempt-json"
|
||||
>
|
||||
<TabPanel id="mfa-verification-attempt-json" label="JSON">
|
||||
|
||||
```json
|
||||
{
|
||||
"factor_id": "6eab6a69-7766-48bf-95d8-bd8f606894db",
|
||||
"user_id": "3919cb6e-4215-4478-a960-6d3454326cec",
|
||||
"valid": true
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="mfa-verification-attempt-json-schema" label="JSON Schema">
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_id": {
|
||||
"type": "string",
|
||||
"x-faker": "random.uuid"
|
||||
},
|
||||
"valid": {
|
||||
"type": "boolean",
|
||||
"x-faker": "random.boolean"
|
||||
}
|
||||
},
|
||||
"required": ["user_id", "valid"]
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
**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."
|
||||
}
|
||||
```
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="sql"
|
||||
queryGroup="language"
|
||||
>
|
||||
<TabPanel id="sql" label="SQL">
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="sql-limit-failed-mfa-verification-attempts"
|
||||
>
|
||||
<TabPanel id="sql-limit-failed-mfa-verification-attempts" label="Limit failed MFA verification 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;
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
@@ -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. |
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
>
|
||||
<TabPanel id="password-verification-attempt-json" label="JSON">
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": "3919cb6e-4215-4478-a960-6d3454326cec",
|
||||
"valid": true
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="password-verification-attempt-json-schema" label="JSON Schema">
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_id": {
|
||||
"type": "string",
|
||||
"x-faker": "random.uuid"
|
||||
},
|
||||
"valid": {
|
||||
"type": "boolean",
|
||||
"x-faker": "random.boolean"
|
||||
}
|
||||
},
|
||||
"required": ["user_id", "valid"]
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
**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"
|
||||
}
|
||||
```
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="sql"
|
||||
queryGroup="language"
|
||||
>
|
||||
<TabPanel id="sql" label="SQL">
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="sql-limit-failed-password-verification-attempts"
|
||||
>
|
||||
<TabPanel id="sql-limit-failed-password-verification-attempts" label="Limit failed password verification attempts">
|
||||
|
||||
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;
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="sql-send-email-on-failed-password-attempt" label="Send email notification on failed password attempts">
|
||||
|
||||
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": "<html><body><strong>Security Alert:</strong> We have detected repeated login attempts for your account. If this was not you, please secure your account.</body></html>",
|
||||
"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;
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
@@ -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`. |
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
>
|
||||
<TabPanel id="send-email-json" label="JSON">
|
||||
|
||||
```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": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="send-email-json-schema" label="JSON Schema">
|
||||
|
||||
```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"]
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
**Outputs**
|
||||
|
||||
- No outputs are required. An empty response with a status code of 200 is taken as a successful response.
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="http"
|
||||
queryGroup="language"
|
||||
>
|
||||
<TabPanel id="sql" label="SQL">
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="sql-queue-email-messages"
|
||||
>
|
||||
<TabPanel id="sql-queue-email-messages" label="Queue Email Messages">
|
||||
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();'
|
||||
);
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</TabPanel>
|
||||
<TabPanel id="http" label="HTTP">
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="http-send-email-with-resend"
|
||||
>
|
||||
<TabPanel id="http-send-email-with-resend" label="Use Resend as an email provider">
|
||||
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=<base64_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 <onboarding@example.com>",
|
||||
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
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="http-internationalization-for-emails" label="Add Internationalization for Email Templates">
|
||||
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: `<h2>Confirm your email</h2><p>Follow this link to confirm your email:</p><p><a href="{{confirmation_url}}">Confirm your email address</a></p><p>Alternatively, enter the code: {{token}}</p>`,
|
||||
recovery: `<h2>Reset password</h2><p>Follow this link to reset the password for your user:</p><p><a href="{{confirmation_url}}">Reset password</a></p><p>Alternatively, enter the code: {{token}}</p>`,
|
||||
invite: `<h2>You have been invited</h2><p>You have been invited to create a user on {{site_url}}. Follow this link to accept the invite:</p><p><a href="{{confirmation_url}}">Accept the invite</a></p><p>Alternatively, enter the code: {{token}}</p>`,
|
||||
magic_link: `<h2>Magic Link</h2><p>Follow this link to login:</p><p><a href="{{confirmation_url}}">Log In</a></p><p>Alternatively, enter the code: {{token}}</p>`,
|
||||
email_change: `<h2>Confirm email address change</h2><p>Follow this link to confirm the update of your email address from {{old_email}} to {{new_email}}:</p><p><a href="{{confirmation_url}}">Change email address</a></p><p>Alternatively, enter the codes: {{token}} and {{new_token}}</p>`,
|
||||
email_change_new: `<h2>Confirm New Email Address</h2><p>Follow this link to confirm your new email address:</p><p><a href="{{confirmation_url}}">Confirm new email address</a></p><p>Alternatively, enter the code: {{new_token}}</p>`,
|
||||
reauthentication: `<h2>Confirm reauthentication</h2><p>Enter the code: {{token}}</p>`,
|
||||
},
|
||||
es: {
|
||||
signup: `<h2>Confirma tu correo electrónico</h2><p>Sigue este enlace para confirmar tu correo electrónico:</p><p><a href="{{confirmation_url}}">Confirma tu correo electrónico</a></p><p>Alternativamente, ingresa el código: {{token}}</p>`,
|
||||
recovery: `<h2>Restablece tu contraseña</h2><p>Sigue este enlace para restablecer la contraseña de tu usuario:</p><p><a href="{{confirmation_url}}">Restablece tu contraseña</a></p><p>Alternativamente, ingresa el código: {{token}}</p>`,
|
||||
invite: `<h2>Has sido invitado</h2><p>Has sido invitado para crear un usuario en {{site_url}}. Sigue este enlace para aceptar la invitación:</p><p><a href="{{confirmation_url}}">Aceptar la invitación</a></p><p>Alternativamente, ingresa el código: {{token}}</p>`,
|
||||
magic_link: `<h2>Tu enlace mágico</h2><p>Sigue este enlace para iniciar sesión:</p><p><a href="{{confirmation_url}}">Iniciar sesión</a></p><p>Alternativamente, ingresa el código: {{token}}</p>`,
|
||||
email_change: `<h2>Confirma el cambio de correo electrónico</h2><p>Sigue este enlace para confirmar la actualización de tu correo electrónico de {{old_email}} a {{new_email}}:</p><p><a href="{{confirmation_url}}">Cambiar correo electrónico</a></p><p>Alternativamente, ingresa los códigos: {{token}} y {{new_token}}</p>`,
|
||||
email_change_new: `<h2>Confirma la Nueva Dirección de Correo</h2><p>Sigue este enlace para confirmar tu nueva dirección de correo electrónico:</p><p><a href="{{confirmation_url}}">Confirma la nueva dirección de correo</a></p><p>Alternativamente, ingresa el código: {{new_token}}</p>`,
|
||||
reauthentication: `<h2>Confirma la reautenticación</h2><p>Ingresa el código: {{token}}</p>`,
|
||||
},
|
||||
fr: {
|
||||
signup: `<h2>Confirmez votre adresse e-mail</h2><p>Suivez ce lien pour confirmer votre adresse e-mail :</p><p><a href="{{confirmation_url}}">Confirmez votre adresse e-mail</a></p><p>Vous pouvez aussi saisir le code : {{token}}</p>`,
|
||||
recovery: `<h2>Réinitialisez votre mot de passe</h2><p>Suivez ce lien pour réinitialiser votre mot de passe :</p><p><a href="{{confirmation_url}}">Réinitialisez votre mot de passe</a></p><p>Vous pouvez aussi saisir le code : {{token}}</p>`,
|
||||
invite: `<h2>Vous avez été invité</h2><p>Vous avez été invité à créer un utilisateur sur {{site_url}}. Suivez ce lien pour accepter l'invitation :</p><p><a href="{{confirmation_url}}">Acceptez l'invitation</a></p><p>Vous pouvez aussi saisir le code : {{token}}</p>`,
|
||||
magic_link: `<h2>Votre Lien Magique</h2><p>Suivez ce lien pour vous connecter :</p><p><a href="{{confirmation_url}}">Connectez-vous</a></p><p>Vous pouvez aussi saisir le code : {{token}}</p>`,
|
||||
email_change: `<h2>Confirmez le changement d’adresse e-mail</h2><p>Suivez ce lien pour confirmer la mise à jour de votre adresse e-mail de {{old_email}} à {{new_email}} :</p><p><a href="{{confirmation_url}}">Changez d’adresse e-mail</a></p><p>Vous pouvez aussi saisir les codes : {{token}} et {{new_token}}</p>`,
|
||||
email_change_new: `<h2>Confirmez la nouvelle adresse e-mail</h2><p>Suivez ce lien pour confirmer votre nouvelle adresse e-mail :</p><p><a href="{{confirmation_url}}">Confirmez la nouvelle adresse e-mail</a></p><p>Vous pouvez aussi saisir le code : {{new_token}}</p>`,
|
||||
reauthentication: `<h2>Confirmez la réauthentification</h2><p>Saisissez le code : {{token}}</p>`,
|
||||
},
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
{/* <TabPanel id="http-backup-email-provider" label="Add Backup Email Provider">
|
||||
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: `<h2>Confirm your email</h2><p>Follow this link to confirm your email:</p><p><a href="{{confirmation_url}}">Confirm your email address</a></p><p>Alternatively, enter the code: {{token}}</p>`,
|
||||
recovery: `<h2>Reset password</h2><p>Follow this link to reset the password for your user:</p><p><a href="{{confirmation_url}}">Reset password</a></p><p>Alternatively, enter the code: {{token}}</p>`,
|
||||
invite: `<h2>You have been invited</h2><p>You have been invited to create a user on {{site_url}}. Follow this link to accept the invite:</p><p><a href="{{confirmation_url}}">Accept the invite</a></p><p>Alternatively, enter the code: {{token}}</p>`,
|
||||
magic_link: `<h2>Magic Link</h2><p>Follow this link to login:</p><p><a href="{{confirmation_url}}">Log In</a></p><p>Alternatively, enter the code: {{token}}</p>`,
|
||||
email_change: `<h2>Confirm email address change</h2><p>Follow this link to confirm the update of your email address from {{old_email}} to {{new_email}}:</p><p><a href="{{confirmation_url}}">Change email address</a></p><p>Alternatively, enter the codes: {{token}} and {{new_token}}</p>`,
|
||||
email_change_new: `<h2>Confirm New Email Address</h2><p>Follow this link to confirm your new email address:</p><p><a href="{{confirmation_url}}">Confirm new email address</a></p><p>Alternatively, enter the code: {{new_token}}</p>`,
|
||||
reauthentication: `<h2>Confirm reauthentication</h2><p>Enter the code: {{token}}</p>`
|
||||
};
|
||||
|
||||
async function sendEmailWithPostmark(user: any, email_data: any, serverToken: string): Promise<Response> {
|
||||
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<Response> {
|
||||
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"
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
</TabPanel> */}
|
||||
</Tabs>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
@@ -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. |
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
>
|
||||
<TabPanel id="send-sms-json" label="JSON">
|
||||
|
||||
```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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="send-sms-json-schema" label="JSON Schema">
|
||||
|
||||
```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"]
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
**Outputs**
|
||||
|
||||
- No outputs are required. An empty response with a status code of 200 is taken as a successful response.
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="sql"
|
||||
queryGroup="language"
|
||||
>
|
||||
<TabPanel id="sql" label="SQL">
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="sql-send-message-via-job-queue"
|
||||
>
|
||||
<TabPanel id="sql-send-message-via-job-queue" label="Queue SMS Messages">
|
||||
|
||||
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();'
|
||||
);
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</TabPanel>
|
||||
<TabPanel id="http" label="HTTP">
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="http-alternate-message-provider"
|
||||
>
|
||||
<TabPanel id="http-alternate-message-provider" label="Alternate message provider">
|
||||
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<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: `+${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',
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="http-whatsapp-and-sms-messages" label="Use WhatsApp with SMS">
|
||||
|
||||
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",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
@@ -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.
|
||||
|
||||
<Image
|
||||
alt="Diagram showing the flow of Multi-Factor authentication"
|
||||
src={{
|
||||
light: '/docs/img/guides/auth-mfa/auth-mfa-flow.svg',
|
||||
dark: '/docs/img/guides/auth-mfa/auth-mfa-flow.svg',
|
||||
}}
|
||||
containerClassName="max-w-[700px]"
|
||||
/>
|
||||
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 `<img>` 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 <img> tag.
|
||||
setQR(data.totp.qr_code)
|
||||
})()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<img src={qr} />
|
||||
<input
|
||||
type="text"
|
||||
value={verifyCode}
|
||||
onChange={(e) => setVerifyCode(e.target.value.trim())}
|
||||
/>
|
||||
<input type="button" value="Enable" onClick={onEnableClicked} />
|
||||
<input type="button" value="Cancel" onClick={onCancelled} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
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() {
|
||||
<td>Factor ID</td>
|
||||
<td>Friendly Name</td>
|
||||
<td>Factor Status</td>
|
||||
<td>Phone Number</td>
|
||||
</tr>
|
||||
{factors.map((factor) => (
|
||||
<tr>
|
||||
@@ -267,6 +158,7 @@ export function UnenrollMFA() {
|
||||
<td>{factor.friendly_name}</td>
|
||||
<td>{factor.factor_type}</td>
|
||||
<td>{factor.status}</td>
|
||||
<td>{factor.phone}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -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 <AuthMFA />
|
||||
}
|
||||
|
||||
return <App />
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div>Please enter the code from your authenticator app.</div>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<input
|
||||
type="text"
|
||||
value={verifyCode}
|
||||
onChange={(e) => setVerifyCode(e.target.value.trim())}
|
||||
/>
|
||||
<input type="button" value="Submit" onClick={onSubmitClicked} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- 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"
|
||||
>
|
||||
|
||||
<AccordionItem
|
||||
header={<span className="text-foreground">Why is there a challenge and verify API when challenge does not do much?</span>}
|
||||
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.
|
||||
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
header={<span className="text-foreground">What's inside the QR code?</span>}
|
||||
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.
|
||||
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
header={<span className="text-foreground">How long is the TOTP code valid for?</span>}
|
||||
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.
|
||||
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
header={<span className="text-foreground">How do I check when a user went through MFA?</span>}
|
||||
id="how-do-i-check-when-a-user-went-through-mfa"
|
||||
|
||||
@@ -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).
|
||||
|
||||
<Image
|
||||
alt="Diagram showing the flow of Multi-Factor authentication"
|
||||
src={{
|
||||
light: '/docs/img/guides/auth-mfa/auth-mfa-phone-flow.svg',
|
||||
dark: '/docs/img/guides/auth-mfa/auth-mfa-phone-flow.svg',
|
||||
}}
|
||||
containerClassName="max-w-[700px]"
|
||||
/>
|
||||
|
||||
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 && <div className="error">{error}</div>}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Phone Number"
|
||||
value={phoneNumber}
|
||||
onChange={(e) => setPhoneNumber(e.target.value.trim())}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Verification Code"
|
||||
value={verifyCode}
|
||||
onChange={(e) => setVerifyCode(e.target.value.trim())}
|
||||
/>
|
||||
<input type="button" value="Enroll" onClick={onEnrollClicked} />
|
||||
<input type="button" value="Submit Code" onClick={onEnableClicked} />
|
||||
<input type="button" value="Send OTP Code" onClick={onSendOTPClicked} />
|
||||
<input type="button" value="Cancel" onClick={onCancelled} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 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 <AuthMFA />
|
||||
}
|
||||
|
||||
return <App />
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div>Please enter the code sent to your phone.</div>
|
||||
{phoneNumber && <div>Phone number: {phoneNumber}</div>}
|
||||
{error && <div className="error">{error}</div>}
|
||||
<input
|
||||
type="text"
|
||||
value={verifyCode}
|
||||
onChange={(e) => setVerifyCode(e.target.value.trim())}
|
||||
/>
|
||||
{!challengeId ? (
|
||||
<input type="button" value="Start Challenge" onClick={startChallenge} />
|
||||
) : (
|
||||
<input type="button" value="Verify Code" onClick={verifyCode} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- 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)
|
||||
@@ -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=<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).
|
||||
|
||||
<Image
|
||||
alt="Diagram showing the flow of Multi-Factor authentication"
|
||||
src={{
|
||||
light: '/docs/img/guides/auth-mfa/auth-mfa-flow.svg',
|
||||
dark: '/docs/img/guides/auth-mfa/auth-mfa-flow.svg',
|
||||
}}
|
||||
containerClassName="max-w-[700px]"
|
||||
/>
|
||||
|
||||
[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 `<img>` 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 <img> tag.
|
||||
setQR(data.totp.qr_code)
|
||||
})()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<img src={qr} />
|
||||
<input
|
||||
type="text"
|
||||
value={verifyCode}
|
||||
onChange={(e) => setVerifyCode(e.target.value.trim())}
|
||||
/>
|
||||
<input type="button" value="Enable" onClick={onEnableClicked} />
|
||||
<input type="button" value="Cancel" onClick={onCancelled} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 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 <AuthMFA />
|
||||
}
|
||||
|
||||
return <App />
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div>Please enter the code from your authenticator app.</div>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<input
|
||||
type="text"
|
||||
value={verifyCode}
|
||||
onChange={(e) => setVerifyCode(e.target.value.trim())}
|
||||
/>
|
||||
<input type="button" value="Submit" onClick={onSubmitClicked} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- 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
|
||||
|
||||
<Accordion
|
||||
type="default"
|
||||
openBehaviour="multiple"
|
||||
chevronAlign="right"
|
||||
justified
|
||||
size="medium"
|
||||
className="text-foreground-light mt-8 mb-6 [&>div]:space-y-4"
|
||||
>
|
||||
|
||||
<AccordionItem
|
||||
header={<span className="text-foreground">What's inside the QR code?</span>}
|
||||
id="what-is-inside-the-qr-code"
|
||||
>
|
||||
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
header={<span className="text-foreground">How long is the TOTP code valid for?</span>}
|
||||
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.
|
||||
|
||||
</AccordionItem>
|
||||
|
||||
</Accordion>
|
||||
@@ -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)
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
<StepHikeCompact.Code>
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
</TabPanel>
|
||||
@@ -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<string[]> => {
|
||||
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 (
|
||||
<>
|
||||
<Script src="https://accounts.google.com/gsi/client" />
|
||||
<div id="oneTap" className="fixed top-0 right-0 z-[100]" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default OneTapComponent
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="react-native" label="Expo React Native">
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
---
|
||||
id: 'auth-third-party-auth0'
|
||||
title: 'Auth0'
|
||||
subtitle: 'Use Auth0 with your Supabase project'
|
||||
---
|
||||
|
||||
Auth0 can be used as a third-party authentication provider alongside Supabase Auth, or standalone, with your Supabase project.
|
||||
|
||||
## Getting started
|
||||
|
||||
1. First you need to add an integration to connect your Supabase project with your Auth0 tenant. You will need your tenant ID (and in some cases region ID).
|
||||
2. Add a new Third-party Auth integration in your project's [Authentication settings](/dashboard/project/_/settings/auth).
|
||||
3. Assign the `role: 'authenticated'` custom claim to all JWTs by using an Auth0 Action.
|
||||
4. Finally setup the Supabase client in your application.
|
||||
|
||||
## Setup the Supabase client library
|
||||
|
||||
<Tabs type="underlined" queryGroup="auth0-create-client">
|
||||
|
||||
<TabPanel id="ts" label="TypeScript">
|
||||
|
||||
```typescript
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabase = createClient('https://<supabase-project>.supabase.co', 'SUPABASE_ANON_KEY', {
|
||||
accessToken: async () => {
|
||||
const accessToken = await auth0.getTokenSilently()
|
||||
|
||||
// Alternatively you can use (await auth0.getIdTokenClaims()).__raw to
|
||||
// use an ID token instead.
|
||||
|
||||
return accessToken
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="swift" label="Swift (iOS)">
|
||||
|
||||
```swift
|
||||
import Auth0
|
||||
import Supabase
|
||||
|
||||
extension CredentialsManager {
|
||||
static let shared = Auth0.CredentialsManager(authentication: Auth0.authentication())
|
||||
}
|
||||
|
||||
let supabase = SupabaseClient(
|
||||
supabaseURL: URL(string: "https://<supabase-project>.supabase.co")!,
|
||||
supabaseKey: "SUPABASE_ANON_KEY",
|
||||
options: SupabaseClientOptions(
|
||||
auth: SupabaseClientOptions.AuthOptions(
|
||||
accessToken: {
|
||||
try await CredentialsManager.shared.credentials().idToken
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="dart" label="Flutter">
|
||||
|
||||
```dart
|
||||
import 'package:auth0_flutter/auth0_flutter.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
final auth0 = Auth0('AUTH0_DOMAIN', 'AUTH0_CLIENT_ID');
|
||||
await Supabase.initialize(
|
||||
url: 'https://<supabase-project>.supabase.co',
|
||||
anonKey: 'SUPABASE_ANON_KEY',
|
||||
accessToken: () async {
|
||||
final credentials = await auth0.credentialsManager.credentials();
|
||||
return credentials.accessToken;
|
||||
},
|
||||
);
|
||||
runApp(const MyApp());
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="kotlin" label="Kotlin">
|
||||
|
||||
```kotlin
|
||||
import com.auth0.android.result.Credentials
|
||||
|
||||
val supabase = createSupabaseClient(
|
||||
"https://<supabase-project>.supabase.co",
|
||||
"SUPABASE_ANON_KEY"
|
||||
) {
|
||||
accessToken = {
|
||||
val credentials: Credentials = ...; // Get credentials from Auth0
|
||||
credentials.accessToken
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
|
||||
</Tabs>
|
||||
|
||||
## Add a new Third-Party Auth integration to your project
|
||||
|
||||
In the dashboard navigate to your project's [Authentication settings](/dashboard/project/_/settings/auth) and find the Third-Party Auth section to add a new integration.
|
||||
|
||||
In the CLI add the following config to your `supabase/config.toml` file:
|
||||
|
||||
```toml
|
||||
[auth.third_party.auth0]
|
||||
enabled = true
|
||||
tenant_id = "<id>"
|
||||
tenant_region = "<region>" # if your tenant has a region
|
||||
```
|
||||
|
||||
## Use an Auth0 Action to assign the authenticated role
|
||||
|
||||
Your Supabase project inspects the `role` claim present in all JWTs sent to it, to assign the correct Postgres role when using the Data API, Storage or Realtime authorization.
|
||||
|
||||
By default, Auth0 JWTs (both access token and ID token) do not contain a `role` claim in them. If you were to send such a JWT to your Supabase project, the `anon` role would be assigned when executing the Postgres query. Most of your app's logic will be accessible by the `authenticated` role.
|
||||
|
||||
A recommended approach to do this is to configure the [`onExecutePostLogin` Auth0 Action](https://auth0.com/docs/secure/tokens/json-web-tokens/create-custom-claims#create-custom-claims) which will add the custom claim:
|
||||
|
||||
```javascript
|
||||
exports.onExecutePostLogin = async (event, api) => {
|
||||
api.accessToken.setCustomClaim('role', 'authenticated')
|
||||
}
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
At this time, Auth0 tenants with the following [signing algorithms](https://auth0.com/docs/get-started/applications/signing-algorithms) are not supported:
|
||||
|
||||
- HS256 (HMAC with SHA-256) -- also known as symmetric JWTs
|
||||
- PS256 (RSA-PSS with SHA-256)
|
||||
@@ -0,0 +1,171 @@
|
||||
---
|
||||
id: 'auth-third-party-aws-cognito'
|
||||
title: 'Amazon Cognito (Amplify)'
|
||||
subtitle: 'Use Amazon Cognito via Amplify or standalone with your Supabase project'
|
||||
---
|
||||
|
||||
Amazon Cognito User Pools (via AWS Amplify or on its own) can be used as a third-party authentication provider alongside Supabase Auth, or standalone, with your Supabase project.
|
||||
|
||||
## Getting started
|
||||
|
||||
1. First you need to add an integration to connect your Supabase project with your Amazon Cognito User Pool. You will need the pool's ID and region.
|
||||
2. Add a new Third-party Auth integration in your project's [Authentication settings](/dashboard/project/_/settings/auth) or configure it in the CLI.
|
||||
3. Assign the `role: 'authenticated'` custom claim to all JWTs by using a Pre-Token Generation Trigger.
|
||||
4. Finally setup the Supabase client in your application.
|
||||
|
||||
## Setup the Supabase client library
|
||||
|
||||
<Tabs type="underlined" queryGroup="cognito-create-client">
|
||||
|
||||
<TabPanel id="ts" label="TypeScript (Amplify)">
|
||||
|
||||
```typescript
|
||||
import { fetchAuthSession, Hub } from 'aws-amplify/auth'
|
||||
|
||||
const supabase = createClient('https://<supabase-project>.supabase.co', 'SUPABASE_ANON_KEY', {
|
||||
accessToken: async () => {
|
||||
const tokens = await fetchAuthSession()
|
||||
|
||||
// Alternatively you can use tokens?.idToken instead.
|
||||
return tokens?.accessToken
|
||||
},
|
||||
})
|
||||
|
||||
// if you're using Realtime you also need to set up a listener for Cognito auth changes
|
||||
Hub.listen('auth', () => {
|
||||
fetchAuthSession().then((tokens) => supabase.realtime.setAuth(tokens?.accessToken))
|
||||
})
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="swift" label="Swift (iOS)">
|
||||
|
||||
```swift
|
||||
import Supabase
|
||||
import AWSPluginsCore
|
||||
|
||||
struct UnexpectedAuthSessionError: Error {}
|
||||
|
||||
let supabase = SupabaseClient(
|
||||
supabaseURL: URL(string: "https://<supabase-project>.supabase.co")!,
|
||||
supabaseKey: "SUPABASE_ANON_KEY",
|
||||
options: SupabaseClientOptions(
|
||||
auth: SupabaseClientOptions.AuthOptions(
|
||||
accessToken: {
|
||||
let session = try await Amplify.Auth.fetchAuthSession()
|
||||
|
||||
guard let cognitoTokenProvider = session as? AuthCognitoTokensProvider else {
|
||||
throw UnexpectedAuthSessionError()
|
||||
}
|
||||
|
||||
let tokens = try cognitoTokenProvider.getCognitoTokens().get()
|
||||
return tokens.idToken
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="dart" label="Flutter">
|
||||
|
||||
```dart
|
||||
import 'package:amplify_auth_cognito/amplify_auth_cognito.dart';
|
||||
import 'package:amplify_flutter/amplify_flutter.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
await Supabase.initialize(
|
||||
url: 'https://<supabase-project>.supabase.co',
|
||||
anonKey: 'SUPABASE_ANON_KEY',
|
||||
accessToken: () async {
|
||||
final session = await Amplify.Auth.fetchAuthSession();
|
||||
final cognitoSession = session as CognitoAuthSession;
|
||||
return cognitoSession.userPoolTokensResult.value.accessToken.raw;
|
||||
},
|
||||
);
|
||||
runApp(const MyApp());
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="kotlin" label="Kotlin">
|
||||
|
||||
```kotlin
|
||||
import com.amplifyframework.auth.AuthSession
|
||||
import com.amplifyframework.auth.cognito.AWSCognitoAuthSession
|
||||
import com.amplifyframework.core.Amplify
|
||||
|
||||
val supabase = createSupabaseClient(
|
||||
"https://<supabase-project>.supabase.co",
|
||||
"SUPABASE_ANON_KEY"
|
||||
) {
|
||||
accessToken = {
|
||||
getAccessToken()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getAccessToken(): String? {
|
||||
return suspendCoroutine {
|
||||
Amplify.Auth.fetchAuthSession(
|
||||
{ result: AuthSession ->
|
||||
val cognitoAuthSession = result as AWSCognitoAuthSession
|
||||
it.resume(cognitoAuthSession.userPoolTokensResult.value?.accessToken)
|
||||
},
|
||||
{ _ ->
|
||||
// Handle error
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
|
||||
</Tabs>
|
||||
|
||||
## Add a new Third-Party Auth integration to your project
|
||||
|
||||
In the dashboard navigate to your project's [Authentication settings](/dashboard/project/_/settings/auth) and find the Third-Party Auth section to add a new integration.
|
||||
|
||||
In the CLI add the following config to your `supabase/config.toml` file:
|
||||
|
||||
```toml
|
||||
[auth.third_party.aws_cognito]
|
||||
enabled = true
|
||||
user_pool_id = "<id>"
|
||||
user_pool_region = "<region>"
|
||||
```
|
||||
|
||||
## Use a Pre-Token Generation Trigger to assign the authenticated role
|
||||
|
||||
Your Supabase project inspects the `role` claim present in all JWTs sent to it, to assign the correct Postgres role when using the Data API, Storage or Realtime authorization.
|
||||
|
||||
By default, Amazon Cognito JWTs (both ID token and access tokens) do not contain a `role` claim in them. If you were to send such a JWT to your Supabase project, the `anon` role would be assigned when executing the Postgres query. Most of your app's logic will be accessible by the `authenticated` role.
|
||||
|
||||
A recommended approach to do this is to configure a [Pre-Token Generation Trigger](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-pre-token-generation.html) either `V1_0` (ID token only) or `V2_0` (both access and ID token). To do this you will need to create a new Lambda function (in any language and runtime) and assign it to the [Amazon Cognito User Pool's Lambda Triggers configuration](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-identity-pools-working-with-aws-lambda-triggers.html). For example, the Lambda function should look similar to this:
|
||||
|
||||
<Tabs type="underlined" queryGroup="cognito-lambda">
|
||||
|
||||
<TabPanel id="blocking-nodejs" label="Node.js">
|
||||
|
||||
```javascript
|
||||
export const handler = async (event) => {
|
||||
event.response = {
|
||||
claimsOverrideDetails: {
|
||||
claimsToAddOrOverride: {
|
||||
role: 'authenticated',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return event
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
|
||||
</Tabs>
|
||||
@@ -0,0 +1,332 @@
|
||||
---
|
||||
id: 'auth-third-party-firebase-auth'
|
||||
title: 'Firebase Auth'
|
||||
subtitle: 'Use Firebase Auth with your Supabase project'
|
||||
---
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
Creating a Firebase Auth third-party integration with your Supabase project is currently limited to a private alpha release. We are still trying to improve the developer experience around securing your project. [Register your interest in Third-Party Auth with Firebase](https://forms.supabase.com/third-party-auth-with-firebase) and the team will reach out to you.
|
||||
|
||||
</Admonition>
|
||||
|
||||
Firebase Auth can be used as a third-party authentication provider alongside Supabase Auth, or standalone, with your Supabase project.
|
||||
|
||||
## Getting started
|
||||
|
||||
1. First you need to add an integration to connect your Supabase project with your Firebase project. You will need to get the Project ID in the [Firebase Console](https://console.firebase.google.com/u/0/project/_/settings/general).
|
||||
2. Add a new Third-party Auth integration in your project's [Authentication settings](/dashboard/project/_/settings/auth).
|
||||
3. If you are using Third Party Auth in local development, create and attach restrictive RLS policies to all tables in your public schema, Storage and Realtime to prevent unauthorized access from unrelated Firebase projects.
|
||||
4. Assign the `role: 'authenticated'` [custom user claim](https://firebase.google.com/docs/auth/admin/custom-claims) to all your users.
|
||||
5. Finally set up the Supabase client in your application.
|
||||
|
||||
## Setup the Supabase client library
|
||||
|
||||
<Tabs type="underlined" queryGroup="firebase-create-client">
|
||||
|
||||
<TabPanel id="ts" label="TypeScript">
|
||||
|
||||
Creating a client for the Web is as easy as passing the `accessToken` async function. This function should [return the Firebase Auth JWT of the current user](https://firebase.google.com/docs/auth/admin/verify-id-tokens#web) (or null if no such user) is found.
|
||||
|
||||
```typescript
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabase = createClient('https://<supabase-project>.supabase.co', 'SUPABASE_ANON_KEY', {
|
||||
accessToken: async () => {
|
||||
;(await firebase.auth().currentUser?.getIdToken(/* forceRefresh */ false)) ?? null
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Make sure the all users in your application have the `role: 'authenticated'` [custom claim](https://firebase.google.com/docs/auth/admin/custom-claims) set. If you're using the `onCreate` Cloud Function to add this custom claim to newly signed up users, you will need to call `getIdToken(/* forceRefresh */ true)` immediately after sign up as the `onCreate` function does not run synchronously.
|
||||
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="swift" label="Swift (iOS)">
|
||||
|
||||
```swift
|
||||
import Supabase
|
||||
import FirebaseAuth
|
||||
|
||||
struct MissingFirebaseTokenError: Error {}
|
||||
|
||||
let supabase = SupabaseClient(
|
||||
supabaseURL: URL(string: "https://<supabase-project>.supabase.co")!,
|
||||
supabaseKey: "SUPABASE_ANON_KEY",
|
||||
options: SupabaseClientOptions(
|
||||
auth: SupabaseClientOptions.AuthOptions(
|
||||
accessToken: {
|
||||
guard let token = await Auth.auth().currentUser?.getIDToken() else {
|
||||
throw MissingFirebaseTokenError()
|
||||
}
|
||||
|
||||
return token
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="kotlin" label="Kotlin (Android)">
|
||||
|
||||
Create a Supabase client with the `accessToken` function that returns the Firebase Auth JWT of the current user. This code uses the [official Firebase SDK](https://firebase.google.com/docs/auth/android/start) for Android.
|
||||
|
||||
```kotlin
|
||||
import com.google.firebase.auth.ktx.auth
|
||||
import com.google.firebase.ktx.Firebase
|
||||
|
||||
val supabase = createSupabaseClient(
|
||||
"https://<supabase-project>.supabase.co",
|
||||
"SUPABASE_ANON_KEY"
|
||||
) {
|
||||
accessToken = {
|
||||
Firebase.auth.currentUser?.getIdToken(false)?.await()?.token
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="kotlinmp" label="Kotlin (Multiplatform)">
|
||||
|
||||
Create a Supabase client with the `accessToken` function that returns the Firebase Auth JWT of the current user. This code uses a [community Firebase SDK](https://github.com/GitLiveApp/firebase-kotlin-sdk) which supports Kotlin Multiplatform.
|
||||
|
||||
```kotlin
|
||||
import dev.gitlive.firebase.Firebase
|
||||
import dev.gitlive.firebase.auth.auth
|
||||
|
||||
val supabase = createSupabaseClient(
|
||||
"https://<supabase-project>.supabase.co",
|
||||
"SUPABASE_ANON_KEY"
|
||||
) {
|
||||
accessToken = {
|
||||
Firebase.auth.currentUser?.getIdToken(false)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
|
||||
</Tabs>
|
||||
|
||||
## Add a new Third-Party Auth integration to your project
|
||||
|
||||
In the dashboard navigate to your project's [Authentication settings](/dashboard/project/_/settings/auth) and find the Third-Party Auth section to add a new integration.
|
||||
|
||||
In the CLI add the following config to your `supabase/config.toml` file:
|
||||
|
||||
```toml
|
||||
[auth.third_party.firebase]
|
||||
enabled = true
|
||||
project_id = "<id>"
|
||||
```
|
||||
|
||||
## Adding an extra layer of security to your project's RLS policies
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
While Third-Party Auth with Firebase is in a private alpha, it is strongly recommended to follow this guide closely. Once it becomes generally available this method would not be necessary to secure your project against unauthorized access from other Firebase projects.
|
||||
|
||||
You should also follow this best practice if self-hosting.
|
||||
|
||||
</Admonition>
|
||||
|
||||
Firebase Auth uses a single set of JWT signing keys for all projects. This means that JWTs issued from an unrelated Firebase project to yours could access data in your Supabase project. To guard against this, creating and maintaining the following RLS policies for **all of your tables in the `public` schema** is very important. You should also attach this policy to [Storage](/docs/guides/storage/security/access-control) buckets or [Realtime](/docs/guides/realtime/authorization) channels.
|
||||
|
||||
To achieve this we recommend using a [restrictive Postgres Row-Level Security policy](https://www.postgresql.org/docs/current/sql-createpolicy.html).
|
||||
|
||||
Restrictive RLS policies differ from regular (or permissive) policies in that they use the `as restrictive` clause when being defined. They do not grant permissions, but rather restrict any existing or future permissions. They're great for cases like this where the technical limitations of Firebase Auth remain separate from your app's logic.
|
||||
|
||||
<Admonition type="danger">
|
||||
|
||||
Postgres has two types of policies: permissive and restrictive. This example uses restrictive policies so make sure you don't omit the `as restrictive` clause.
|
||||
|
||||
</Admonition>
|
||||
|
||||
This is an example of such an RLS policy that will restrict access to only your project's (denoted with `<firebase-project-id>`) users, and not any other Firebase project.
|
||||
|
||||
```sql
|
||||
create policy "Restrict access to Supabase Auth and Firebase Auth for project ID <firebase-project-id>"
|
||||
on table_name
|
||||
as restrictive
|
||||
to authenticated
|
||||
using (
|
||||
(auth.jwt()->>'iss' = 'https://<project-ref>.supabase.co/auth/v1')
|
||||
or
|
||||
(
|
||||
auth.jwt()->>'iss' = 'https://securetoken.google.com/<firebase-project-id>'
|
||||
and
|
||||
auth.jwt()->>'aud' = '<firebase-project-id>'
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
If you have a lot of tables in your app, or need to manage complex RLS policies for [Storage](/docs/guides/storage) or [Realtime](/docs/guides/realtime) it can be useful to define a [stable Postgres function](https://www.postgresql.org/docs/current/xfunc-volatility.html) that performs the check to cut down on duplicate code. For example:
|
||||
|
||||
```sql
|
||||
create function public.is_supabase_or_firebase_project_jwt()
|
||||
returns bool
|
||||
language sql
|
||||
stable
|
||||
returns null on null input
|
||||
return (
|
||||
(auth.jwt()->>'iss' = 'https://<project-ref>.supabase.co/auth/v1')
|
||||
or
|
||||
(
|
||||
auth.jwt()->>'iss' = concat('https://securetoken.google.com/<firebase-project-id>')
|
||||
and
|
||||
auth.jwt()->>'aud' = '<firebase-project-id>'
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
Make sure you substitute `<project-ref>` with your Supabase project's ID and the `<firebase-project-id>` to your Firebase Project ID. Then the restrictive policies on all your tables, buckets and channels can be simplified to be:
|
||||
|
||||
```sql
|
||||
create policy "Restrict access to correct Supabase and Firebase projects"
|
||||
on table_name
|
||||
as restrictive
|
||||
to authenticated
|
||||
using ((select public.is_supabase_or_firebase_project_jwt()) is true);
|
||||
```
|
||||
|
||||
## Assign the "role" custom claim
|
||||
|
||||
Your Supabase project inspects the `role` claim present in all JWTs sent to it, to assign the correct Postgres role when using the Data API, Storage or Realtime authorization.
|
||||
|
||||
By default, Firebase JWTs do not contain a `role` claim in them. If you were to send such a JWT to your Supabase project, the `anon` role would be assigned when executing the Postgres query. Most of your app's logic will be accessible by the `authenticated` role.
|
||||
|
||||
### Use Firebase Authentication functions to assign the authenticated role
|
||||
|
||||
You have two choices to set up a Firebase Authentication function depending on your Firebase project's configuration:
|
||||
|
||||
1. Easiest: Use a [blocking Firebase Authentication function](https://firebase.google.com/docs/auth/extend-with-blocking-functions) but this is only available if your project uses [Firebase Authentication with Identity Platform](https://cloud.google.com/security/products/identity-platform).
|
||||
2. Manually assign the custom claims to all users with the [admin SDK](https://firebase.google.com/docs/auth/admin/custom-claims#set_and_validate_custom_user_claims_via_the_admin_sdk) and define an [`onCreate` Firebase Authentication Cloud Function](https://firebase.google.com/docs/auth/extend-with-functions) to persist the role to all newly created users.
|
||||
|
||||
<Tabs type="underlined" queryGroup="firebase-functions">
|
||||
|
||||
<TabPanel id="blocking-nodejs" label="Node.js (Blocking Functions Gen 2)">
|
||||
|
||||
```typescript
|
||||
import { beforeUserCreated, beforeUserSignedIn } from 'firebase-functions/v2/identity'
|
||||
|
||||
export const beforecreated = beforeUserCreated((event) => {
|
||||
return {
|
||||
customClaims: {
|
||||
// The Supabase project will use this role to assign the `authenticated`
|
||||
// Postgres role.
|
||||
role: 'authenticated',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
export const beforesignedin = beforeUserSignedIn((event) => {
|
||||
return {
|
||||
customClaims: {
|
||||
// The Supabase project will use this role to assign the `authenticated`
|
||||
// Postgres role.
|
||||
role: 'authenticated',
|
||||
},
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Note that instead of using `customClaims` you can instead use `sessionClaims`. The difference is that `session_claims` are not saved in the Firebase user profile, but remain valid for as long as the user is signed in.
|
||||
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="blocking-python" label="Python (Blocking Functions Gen 2)">
|
||||
|
||||
```python
|
||||
@identity_fn.before_user_created()
|
||||
def set_supabase_role_sign_up(event: identity_fn.AuthBlockingEvent) -> identity_fn.BeforeCreateResponse | None:
|
||||
return identity_fn.BeforeCreateResponse(
|
||||
# The Supabase project will use this role to assign the `authenticated`
|
||||
# Postgres role.
|
||||
custom_claims={'role': 'authenticated'})
|
||||
|
||||
@identity_fn.before_user_signed_in()
|
||||
def set_supabase_role_sign_in(event: identity_fn.AuthBlockingEvent) -> identity_fn.BeforeSignInResponse | None:
|
||||
return identity_fn.BeforeSignInResponse(
|
||||
# The Supabase project will use this role to assign the `authenticated`
|
||||
# Postgres role.
|
||||
custom_claims={'role': 'authenticated'})
|
||||
```
|
||||
|
||||
Note that instead of using `custom_claims` you can instead use `session_claims`. The difference is that `session_claims` are not saved in the Firebase user profile, but remain valid for as long as the user is signed in.
|
||||
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="oncreate-nodejs" label="onCreate Cloud Function in Node.js">
|
||||
|
||||
```javascript
|
||||
const functions = require('firebase-functions')
|
||||
const { initializeApp } = require('firebase-admin/app')
|
||||
const { getAuth } = require('firebase-admin/auth')
|
||||
const { getDatabase } = require('firebase-admin/database')
|
||||
|
||||
initializeApp()
|
||||
|
||||
// On sign up.
|
||||
exports.processSignUp = functions.auth.user().onCreate(async (user) => {
|
||||
try {
|
||||
// Set custom user claims on this newly created user.
|
||||
await getAuth().setCustomUserClaims(user.uid, {
|
||||
role: 'authenticated',
|
||||
})
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Note that the `onCreate` Firebase Cloud Function is not _synchronous_ (unlike the Blocking Functions), so the very first ID token received by the Firebase client library in your app _will not contain_ the `role: 'authenticated'` claim. Force-refresh the ID token immediately after sign-up to fetch an ID token with the applied role.
|
||||
|
||||
</TabPanel>
|
||||
|
||||
</Tabs>
|
||||
|
||||
Finally deploy your functions for the changes to take effect:
|
||||
|
||||
```
|
||||
firebase deploy --only functions
|
||||
```
|
||||
|
||||
Note that these functions are only called on new sign-ups and sign-ins. Existing users will not have these claims in their ID tokens. You will need to use the admin SDK to assign the role custom claim to all users. Make sure you do this after the blocking Firebase Authentication functions as described above are deployed.
|
||||
|
||||
### Use the admin SDK to assign the role custom claim to all users
|
||||
|
||||
You need to run a script that will assign the `role: 'authenticated'` custom claim to all of your existing Firebase Authentication users. You can do this by combining the [list users](https://firebase.google.com/docs/auth/admin/manage-users#list_all_users) and [set custom user claims](https://firebase.google.com/docs/auth/admin/create-custom-tokens) admin APIs. An example script is provided below:
|
||||
|
||||
```javascript
|
||||
'use strict';
|
||||
const { initializeApp } = require('firebase-admin/app');
|
||||
const { getAuth } = require('firebase-admin/auth');
|
||||
initializeApp();
|
||||
|
||||
async function setRoleCustomClaim() => {
|
||||
let nextPageToken = undefined
|
||||
|
||||
do {
|
||||
const listUsersResult = await getAuth().listUsers(1000, nextPageToken)
|
||||
|
||||
nextPageToken = listUsersResult.pageToken
|
||||
|
||||
await Promise.all(listUsersResult.users.map(async (userRecord) => {
|
||||
try {
|
||||
await getAuth().setCustomUserClaims(userRecord.id, {
|
||||
role: 'authenticated'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to set custom role for user', userRecord.id)
|
||||
}
|
||||
})
|
||||
} while (nextPageToken);
|
||||
};
|
||||
|
||||
setRoleCustomClaim().then(() => process.exit(0))
|
||||
```
|
||||
|
||||
After all users have received the `role: 'authenticated'` claim, it will appear in all newly issued ID tokens for the user.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
id: 'auth-third-party-overview'
|
||||
title: 'Third-party auth'
|
||||
subtitle: 'First-class support for authentication providers'
|
||||
---
|
||||
|
||||
Supabase has first-class support for these third-party authentication providers:
|
||||
|
||||
- [Firebase Auth](/docs/guides/auth/third-party/firebase-auth)
|
||||
- [Auth0](/docs/guides/auth/third-party/auth0)
|
||||
- [AWS Cognito (with or without AWS Amplify)](/docs/guides/auth/third-party/aws-cognito)
|
||||
|
||||
You can use these providers alongside Supabase Auth, or on their own, to access the [Data API (REST and GraphQL)](/docs/guides/database), [Storage](/docs/guides/storage), [Realtime](/docs/guides/storage) and [Functions](/docs/guides/functions) from your existing apps.
|
||||
|
||||
If you already have production apps using one of these authentication providers, and would like to use a Supabase feature, you no longer need to migrate your users to Supabase Auth or use work-arounds like translating JWTs into the Supabase Auth format and using your project's signing secret.
|
||||
|
||||
## How does it work?
|
||||
|
||||
To use Supabase products like Data APIs for your Postgres database, Storage or Realtime, you often need to send access tokens or JWTs via the Supabase client libraries or via the REST API. Third-party auth support means that when you add a new integration with one of these providers, the API will trust JWTs issued by the provider similar to how it trusts JWTs issued by Supabase Auth.
|
||||
|
||||
This is made possible if the providers are using JWTs signed with asymmetric keys, which means that the Supabase APIs will be able to only verify but not create JWTs.
|
||||
|
||||
## Limitations
|
||||
|
||||
There are some limitations you should be aware of when using third-party authentication providers with Supabase.
|
||||
|
||||
1. The third-party provider must use asymmetrically signed JWTs (exposed as an OIDC Issuer Discovery URL by the third-party authentication provider). Using symmetrically signed JWTs is not possible at this time.
|
||||
2. The JWT signing keys from the third-party provider are stored in the configuration of your project, and are checked for changes periodically. If you are rotating your keys (when supported) allow up to 30 minutes for the change to be picked up.
|
||||
3. It is not possible to disable Supabase Auth at this time.
|
||||
@@ -167,4 +167,4 @@ npx @snaplet/seed sync
|
||||
npx tsx seed.ts > supabase/seed.sql
|
||||
```
|
||||
|
||||
For more information, check out Snaplet's [seed documentation](https://docs.snaplet.dev/seed/integrations/supabase)
|
||||
For more information, check out Snaplet's [seed documentation](https://snaplet-seed.netlify.app/seed/integrations/supabase)
|
||||
@@ -10,7 +10,7 @@ Postgres provides a set of sensible defaults for you database size. In some case
|
||||
|
||||
## Timeouts
|
||||
|
||||
See the [Timeouts](/docs/guides/database/timeouts) section.
|
||||
See the [Timeouts](/docs/guides/database/postgres/timeouts) section.
|
||||
|
||||
## Statement optimization
|
||||
|
||||
|
||||
@@ -44,7 +44,9 @@ If you use special symbols in your postgres password, you must remember to [perc
|
||||
|
||||
When you created your project you were also asked to enter a password. This is actually the password for the `postgres` role in your database. You can update this from the Dashboard under the [database settings](https://supabase.com/dashboard/project/_/settings/database) page. You should _never_ give this to third-party service unless you absolutely trust them. Instead, we recommend that you create a new user for every service that you want to give access too. This will also help you with debugging - you can see every query that each role is executing in your database within `pg_stat_statements`.
|
||||
|
||||
## Granting permissions
|
||||
Changing the password does not result in any downtime. All connected services, such as postgrest, pgbouncer, and other Supabase managed services, are automatically updated to use the latest password to ensure availability. However, if you have any external services connecting to the Supabase database using hardcoded username/password credentials, a manual update will be required.
|
||||
|
||||
## Granting Permissions
|
||||
|
||||
Roles can be granted various permissions on database objects using the `GRANT` command. Permissions include `SELECT`, `INSERT`, `UPDATE`, and `DELETE`. You can configure access to almost any object inside your database - including tables, views, functions, and triggers.
|
||||
|
||||
|
||||
@@ -220,6 +220,12 @@ Returns the ID of the user making the request.
|
||||
|
||||
### `auth.jwt()`
|
||||
|
||||
<Admonition type="caution">
|
||||
|
||||
Not all information present in the JWT should be used in RLS policies. For instance, creating an RLS policy that relies on the `user_metadata` claim can create security issues in your application as this information can be modified by authenticated end users.
|
||||
|
||||
</Admonition>
|
||||
|
||||
Returns the JWT of the user making the request. Anything that you store in the user's `raw_app_meta_data` column or the `raw_user_meta_data` column will be accessible using this function. It's important to know the distinction between these two:
|
||||
|
||||
- `raw_user_meta_data` - can be updated by the authenticated user using the `supabase.auth.update()` function. It is not a good place to store authorization data.
|
||||
@@ -484,7 +490,9 @@ This prevents the policy `( (select auth.uid()) = user_id )` from running for an
|
||||
| --------------------------------------------------------------------------------------------- | ----------- | ---------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [test6-To-role](https://github.com/GaryAustin1/RLS-Performance/tree/main/tests/test6-To-role) | 170 | < 0.1 | 99.78% | <details className="cursor-pointer">Before:<br/>No `TO` policy<br/><br/>After:<br/>`TO authenticated` (anon accessing)</details> |
|
||||
|
||||
## Resources
|
||||
## More resources
|
||||
|
||||
- [Testing your database](/docs/guides/database/testing)
|
||||
- [Row Level Security and Supabase Auth](/docs/guides/database/postgres/row-level-security)
|
||||
- [RLS Guide and Best Practices](https://github.com/orgs/supabase/discussions/14576)
|
||||
- Community repo on testing RLS using [pgTAP and dbdev](https://github.com/usebasejump/supabase-test-helpers/tree/main)
|
||||
@@ -18,7 +18,7 @@ You can change the Postgres timeout at the:
|
||||
|
||||
### Session level
|
||||
|
||||
Session level settings persist for the duration of the connection. You must be connecting to the database in session mode for this to take effect.
|
||||
Session level settings persist only for the duration of the connection.
|
||||
|
||||
Set the session timeout by running:
|
||||
|
||||
@@ -26,7 +26,7 @@ Set the session timeout by running:
|
||||
set statement_timeout = '10min';
|
||||
```
|
||||
|
||||
Because this applies to sessions only, it can only be used with connections through Supavisor in session mode (port 5432) or a direct connection. It cannot be used in the Dashboard, with the Supabase Client API, nor with Supavisor in Transaction mode (port 6543).
|
||||
Because it applies to sessions only, it can only be used with connections through Supavisor in session mode (port 5432) or a direct connection. It cannot be used in the Dashboard, with the Supabase Client API, nor with Supavisor in Transaction mode (port 6543).
|
||||
|
||||
This is most often used for single, long running, administrative tasks, such as creating an HSNW index. Once the setting is implemented, you can view it by executing:
|
||||
|
||||
@@ -51,25 +51,9 @@ set statement_timeout TO '4s'; -- set custom timeout
|
||||
|
||||
This is mostly for recurring functions that need a special exemption for runtimes.
|
||||
|
||||
### Global level
|
||||
|
||||
This changes the statement timeout for all queries.
|
||||
|
||||
It is only available on Supabase Postgres version 15.1.1.78 and higher. If you are unsure what version you have, check your [Infrastructure Settings](/dashboard/project/_/settings/infrastructure)
|
||||
|
||||
```sql
|
||||
alter database postgres set statement_timeout TO '4s';
|
||||
```
|
||||
|
||||
Check if your changes took effect:
|
||||
|
||||
```sql
|
||||
show statement_timeout;
|
||||
```
|
||||
|
||||
### Role level
|
||||
|
||||
This sets the timeout for a specific role. It has a higher precedence than the global timeout setting.
|
||||
This sets the timeout for a specific role.
|
||||
|
||||
The default role timeouts are:
|
||||
|
||||
@@ -101,6 +85,20 @@ where
|
||||
);
|
||||
```
|
||||
|
||||
### Global level
|
||||
|
||||
This changes the statement timeout for all roles and sessions without an explicit timeout already set.
|
||||
|
||||
```sql
|
||||
alter database postgres set statement_timeout TO '4s';
|
||||
```
|
||||
|
||||
Check if your changes took effect:
|
||||
|
||||
```sql
|
||||
show statement_timeout;
|
||||
```
|
||||
|
||||
Although not necessary, if you are uncertain if a timeout has been applied, you can run a quick test:
|
||||
|
||||
```sql
|
||||
@@ -117,7 +115,7 @@ The Supabase Dashboard contains tools to help you identify timed-out and long-ru
|
||||
|
||||
### Using the Log Explorer
|
||||
|
||||
Go to the [Log Explorer](/dashboard/project/_/logs/explorer), and run the following query to identify timed-out events (`statement timeout`) and queries that successfully run in greater than 10 seconds (`duration`).
|
||||
Go to the [Log Explorer](/dashboard/project/_/logs/explorer), and run the following query to identify timed-out events (`statement timeout`) and queries that successfully run for longer than 10 seconds (`duration`).
|
||||
|
||||
```sql
|
||||
select
|
||||
@@ -170,9 +168,3 @@ where
|
||||
-- find events from the relevant role
|
||||
parsed.user_name = '<ROLE>'
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [How to interpret and explore the Postgres logs](https://github.com/orgs/supabase/discussions/26224)
|
||||
- [How to interpret and explore the Database API logs](https://github.com/orgs/supabase/discussions/22849)
|
||||
- [How to filter the Log Explorer](https://github.com/orgs/supabase/discussions/22640)
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
id: 'functions-compression'
|
||||
title: 'Handling Compressed Requests'
|
||||
description: 'Handling Gzip compressed requests.'
|
||||
subtitle: 'Handling Gzip compressed requests.'
|
||||
---
|
||||
|
||||
To decompress Gzip bodies, you can use `gunzipSync` from the `node:zlib` API to decompress and then read the body.
|
||||
|
||||
```ts
|
||||
import { gunzipSync } from 'node:zlib'
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
try {
|
||||
// Check if the request body is gzip compressed
|
||||
const contentEncoding = req.headers.get('content-encoding')
|
||||
if (contentEncoding !== 'gzip') {
|
||||
return new Response('Request body is not gzip compressed', {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
// Read the compressed body
|
||||
const compressedBody = await req.arrayBuffer()
|
||||
|
||||
// Decompress the body
|
||||
const decompressedBody = gunzipSync(new Uint8Array(compressedBody))
|
||||
|
||||
// Convert the decompressed body to a string
|
||||
const decompressedString = new TextDecoder().decode(decompressedBody)
|
||||
const data = JSON.parse(decompressedString)
|
||||
|
||||
// Process the decompressed body as needed
|
||||
console.log(`Received: ${JSON.stringify(data)}`)
|
||||
|
||||
return new Response('ok', {
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error:', error)
|
||||
return new Response('Error processing request', { status: 500 })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
<Admonition type="warning">
|
||||
|
||||
Edge functions have a runtime memory limit of 150MB. Overly large compressed payloads may result in an out-of-memory error.
|
||||
|
||||
</Admonition>
|
||||
@@ -13,9 +13,9 @@ Developing with Edge Functions is similar to developing with Node.js, but with a
|
||||
Supabase Edge Functions support:
|
||||
|
||||
- The Deno [standard library](https://deno.land/std)
|
||||
- Third party [modules](https://deno.land/x)
|
||||
- JavaScript modules from npm (https://docs.deno.com/examples/npm/)
|
||||
- Built-in [Node APIs](https://docs.deno.com/runtime/manual/node/compatibility)
|
||||
- Node.js modules published to npm
|
||||
- Third party modules published to [JSR](https://jsr.io/) or [deno.land/x](https://deno.land/x)
|
||||
|
||||
You can import npm modules using the `npm:` specifier or via CDNs like [esm.sh](https://esm.sh). For example, to import the `supabase-js` package:
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ The following script is a good example to get started with testing your Edge Fun
|
||||
import { assert, assertEquals } from 'https://deno.land/std@0.192.0/testing/asserts.ts'
|
||||
import { createClient, SupabaseClient } from 'jsr:@supabase/supabase-js@2'
|
||||
|
||||
// Will load the .env file to Deno.env
|
||||
import 'https://deno.land/x/dotenv@v3.2.2/load.ts'
|
||||
|
||||
// Set up the configuration for the Supabase client
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL') ?? ''
|
||||
const supabaseKey = Deno.env.get('SUPABASE_ANON_KEY') ?? ''
|
||||
@@ -130,19 +133,19 @@ To locally test and debug Edge Functions, you can utilize the Supabase CLI. Let'
|
||||
|
||||
```bash
|
||||
# creates the file
|
||||
touch .env.local
|
||||
touch .env
|
||||
# adds the SUPABASE_URL secret
|
||||
echo "SUPABASE_URL=http://localhost:54321" >> .env.local
|
||||
echo "SUPABASE_URL=http://localhost:54321" >> .env
|
||||
# adds the SUPABASE_ANON_KEY secret
|
||||
echo "SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" >> .env.local
|
||||
echo "SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" >> .env
|
||||
# Alternatively, you can open it in your editor:
|
||||
open .env.local
|
||||
open .env
|
||||
```
|
||||
|
||||
4. To run the tests, use the following command in your terminal:
|
||||
|
||||
```bash
|
||||
deno test --allow-all supabase/functions/tests/function-one-test.ts --env=.env.local
|
||||
deno test --allow-all supabase/functions/tests/function-one-test.ts
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
@@ -66,6 +66,10 @@ Deploy read-only databases across multiple regions, for lower latency and better
|
||||
|
||||
Launch the Supabase stack on Fly.io edge network. [Docs](/docs/guides/platform/fly-postgres).
|
||||
|
||||
### Log Drains
|
||||
|
||||
Export Supabase logs at to 3rd party providers and external tooling. [Docs](/docs/guides/platform/log-drains).
|
||||
|
||||
## Studio
|
||||
|
||||
### Studio Single Sign On
|
||||
@@ -144,7 +148,7 @@ Upload large files using resumable uploads. [Docs](/docs/guides/storage/uploads/
|
||||
|
||||
### S3 compatibility
|
||||
|
||||
Interact with Storage from tool which supports with the S3 protocol. [Docs](/docs/guides/storage/s3/compatibility).
|
||||
Interact with Storage from tool which supports the S3 protocol. [Docs](/docs/guides/storage/s3/compatibility).
|
||||
|
||||
## Edge Functions
|
||||
|
||||
@@ -212,11 +216,14 @@ In addition to the Beta requirements, features in GA are covered by the [uptime
|
||||
| Platform | Terraform Provider | `public alpha` | N/A |
|
||||
| Platform | Read Replicas | `private alpha` | N/A |
|
||||
| Platform | Fly Postgres | `private alpha` | N/A |
|
||||
| Platform | Log Drains | `public alpha` | ✅ |
|
||||
| Studio | | `GA` | ✅ |
|
||||
| Studio | SSO | `GA` | ✅ |
|
||||
| Realtime | Postgres Changes | `GA` | ✅ |
|
||||
| Realtime | Broadcast | `GA` | ✅ |
|
||||
| Realtime | Presence | `GA` | ✅ |
|
||||
| Realtime | Broadcast Authorization | `public beta` | ✅ |
|
||||
| Realtime | Presence Authorization | `public beta` | ✅ |
|
||||
| Storage | | `GA` | ✅ |
|
||||
| Storage | CDN | `GA` | 🚧 [Cloudflare](https://www.cloudflare.com) |
|
||||
| Storage | Smart CDN | `GA` | 🚧 [Cloudflare](https://www.cloudflare.com) |
|
||||
|
||||
@@ -119,7 +119,18 @@ Add an intent-filter to enable deep linking:
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="web" label="Web">
|
||||
There are no additional configurations.
|
||||
Supabase redirects do not work with Flutter's [default URL strategy](https://docs.flutter.dev/ui/navigation/url-strategies).
|
||||
We can switch to the path URL strategy as follows:
|
||||
|
||||
```dart
|
||||
import 'package:flutter_web_plugins/url_strategy.dart';
|
||||
|
||||
void main() {
|
||||
usePathUrlStrategy();
|
||||
runApp(ExampleApp());
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -303,14 +303,18 @@ class ProductRepositoryImpl @Inject constructor(
|
||||
set("price", price)
|
||||
set("image", buildImageUrl(imageFileName = imageUrl))
|
||||
}) {
|
||||
eq("id", id)
|
||||
filter {
|
||||
eq("id", id)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
postgrest.from("products").update({
|
||||
set("name", name)
|
||||
set("price", price)
|
||||
}) {
|
||||
eq("id", id)
|
||||
filter {
|
||||
eq("id", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +221,7 @@ Now that we have all the components in place, let's update `App.svelte`:
|
||||
import Account from './lib/Account.svelte'
|
||||
import Auth from './lib/Auth.svelte'
|
||||
|
||||
let session: AuthSession
|
||||
let session: AuthSession | null
|
||||
|
||||
onMount(() => {
|
||||
supabase.auth.getSession().then(({ data }) => {
|
||||
@@ -269,9 +269,9 @@ Let's create an avatar for the user so that they can upload a profile photo. We
|
||||
import { supabase } from '../supabaseClient'
|
||||
|
||||
export let size: number
|
||||
export let url: string
|
||||
export let url: string | null = null
|
||||
|
||||
let avatarUrl: string = null
|
||||
let avatarUrl: string | null = null
|
||||
let uploading = false
|
||||
let files: FileList
|
||||
|
||||
@@ -341,8 +341,8 @@ Let's create an avatar for the user so that they can upload a profile photo. We
|
||||
id="single"
|
||||
accept="image/*"
|
||||
bind:files
|
||||
on:change="{uploadAvatar}"
|
||||
disabled="{uploading}"
|
||||
on:change={uploadAvatar}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -128,6 +128,8 @@ After a user is signed in, you can allow them to edit their profile details and
|
||||
Create a new view for that called `ProfileView.swift`.
|
||||
|
||||
```swift ProfileView.swift
|
||||
import SwiftUI
|
||||
|
||||
struct ProfileView: View {
|
||||
@State var username = ""
|
||||
@State var fullName = ""
|
||||
@@ -180,7 +182,7 @@ struct ProfileView: View {
|
||||
do {
|
||||
let currentUser = try await supabase.auth.session.user
|
||||
|
||||
let profile: Profile = try await supabase.database
|
||||
let profile: Profile = try await supabase
|
||||
.from("profiles")
|
||||
.select()
|
||||
.eq("id", value: currentUser.id)
|
||||
@@ -204,7 +206,7 @@ struct ProfileView: View {
|
||||
do {
|
||||
let currentUser = try await supabase.auth.session.user
|
||||
|
||||
try await supabase.database
|
||||
try await supabase
|
||||
.from("profiles")
|
||||
.update(
|
||||
UpdateProfileParams(
|
||||
@@ -260,6 +262,8 @@ Now that you've created all the views, add an entry point for the application. T
|
||||
Add a new `AppView.swift` file.
|
||||
|
||||
```swift AppView.swift
|
||||
import SwiftUI
|
||||
|
||||
struct AppView: View {
|
||||
@State var isAuthenticated = false
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
---
|
||||
title: 'Advanced Log Filtering'
|
||||
description: 'Filter logs with regular expressions'
|
||||
---
|
||||
|
||||
# Querying the logs
|
||||
|
||||
## Understanding field references
|
||||
|
||||
The log tables are queried with subset of BigQuery SQL syntax. They all have three columns: `event_message`, `timestamp`, and `metadata`.
|
||||
|
||||
| column | description |
|
||||
| ------------- | --------------------------- |
|
||||
| timestamp | time event was recorded |
|
||||
| event_message | the log's message |
|
||||
| metadata | information about the event |
|
||||
|
||||
The `metadata` column is an array of JSON objects that stores important details about each recorded event. For example, in the postgres table, the `metadata.parsed.error_severity` field indicates the error level of an event. To work with its values, you need to `unnest` them using a `cross join`.
|
||||
|
||||
This approach is commonly used with JSON and array columns, so it might look a bit unfamiliar if you're not used to working with these data types.
|
||||
|
||||
```sql
|
||||
select
|
||||
event_message,
|
||||
parsed.error_severity,
|
||||
parsed.user_name
|
||||
from
|
||||
postgres_logs
|
||||
-- extract first layer
|
||||
cross join unnest(postgres_logs.metadata) as metadata
|
||||
-- extract second layer
|
||||
cross join unnest(metadata.parsed) as parsed;
|
||||
```
|
||||
|
||||
## Expanding results
|
||||
|
||||
Logs returned by queries may be difficult to read in table format. A row can be double-clicked to expand the results into more readable JSON:
|
||||
|
||||

|
||||
|
||||
## Filtering with [regular expressions](https://en.wikipedia.org/wiki/Regular_expression)
|
||||
|
||||
The Logs use BigQuery Style regular expressions with the [regexp_contains function](https://cloud.google.com/bigquery/docs/reference/standard-sql/string_functions#regexp_contains). In its most basic form, it will check if a string is present in a specified column.
|
||||
|
||||
```sql
|
||||
select
|
||||
cast(timestamp as datetime) as timestamp,
|
||||
event_message,
|
||||
metadata
|
||||
from postgres_logs
|
||||
where regexp_contains(event_message, 'is present');
|
||||
```
|
||||
|
||||
There are multiple operators that you should consider using:
|
||||
|
||||
### Find messages that start with a phrase
|
||||
|
||||
`^` only looks for values at the start of a string
|
||||
|
||||
```sql
|
||||
-- find only messages that start with connection
|
||||
regexp_contains(event_message, '^connection')
|
||||
```
|
||||
|
||||
### Find messages that end with a phrase:
|
||||
|
||||
`$` only looks for values at the end of the string
|
||||
|
||||
```sql
|
||||
-- find only messages that ends with port=12345
|
||||
regexp_contains(event_message, '$port=12345')
|
||||
```
|
||||
|
||||
### Ignore case sensitivity:
|
||||
|
||||
`(?i)` ignores capitalization for all proceeding characters
|
||||
|
||||
```sql
|
||||
-- find all event_messages with the word "connection"
|
||||
regexp_contains(event_message, '(?i)COnnecTion')
|
||||
```
|
||||
|
||||
### Wildcards:
|
||||
|
||||
`.` can represent any string of characters
|
||||
|
||||
```sql
|
||||
-- find event_messages like "hello<anything>world"
|
||||
regexp_contains(event_message, 'hello.world')
|
||||
```
|
||||
|
||||
### Alphanumeric ranges:
|
||||
|
||||
`[1-9a-zA-Z]` finds any strings with only numbers and letters
|
||||
|
||||
```sql
|
||||
-- find event_messages that contain a number between 1 and 5 (inclusive)
|
||||
regexp_contains(event_message, '[1-5]')
|
||||
```
|
||||
|
||||
### Repeated values:
|
||||
|
||||
`x*` zero or more x
|
||||
`x+` one or more x
|
||||
`x?` zero or one x
|
||||
`x{4,}` four or more x
|
||||
`x{3}` exactly 3 x
|
||||
|
||||
```sql
|
||||
-- find event_messages that contains any sequence of 3 digits
|
||||
regexp_contains(event_message, '[0-9]{3}')
|
||||
```
|
||||
|
||||
### Escaping reserved characters:
|
||||
|
||||
`\.` interpreted as period `.` instead of as a wildcard
|
||||
|
||||
```sql
|
||||
-- escapes .
|
||||
regexp_contains(event_message, 'hello world\.')
|
||||
```
|
||||
|
||||
### `or` statements:
|
||||
|
||||
`x|y` any string with `x` or `y` present
|
||||
|
||||
```sql
|
||||
-- find event_messages that have the word 'started' followed by either the word "host" or "authenticated"
|
||||
regexp_contains(event_message, 'started host|authenticated')
|
||||
```
|
||||
|
||||
### and/or/not statements in SQL:
|
||||
|
||||
`and`, `or`, and `not` are all native terms in SQL and can be used in conjunction with regular expressions to filter results
|
||||
|
||||
```sql
|
||||
select
|
||||
cast(timestamp as datetime) as timestamp,
|
||||
event_message,
|
||||
metadata
|
||||
from postgres_logs
|
||||
where
|
||||
(regexp_contains(event_message, 'connection') and regexp_contains(event_message, 'host'))
|
||||
or not regexp_contains(event_message, 'received');
|
||||
```
|
||||
|
||||
### Filtering and unnesting example
|
||||
|
||||
**Filter for Postgres**
|
||||
|
||||
```sql
|
||||
select
|
||||
cast(postgres_logs.timestamp as datetime) as timestamp,
|
||||
parsed.error_severity,
|
||||
parsed.user_name,
|
||||
event_message
|
||||
from
|
||||
postgres_logs
|
||||
cross join unnest(metadata) as metadata
|
||||
cross join unnest(metadata.parsed) as parsed
|
||||
where regexp_contains(parsed.error_severity, 'ERROR|FATAL|PANIC')
|
||||
order by timestamp desc
|
||||
limit 100;
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
### Log tables cannot be joined together
|
||||
|
||||
Each product table operates independently without the ability to join with other log tables. This may change in the future.
|
||||
|
||||
### The `with` keyword and subqueries are not supported
|
||||
|
||||
The parser does not yet support `with` and subquery statements.
|
||||
|
||||
### The `ilike` and `similar to` keywords are not supported
|
||||
|
||||
Although `like` and other comparison operators can be used, `ilike` and `similar to` are incompatible with BigQuery's variant of SQL. `regexp_contains` can be used as an alternative.
|
||||
|
||||
### The wildcard operator `*` to select columns is not supported
|
||||
|
||||
The log parser is not able to parse the `*` operator for column selection. Instead, you can access all fields from the `metadata` column:
|
||||
|
||||
```sql
|
||||
select
|
||||
cast(postgres_logs.timestamp as datetime) as timestamp,
|
||||
event_message,
|
||||
metadata
|
||||
from
|
||||
<log_table_name>
|
||||
order by timestamp desc
|
||||
limit 100;
|
||||
```
|
||||
@@ -134,3 +134,27 @@ A date and time picker will be provided upon pressing the `Start a restore` butt
|
||||

|
||||
|
||||
After locking in the desired point in time to recover to, The Dashboard will then prompt for a review and confirmation before proceeding with the restoration. The project will be inaccessible following this. As such, do ensure to allot for downtime beforehand. This is dependent on the size of the database. The larger it is, the longer the downtime will be. Once the confirmation has been given, the latest physical backup available is downloaded to the project and the database is partially restored. WAL files generated after this physical backup up to the specified point-in-time are then downloaded. The underlying records of transactions in these files are replayed against the database to complete the restoration. The Dashboard will display a notification once the restoration completes.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Logical backups
|
||||
|
||||
#### search_path issues
|
||||
|
||||
During the `pg_restore` process, the `search_path` is set to an empty string for predictability, and security. Using unqualified references to functions or relations can cause restorations using logical backups to fail, as the database will not be able to locate the function or relation being referenced. This can happen even if the database functions without issues during normal operations, as the `search_path` is usually set to include several schemas during normal operations. Therefore, you should always use schema-qualified names within your SQL code.
|
||||
|
||||
You can refer to [an example PR](https://github.com/supabase/supabase/pull/28393/files) on how to update SQL code to use schema-qualified names.
|
||||
|
||||
#### Invalid check constraints
|
||||
|
||||
Postgres requires that [check constraints](https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-CHECK-CONSTRAINTS) be:
|
||||
|
||||
1. immutable
|
||||
1. not reference table data other than the new or updated row being checked
|
||||
|
||||
Violating these requirements can result in numerous failure scenarios, including during logical restorations.
|
||||
|
||||
Common examples of check constraints that can result in such failures are:
|
||||
|
||||
- validating against the current time, e.g. that the row being inserted references a future event
|
||||
- validating the contents of a row against the contents of another table
|
||||
@@ -64,7 +64,7 @@ Read more on [how billing for compute works](https://supabase.com/docs/guides/pl
|
||||
|
||||
#### What is unified egress and how is it billed?
|
||||
|
||||
Unified egress refers to the total egress quota available to each organization. This quota can be utilized for various purposes such as Storage, Realtime, Auth, Functions, Supavisor and Database. Each plan includes a specific egress quota, and any additional usage beyond that quota is billed accordingly.
|
||||
Unified egress refers to the total egress quota available to each organization. This quota can be utilized for various purposes such as Storage, Realtime, Auth, Functions, Supavisor, Log Drains and Database. Each plan includes a specific egress quota, and any additional usage beyond that quota is billed accordingly.
|
||||
|
||||
To learn more about unified egress and its details, please refer to the information provided [here](https://supabase.com/docs/guides/platform/org-based-billing#:~:text=running%20any%20projects.-,Unified%20egress,-%23).
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ description: "Learn about your project's instance and additional add-ons."
|
||||
|
||||
Every project on the Supabase Platform comes with its own dedicated Postgres instance running inside a virtual machine (VM). The following table describes the base instance with additional compute add-ons available if you need extra performance when scaling up Supabase.
|
||||
|
||||
You can upgrade your compute from the [Supabase Dashboard](/dashboard/project/_/settings/addons).
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
Free Plan resources subject to changes.
|
||||
@@ -101,3 +103,7 @@ The maximum number of replication slots and WAL senders depends on your compute
|
||||
As mentioned in the Postgres [documentation](https://postgresqlco.nf/doc/en/param/max_replication_slots/), setting `max_replication_slots` to a lower value than the current number of replication slots will prevent the server from starting. If you are downgrading your compute add-on, please ensure that you are using fewer slots than the maximum number of replication slots available for the new compute add-on.
|
||||
|
||||
</Admonition>
|
||||
|
||||
## Upgrade downtime
|
||||
|
||||
Compute add-on changes are usually applied with less than 2 minutes of downtime, but can take longer depending on the Cloud Provider.
|
||||
@@ -32,7 +32,6 @@ The following parameters are available for overrides:
|
||||
1. [max_worker_processes](https://postgresqlco.nf/doc/en/param/max_worker_processes/)
|
||||
1. [session_replication_role](https://postgresqlco.nf/doc/en/param/session_replication_role/)
|
||||
1. [shared_buffers](https://postgresqlco.nf/doc/en/param/shared_buffers/)
|
||||
1. [statement_timeout](https://postgresqlco.nf/doc/en/param/statement_timeout/)
|
||||
1. [work_mem](https://postgresqlco.nf/doc/en/param/work_mem/)
|
||||
|
||||
### Setting config using the CLI
|
||||
@@ -64,17 +63,6 @@ max_parallel_workers |3 |
|
||||
- End of Custom Postgres Config -
|
||||
```
|
||||
|
||||
When configuring parameters that require time units, such as `statement_timeout`, the CLI expects a string value with a time unit. Supported units include `ms`, `s`, and `min`.
|
||||
|
||||
```bash
|
||||
$ supabase --experimental --project-ref <project-ref> postgres-config update --config statement_timeout=1000ms
|
||||
|
||||
- Custom Postgres Config -
|
||||
Config |Value |
|
||||
statement_timeout |1000ms |
|
||||
- End of Custom Postgres Config -
|
||||
```
|
||||
|
||||
## Considerations
|
||||
|
||||
1. The Postgres cluster will be restarted in order to change the configuration being used. This will cause momentary disruption to existing database connections; in most cases this should not take more than a few seconds.
|
||||
|
||||
@@ -4,17 +4,11 @@ title: 'Log Drains'
|
||||
description: 'Getting started with Supabase Log Drains'
|
||||
---
|
||||
|
||||
Log drains will send all logs of the Supabase stack to one or more desired destinations. It is only available for customers on Team and Enterprise Plans.
|
||||
Log drains will send all logs of the Supabase stack to one or more desired destinations. It is only available for customers on Team and Enterprise Plans. Log drains is available in the dashboard under [Project Settings > Log Drains](https://supabase.com/dashboard/project/_/settings/log-drains).
|
||||
|
||||
<Admonition type="note">
|
||||
You can read about the intiial announcement [here](https://supabase.com/blog/log-drains) and vote for your preferred drains in [this discussion](https://github.com/orgs/supabase/discussions/28324?sort=top).
|
||||
|
||||
Log drains is in **Private Alpha**, and is not available in the dashboard yet. [Reach out to us](https://forms.supabase.com/logdrains) to get log drains enabled for your project.
|
||||
|
||||
You can read about the initial announcement [here](https://github.com/orgs/supabase/discussions/26650).
|
||||
|
||||
</Admonition>
|
||||
|
||||
## Supported Destinations
|
||||
# Supported Destinations
|
||||
|
||||
The following table lists the supported destinations and the required setup configuration:
|
||||
|
||||
@@ -25,7 +19,7 @@ The following table lists the supported destinations and the required setup conf
|
||||
|
||||
HTTP requests are batched with a max of 250 logs or 1 second intervals, whichever happens first. Logs are compressed via Gzip if the destination supports it.
|
||||
|
||||
### Generic HTTP endpoint
|
||||
## Generic HTTP Endpoint
|
||||
|
||||
Logs are sent as a POST request with a JSON body. Both HTTP/1 and HTTP/2 protocols are supported.
|
||||
Custom headers can optionally be configured for all requests.
|
||||
@@ -36,7 +30,109 @@ Note that requests are **unsigned**.
|
||||
Unsigned requests to HTTP endpoints are temporary and all requests will signed in the near future.
|
||||
</Admonition>
|
||||
|
||||
### Datadog
|
||||
<Accordion
|
||||
type="default"
|
||||
openBehaviour="multiple"
|
||||
>
|
||||
<AccordionItem
|
||||
header="Edge Function Walkthrough (Uncompressed)"
|
||||
id="uncompressed"
|
||||
>
|
||||
|
||||
1. Create and deploy the edge function
|
||||
|
||||
Generate a new edge function template and update it to log out the received JSON payload. For simplicity, we will accept any request with an Anon Key.
|
||||
|
||||
```bash
|
||||
supabase functions new hello-world
|
||||
```
|
||||
|
||||
You can use this example snippet as an illustration of how the received request will be like.
|
||||
|
||||
```ts
|
||||
import 'jsr:@supabase/functions-js/edge-runtime.d.ts'
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const data = await req.json()
|
||||
|
||||
console.log(`Received ${data.length} logs, first log:\n ${JSON.stringify(data[0])}`)
|
||||
return new Response(JSON.stringify({ message: 'ok' }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
And then deploy it with:
|
||||
|
||||
```bash
|
||||
supabase functions deploy hello-world --project-ref [PROJECT REF]
|
||||
```
|
||||
|
||||
<Admonition type="warning">
|
||||
|
||||
This will create an infinite loop, as we are generating an additional log event that will eventually trigger a new request to this edge function. However, due to the batching nature of how Log Drain events are dispatched, the rate of edge function triggers will not increase greatly and will have an upper bound.
|
||||
|
||||
</Admonition>
|
||||
|
||||
2. Configure the HTTP Drain
|
||||
|
||||
Create a HTTP drain under the [Project Settings > Log Drains](https://supabase.com/dashboard/project/_/settings/log-drains).
|
||||
|
||||
- Disable the Gzip, as we want to receive the payload without compression.
|
||||
- Under URL, set it to your edge function URL `https://[PROJECT REF].supabase.co/functions/v1/hello-world`
|
||||
- Under Headers, set the `Authorization: Bearer [ANON KEY]`
|
||||
|
||||
<ProjectConfigVariables variable="anonKey" />
|
||||
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
header="Edge Function Gzip Example"
|
||||
id="gzip"
|
||||
>
|
||||
|
||||
Gzip payloads can be decompressed using native in-built APIs. Refer to the Edge Function [compression guide](https://supabase.com/docs/guides/functions/compression)
|
||||
|
||||
```ts
|
||||
import { gunzipSync } from 'node:zlib'
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
try {
|
||||
// Check if the request body is gzip compressed
|
||||
const contentEncoding = req.headers.get('content-encoding')
|
||||
if (contentEncoding !== 'gzip') {
|
||||
return new Response('Request body is not gzip compressed', {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
// Read the compressed body
|
||||
const compressedBody = await req.arrayBuffer()
|
||||
|
||||
// Decompress the body
|
||||
const decompressedBody = gunzipSync(new Uint8Array(compressedBody))
|
||||
|
||||
// Convert the decompressed body to a string
|
||||
const decompressedString = new TextDecoder().decode(decompressedBody)
|
||||
const data = JSON.parse(decompressedString)
|
||||
// Process the decompressed body as needed
|
||||
console.log(`Received: ${data.length} logs.`)
|
||||
|
||||
return new Response('ok', {
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error:', error)
|
||||
return new Response('Error processing request', { status: 500 })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
</AccordionItem>
|
||||
|
||||
</Accordion>
|
||||
|
||||
## Datadog Logs
|
||||
|
||||
Logs sent to Datadog have the name of the log source set on the `service` field of the event and the source set to `Supabase`. Logs are gzipped before they are sent to Datadog.
|
||||
|
||||
@@ -79,10 +175,6 @@ To setup Datadog log drain, generate a Datadog API key [here](https://app.datado
|
||||
metadata.parsed.error_severity, metadata.level
|
||||
```
|
||||
|
||||
[Remapper](https://docs.datadoghq.com/service_management/events/pipelines_and_processors/remapper) for setting the log event message.
|
||||
```
|
||||
event_message
|
||||
```
|
||||
</AccordionItem>
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -174,10 +174,9 @@ Sentry.init({
|
||||
breadcrumbs: true,
|
||||
errors: true,
|
||||
}),
|
||||
Sentry.nativeNodeFetchIntegration({
|
||||
ignoreOutgoingRequests: (url) => {
|
||||
console.log('server', `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest`, url)
|
||||
return url.startsWith(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest`)
|
||||
Sentry.browserTracingIntegration({
|
||||
shouldCreateSpanForRequest: (url) => {
|
||||
return !url.startsWith(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest`)
|
||||
},
|
||||
}),
|
||||
],
|
||||
@@ -207,10 +206,10 @@ Sentry.init({
|
||||
breadcrumbs: true,
|
||||
errors: true,
|
||||
}),
|
||||
Sentry.winterCGFetchIntegration({
|
||||
Sentry.nativeNodeFetchIntegration({
|
||||
breadcrumbs: true,
|
||||
shouldCreateSpanForRequest: (url) => {
|
||||
return !url.startsWith(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest`)
|
||||
ignoreOutgoingRequests: (url) => {
|
||||
return url.startsWith(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest`)
|
||||
},
|
||||
}),
|
||||
],
|
||||
@@ -229,17 +228,17 @@ Sentry.init({
|
||||
```js sentry.edge.config.ts
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
import { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { SupabaseIntegration } from '@supabase/sentry-js-integration'
|
||||
import { supabaseIntegration } from '@supabase/sentry-js-integration'
|
||||
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
integrations: [
|
||||
new SupabaseIntegration(SupabaseClient, {
|
||||
supabaseIntegration(SupabaseClient, Sentry, {
|
||||
tracing: true,
|
||||
breadcrumbs: true,
|
||||
errors: true,
|
||||
}),
|
||||
new Sentry.Integrations.WinterCGFetch({
|
||||
Sentry.winterCGFetchIntegration({
|
||||
breadcrumbs: true,
|
||||
shouldCreateSpanForRequest: (url) => {
|
||||
return !url.startsWith(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest`)
|
||||
|
||||
@@ -73,6 +73,17 @@ Go to your Supabase project's [API Settings](https://supabase.com/dashboard/proj
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
```python
|
||||
from supabase import create_client
|
||||
|
||||
URL = "https://<project>.supabase.co"
|
||||
KEY = "<your-anon-key>"
|
||||
supabase = create_client(URL, KEY)
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
### Listening to broadcast messages
|
||||
@@ -159,6 +170,25 @@ You can provide a callback for the `broadcast` channel to receive message. In th
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
```python
|
||||
# Join a room/topic. Can be anything except for 'realtime'.
|
||||
channel_a = supabase.channel('room-1')
|
||||
|
||||
# Simple function to log any messages we receive
|
||||
def message_received(payload):
|
||||
print(f"Broadcast received: {payload}")
|
||||
|
||||
# Subscribe to the Channel
|
||||
channel_a
|
||||
.on_broadcast(
|
||||
'test',
|
||||
message_received
|
||||
)
|
||||
.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
### Sending broadcast messages
|
||||
@@ -250,6 +280,28 @@ You can provide a callback for the `broadcast` channel to receive message. In th
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
We can send Broadcast messages using `channel_b.send_broadcast()`. Let's set up another client to send messages.
|
||||
|
||||
```python
|
||||
# Join a room/topic. Can be anything except for 'realtime'.
|
||||
channel_b = supabase.channel('room-1')
|
||||
|
||||
def on_subscribe(status, err):
|
||||
if status != RealtimeSubscribeStates.SUBSCRIBED:
|
||||
return
|
||||
|
||||
# Send a message once the client is subscribed
|
||||
channel_b.send_broadcast(
|
||||
'test',
|
||||
{ "message": 'hello, world' },
|
||||
)
|
||||
|
||||
channel_b.subscribe(on_subscribe)
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
Before sending messages we need to ensure the client is connected, which we have done within the `subscribe()` callback.
|
||||
@@ -374,6 +426,33 @@ You can pass configuration options while initializing the Supabase Client.
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
By default, broadcast messages are only sent to other clients. You can broadcast messages back to the sender by setting Broadcast's `self` parameter to `True`.
|
||||
|
||||
```python
|
||||
# Join a room/topic. Can be anything except for 'realtime'.
|
||||
my_channel = supabase.channel('room-2', {"config": {"broadcast": {"self": True}}})
|
||||
|
||||
my_channel.on_broadcast(
|
||||
'test-my-messages',
|
||||
lambda payload: print(payload)
|
||||
)
|
||||
|
||||
def on_subscribe(status, err):
|
||||
if status != RealtimeSubscribeStates.SUBSCRIBED:
|
||||
return
|
||||
|
||||
# Send a message once the client is subscribed
|
||||
channel_b.send_broadcast(
|
||||
'test-my-messages',
|
||||
{ "message": 'talking to myself' },
|
||||
)
|
||||
|
||||
my_channel.subscribe(on_subscribe)
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
### Acknowledge messages
|
||||
@@ -466,6 +545,9 @@ You can pass configuration options while initializing the Supabase Client.
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
Unsupported in Python yet.
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
Use this to guarantee that the server has received the message before resolving `channelD.send`'s promise. If the `ack` config is not set to `true` when creating the channel, the promise returned by `channelD.send` will resolve immediately.
|
||||
@@ -551,4 +633,7 @@ You can also send a Broadcast message by making an HTTP request to Realtime serv
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
Unsupported in Python yet.
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
@@ -277,6 +277,18 @@ changes
|
||||
myChannel.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
```python
|
||||
changes = supabase.channel('schema-db-changes').on_postgres_changes(
|
||||
"*",
|
||||
schema="public",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
@@ -358,6 +370,18 @@ changes
|
||||
myChannel.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
```python
|
||||
changes = supabase.channel('schema-db-changes').on_postgres_changes(
|
||||
"INSERT", # Listen only to INSERTs
|
||||
schema="public",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
@@ -439,6 +463,18 @@ changes
|
||||
myChannel.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
```python
|
||||
changes = supabase.channel('schema-db-changes').on_postgres_changes(
|
||||
"UPDATE", # Listen only to UPDATEs
|
||||
schema="public",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
@@ -520,6 +556,18 @@ changes
|
||||
myChannel.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
```python
|
||||
changes = supabase.channel('schema-db-changes').on_postgres_changes(
|
||||
"DELETE", # Listen only to DELETEs
|
||||
schema="public",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
@@ -606,6 +654,19 @@ changes
|
||||
myChannel.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
```python
|
||||
changes = supabase.channel('db-changes').on_postgres_changes(
|
||||
"UPDATE",
|
||||
schema="public",
|
||||
table="todos",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
@@ -693,6 +754,23 @@ val userChanges = myChannel.postgresChangeFlow<PostgresAction.Insert>(schema = "
|
||||
myChannel.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
```python
|
||||
changes = supabase.channel('db-changes').on_postgres_changes(
|
||||
"*",
|
||||
schema="public",
|
||||
table="messages"
|
||||
callback=lambda payload: print(payload)
|
||||
).on_postgres_changes(
|
||||
"INSERT",
|
||||
schema="public",
|
||||
table="users",
|
||||
callback=lambda payload: print(payload)
|
||||
).subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
@@ -784,6 +862,20 @@ changes
|
||||
myChannel.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
```python
|
||||
changes = supabase.channel('db-changes').on_postgres_changes(
|
||||
"INSERT",
|
||||
schema="public",
|
||||
table="todos",
|
||||
filter="id=eq.1",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
@@ -879,6 +971,20 @@ changes
|
||||
myChannel.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
```python
|
||||
changes = supabase.channel('db-changes').on_postgres_changes(
|
||||
"UPDATE",
|
||||
schema="public",
|
||||
table="messages",
|
||||
filter="body=eq.hey",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
@@ -973,6 +1079,20 @@ supabase.realtime.connect()
|
||||
myChannel.join()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
```python
|
||||
changes = supabase.channel('db-changes').on_postgres_changes(
|
||||
"INSERT",
|
||||
schema="public",
|
||||
table="messages",
|
||||
filter="body=neq.bye",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
@@ -1066,6 +1186,20 @@ changes
|
||||
myChannel.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
```python
|
||||
changes = supabase.channel('db-changes').on_postgres_changes(
|
||||
"INSERT",
|
||||
schema="public",
|
||||
table="profiles",
|
||||
filter="age=lt.65",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
@@ -1159,6 +1293,20 @@ changes
|
||||
myChannel.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
```python
|
||||
changes = supabase.channel('db-changes').on_postgres_changes(
|
||||
"UPDATE",
|
||||
schema="public",
|
||||
table="profiles",
|
||||
filter="age=lte.65",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
@@ -1252,6 +1400,20 @@ changes
|
||||
myChannel.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
```python
|
||||
changes = supabase.channel('db-changes').on_postgres_changes(
|
||||
"UPDATE",
|
||||
schema="public",
|
||||
table="products",
|
||||
filter="quantity=gt.10",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
@@ -1345,6 +1507,20 @@ changes
|
||||
myChannel.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
```python
|
||||
changes = supabase.channel('db-changes').on_postgres_changes(
|
||||
"UPDATE",
|
||||
schema="public",
|
||||
table="products",
|
||||
filter="quantity=gte.10",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
@@ -1438,6 +1614,20 @@ changes
|
||||
myChannel.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
```python
|
||||
changes = supabase.channel('db-changes').on_postgres_changes(
|
||||
"UPDATE",
|
||||
schema="public",
|
||||
table="products",
|
||||
filter="name=in.(red, blue, yellow)",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
@@ -1587,6 +1777,22 @@ changes
|
||||
myChannel.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
```python
|
||||
supabase.realtime.set_auth('your-custom-jwt')
|
||||
|
||||
changes = supabase.channel('db-changes').on_postgres_changes(
|
||||
"UPDATE",
|
||||
schema="public",
|
||||
table="products",
|
||||
filter="name=in.(red, blue, yellow)",
|
||||
callback=lambda payload: print(payload)
|
||||
)
|
||||
.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
@@ -1634,6 +1840,13 @@ In Kotlin, you have to update the token manually per channel:
|
||||
myChannel.updateAuth("fresh-token")
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
```python
|
||||
supabase.realtime.set_auth('fresh-token')
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -70,6 +70,34 @@ val supabase = createSupabaseClient(supabaseUrl, supabaseKey) {
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
```python
|
||||
from supabase import create_client
|
||||
|
||||
SUPABASE_URL = 'https://<project>.supabase.co'
|
||||
SUPABASE_KEY = '<your-anon-key>'
|
||||
|
||||
supabase = create_client(SUPABASE_URL, SUPABASE_KEY)
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="dart" label="Dart">
|
||||
|
||||
```dart
|
||||
void main() {
|
||||
Supabase.initialize(
|
||||
url: 'https://<project>.supabase.co',
|
||||
anonKey: '<your-anon-key>',
|
||||
);
|
||||
|
||||
runApp(MyApp());
|
||||
}
|
||||
|
||||
final supabase = Supabase.instance.client;
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
@@ -156,6 +184,21 @@ presenceFlow
|
||||
roomOne.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
Listen to the `sync`, `join`, and `leave` events triggered whenever any client joins or leaves the channel or changes their slice of state:
|
||||
|
||||
```python
|
||||
room_one = supabase.channel('room_01')
|
||||
|
||||
room_one
|
||||
.on_presence_sync(lambda: print('sync', room_one.presenceState()))
|
||||
.on_presence_join(lambda key, curr_presences, joined_presences: print('join', key, curr_presences, joined_presences))
|
||||
.on_presence_leave(lambda key, curr_presences, left_presences: print('leave', key, curr_presences, left_presences))
|
||||
.subscribe()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
@@ -249,6 +292,26 @@ roomOne.subscribe(blockUntilSubscribed = true) //You can also use the roomOne.st
|
||||
roomOne.track(userStatus)
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
```python
|
||||
room_one = supabase.channel('room_01')
|
||||
|
||||
user_status = {
|
||||
"user": 'user-1',
|
||||
"online_at": datetime.datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
def on_subscribe(status, err):
|
||||
if status != RealtimeSubscribeStates.SUBSCRIBED:
|
||||
return
|
||||
|
||||
room_one.track(user_status)
|
||||
|
||||
room_one.subscribe(on_subscribe)
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
@@ -308,6 +371,13 @@ suspend fun untrackPresence() {
|
||||
untrackPresence()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
```python
|
||||
room_one.untrack()
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
@@ -370,5 +440,18 @@ val channelC = supabase.channel("test") {
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
```python
|
||||
channel_c = supabase.channel('test', {
|
||||
"config": {
|
||||
"presence": {
|
||||
"key": 'userId-123',
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
title: 'Migrate from Amazon RDS to Supabase'
|
||||
description: 'Migrate your Amazon RDS MySQL or MS SQL database to Supabase Postgres database.'
|
||||
description: 'Migrate your Amazon RDS MySQL or MS SQL database to Supabase.'
|
||||
subtitle: 'Migrate your Amazon RDS MySQL or MS SQL database to Supabase.'
|
||||
---
|
||||
|
||||
This guide aims to exhibit the process of transferring your Amazon RDS database from any of these engines Postgres, MySQL or MS SQL to Supabase's Postgres database. Although Amazon RDS is a favored managed database service provided by AWS, it may not suffice for all use cases. Supabase, on the other hand, provides an excellent free and open source option that encompasses all the necessary backend features to develop a product: a Postgres database, authentication, instant APIs, edge functions, real-time subscriptions, and storage.
|
||||
@@ -21,9 +22,7 @@ Supabase's core is Postgres, enabling the use of row-level security and providin
|
||||
|
||||
## Retrieve your Supabase host [#retrieve-supabase-host]
|
||||
|
||||
1. If you're new to Supabase, [create a project](https://supabase.com/dashboard).
|
||||
Make a note of your password, you will need this later. If you forget it, you can [reset it here](https://supabase.com/dashboard/project/_/settings/database).
|
||||
|
||||
1. If you're new to Supabase, [create a project](https://database.new). Make a note of your password, you will need this later. If you forget it, you can [reset it here](https://supabase.com/dashboard/project/_/settings/database).
|
||||
1. Go to the [Database settings](https://supabase.com/dashboard/project/_/settings/database) for your project in the Supabase Dashboard.
|
||||
1. Under **Connection Info**, note your Host (`$SUPABASE_HOST`).
|
||||
|
||||
|
||||
@@ -254,4 +254,4 @@ This isn't supported by Supabase Auth yet.
|
||||
## Useful references
|
||||
|
||||
- [Migrating 125k users from Auth0 to Supabase](https://kevcodez.medium.com/migrating-125-000-users-from-auth0-to-supabase-81c0568de307)
|
||||
- [Loper to Supabase migraton](https://eigen.sh/posts/auth-migration)
|
||||
- [Loper to Supabase migration](https://eigen.sh/posts/auth-migration)
|
||||
@@ -1,14 +1,14 @@
|
||||
---
|
||||
id: 'firebase-auth'
|
||||
title: 'Firebase Auth Migration'
|
||||
description: 'Migrate Firebase auth users to a Supabase project.'
|
||||
sidebar_label: 'Firebase Auth'
|
||||
title: 'Migrate from Firebase Auth to Supabase'
|
||||
description: 'Migrate Firebase auth users to Supabase Auth.'
|
||||
subtitle: 'Migrate Firebase auth users to Supabase Auth.'
|
||||
---
|
||||
|
||||
Supabase provides several [tools](https://github.com/supabase-community/firebase-to-supabase/tree/main/auth) to help migrate auth users from a Firebase project to a Supabase project. There are two parts to the migration process:
|
||||
|
||||
- `firestoreusers2json` ([TypeScript](https://github.com/supabase-community/firebase-to-supabase/blob/main/auth/firestoreusers2json.ts), [JavaScript](https://github.com/supabase-community/firebase-to-supabase/blob/main/auth/firestoreusers2json.js)) exports users from an existing Firebase project to a `.json` file on your local system.
|
||||
- `import_users` ([TypeScript](https://github.com/supabase-community/firebase-to-supabase/blob/main/auth/import_users.ts), [JavaScript](https://github.com/supabase-community/firebase-to-supabase/blob/main/auth/import_users.js)) imports users from a saved `.json` file into your Supabase project (inserting those users into the `auth.users` table of your `PostgreSQL` database instance).
|
||||
- `import_users` ([TypeScript](https://github.com/supabase-community/firebase-to-supabase/blob/main/auth/import_users.ts), [JavaScript](https://github.com/supabase-community/firebase-to-supabase/blob/main/auth/import_users.js)) imports users from a saved `.json` file into your Supabase project (inserting those users into the `auth.users` table of your `Postgres` database instance).
|
||||
|
||||
## Set up the migration tool [#set-up-migration-tool]
|
||||
|
||||
@@ -69,7 +69,7 @@ hash_config {
|
||||
- `filename.json`: (optional) output filename (defaults to `./users.json`)
|
||||
- `batchSize`: (optional) number of users to fetch in each batch (defaults to 100)
|
||||
|
||||
### Import JSON users file to Supabase Auth (PostgreSQL: auth.users) [#import-json-users-file]
|
||||
### Import JSON users file to Supabase Auth (Postgres: auth.users) [#import-json-users-file]
|
||||
|
||||
`node import_users.js <path_to_json_file> [<batch_size>]`
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
id: 'firebase-storage'
|
||||
title: 'Firebase Storage Migration'
|
||||
title: 'Migrated from Firebase Storage to Supabase'
|
||||
description: 'Migrate Firebase Storage files to Supabase Storage.'
|
||||
sidebar_label: 'Firebase Storage'
|
||||
subtitle: 'Migrate Firebase Storage files to Supabase Storage.'
|
||||
---
|
||||
|
||||
Supabase provides several [tools](https://github.com/supabase-community/firebase-to-supabase/tree/main/storage) to convert storage files from Firebase Storage to Supabase Storage. Conversion is a two-step process:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
id: 'firestore-data'
|
||||
title: 'Firestore Data Migration'
|
||||
title: 'Migrated from Firebase Firestore to Supabase'
|
||||
description: 'Migrate your Firebase Firestore database to a Supabase Postgres database.'
|
||||
sidebar_label: 'Firestore Data'
|
||||
subtitle: 'Migrate your Firebase Firestore database to a Supabase Postgres database.'
|
||||
---
|
||||
|
||||
Supabase provides several [tools](https://github.com/supabase-community/firebase-to-supabase/tree/main/firestore) to convert data from a Firebase Firestore database to a Supabase PostgreSQL database. The process copies the entire contents of a single Firestore `collection` to a single PostgreSQL `table`.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
id: 'heroku'
|
||||
title: 'Migrate from Heroku to Supabase'
|
||||
description: 'Migrate your Heroku Postgres database to Supabase.'
|
||||
sidebar_label: 'Heroku'
|
||||
video: 'https://www.youtube.com/v/xsRhPMphtZ4'
|
||||
subtitle: 'Migrate your Heroku Postgres database to Supabase.'
|
||||
tocVideo: 'xsRhPMphtZ4'
|
||||
---
|
||||
|
||||
Supabase is one of the best [free alternatives to Heroku Postgres](https://supabase.com/alternatives/supabase-vs-heroku-postgres). This guide shows how to migrate your Heroku Postgres database to Supabase. This migration requires the [pg_dump](https://www.postgresql.org/docs/current/app-pgdump.html) and [psql](https://www.postgresql.org/docs/current/app-psql.html) CLI tools, which are installed automatically as part of the complete PostgreSQL installation package.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
title: 'Migrate Your MSSQL Database to Supabase'
|
||||
description: 'Migrate your Microsoft SQL Server database to Supabase Postgres database.'
|
||||
title: 'Migrate from MSSQL to Supabase'
|
||||
description: 'Migrate your Microsoft SQL Server database to Supabase.'
|
||||
subtitle: 'Migrate your Microsoft SQL Server database to Supabase.'
|
||||
---
|
||||
|
||||
This guide aims to demonstrate the process of transferring your Microsoft SQL Server database to Supabase's PostgreSQL database. Supabase is a powerful and open-source platform offering a wide range of backend features, including a PostgreSQL database, authentication, instant APIs, edge functions, real-time subscriptions, and storage. Migrating your MSSQL database to Supabase's Postgres enables you to leverage Postgres's capabilities and access all the features you need for your project.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
title: 'Migrate Your MySQL Database to Supabase'
|
||||
title: 'Migrate from MySQL to Supabase'
|
||||
description: 'Migrate your MySQL database to Supabase Postgres database.'
|
||||
subtitle: 'Migrate your MySQL database to Supabase Postgres database.'
|
||||
---
|
||||
|
||||
This guide aims to exhibit the process of transferring your MySQL database to Supabase's Postgres database. Supabase is a robust and open-source platform offering a wide range of backend features, including a PostgreSQL database, authentication, instant APIs, edge functions, real-time subscriptions, and storage. Migrating your MySQL database to Supabase's PostgreSQL enables you to leverage PostgreSQL's capabilities and access all the features you need for your project.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
title: 'Migrate from Postgres to Supabase'
|
||||
description: 'Migrate your Postgres database to Supabase.'
|
||||
description: 'Migrate your existing Postgres database to Supabase.'
|
||||
subtitle: 'Migrate your existing Postgres database to Supabase.'
|
||||
---
|
||||
|
||||
This is a guide for migrating your Postgres database to [Supabase](https://supabase.com).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
title: 'Migrate from Render to Supabase'
|
||||
description: 'Migrate your Render Postgres database to Supabase.'
|
||||
subtitle: 'Migrate your Render Postgres database to Supabase.'
|
||||
---
|
||||
|
||||
Render is a popular Web Hosting service in the online services category that also has a managed Postgres service. Render has a great developer experience, allowing users to deploy straight from GitHub or GitLab. This is the core of their product and they do it really well. However, when it comes to Postgres databases, it may not be the best option.
|
||||
|
||||
@@ -6,7 +6,7 @@ subtitle: 'Learn about using custom roles with storage schema'
|
||||
sidebar_label: 'Schema'
|
||||
---
|
||||
|
||||
In this guide, you will learn how to create and use custom roles with Storage to manage role-based access to objects and buckets.
|
||||
In this guide, you will learn how to create and use custom roles with Storage to manage role-based access to objects and buckets. The same approach can be used to use custom roles with any other Supabase service.
|
||||
|
||||
Supabase Storage uses the same role-based access control system as any other Supabase service using RLS (Row Level Security).
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
export const REFERENCES = {
|
||||
javascript: {
|
||||
type: 'sdk',
|
||||
name: 'JavaScript',
|
||||
library: 'supabase-js',
|
||||
libPath: 'javascript',
|
||||
versions: ['v2', 'v1'],
|
||||
typeSpec: true,
|
||||
icon: 'reference-javascript',
|
||||
meta: {
|
||||
v2: {
|
||||
libId: 'reference_javascript_v2',
|
||||
specFile: 'supabase_js_v2',
|
||||
},
|
||||
v1: {
|
||||
libId: 'reference_javascript_v1',
|
||||
specFile: 'supabase_js_v1',
|
||||
},
|
||||
},
|
||||
},
|
||||
dart: {
|
||||
type: 'sdk',
|
||||
name: 'Flutter',
|
||||
library: 'supabase-dart',
|
||||
libPath: 'dart',
|
||||
versions: ['v2', 'v1'],
|
||||
icon: 'reference-dart',
|
||||
meta: {
|
||||
v2: {
|
||||
libId: 'reference_dart_v2',
|
||||
specFile: 'supabase_dart_v2',
|
||||
},
|
||||
v1: {
|
||||
libId: 'reference_dart_v1',
|
||||
specFile: 'supabase_dart_v1',
|
||||
},
|
||||
},
|
||||
},
|
||||
csharp: {
|
||||
type: 'sdk',
|
||||
name: 'C#',
|
||||
library: 'supabase-csharp',
|
||||
libPath: 'csharp',
|
||||
versions: ['v1', 'v0'],
|
||||
icon: 'reference-csharp',
|
||||
meta: {
|
||||
v1: {
|
||||
libId: 'reference_csharp_v1',
|
||||
specFile: 'supabase_csharp_v1',
|
||||
},
|
||||
v0: {
|
||||
libId: 'reference_csharp_v0',
|
||||
specFile: 'supabase_csharp_v0',
|
||||
},
|
||||
},
|
||||
},
|
||||
swift: {
|
||||
type: 'sdk',
|
||||
name: 'Swift',
|
||||
library: 'supabase-swift',
|
||||
libPath: 'swift',
|
||||
versions: ['v2', 'v1'],
|
||||
icon: 'reference-swift',
|
||||
meta: {
|
||||
v2: {
|
||||
libId: 'reference_swift_v2',
|
||||
specFile: 'supabase_swift_v2',
|
||||
},
|
||||
v1: {
|
||||
libId: 'reference_swift_v1',
|
||||
specFile: 'supabase_swift_v1',
|
||||
},
|
||||
},
|
||||
},
|
||||
kotlin: {
|
||||
type: 'sdk',
|
||||
name: 'Kotlin',
|
||||
library: 'supabase-kt',
|
||||
libPath: 'kotlin',
|
||||
versions: ['v2', 'v1'],
|
||||
icon: 'reference-kotlin',
|
||||
meta: {
|
||||
v2: {
|
||||
libId: 'reference_kotlin_v2',
|
||||
specFile: 'supabase_kt_v2',
|
||||
},
|
||||
v1: {
|
||||
libId: 'reference_kotlin_v1',
|
||||
specFile: 'supabase_kt_v1',
|
||||
},
|
||||
},
|
||||
},
|
||||
python: {
|
||||
type: 'sdk',
|
||||
name: 'Python',
|
||||
library: 'supabase-py',
|
||||
libPath: 'python',
|
||||
versions: ['v2'],
|
||||
icon: 'reference-python',
|
||||
meta: {
|
||||
v2: {
|
||||
libId: 'reference_python_v2',
|
||||
specFile: 'supabase_py_v2',
|
||||
},
|
||||
},
|
||||
},
|
||||
cli: {
|
||||
type: 'cli',
|
||||
name: 'CLI',
|
||||
versions: [],
|
||||
icon: 'reference-cli',
|
||||
},
|
||||
api: {
|
||||
type: 'api',
|
||||
name: 'API',
|
||||
versions: [],
|
||||
icon: 'reference-api',
|
||||
},
|
||||
} as const
|
||||
|
||||
export const clientSdkIds = Object.keys(REFERENCES).filter(
|
||||
(reference) => REFERENCES[reference].type === 'sdk'
|
||||
)
|
||||
@@ -1,29 +1,9 @@
|
||||
---
|
||||
id: introduction
|
||||
title: Introduction
|
||||
hideTitle: true
|
||||
---
|
||||
This reference documents every object and method available in Supabase's C# library, [supabase](https://www.nuget.org/packages/supabase). You can use `Supabase` to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
|
||||
|
||||
<div className="flex items-start gap-6 not-prose" id="introduction">
|
||||
<IconMenuCsharp width={35} height={35} />
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl text-foreground m-0">C# Client Library</h1>
|
||||
<h2 className="text-base font-mono text-foreground-light">
|
||||
@supabase-community/supabase-csharp
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<Admonition type="note">
|
||||
|
||||
{/* prettier-ignore */}
|
||||
<div className="max-w-xl">
|
||||
This reference documents every object and method available in Supabase's C# library, [supabase](https://www.nuget.org/packages/supabase). You can use `Supabase` to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
|
||||
</div>
|
||||
The C# client library is created and maintained by the Supabase community, and is not an official library. Please be tolerant of areas where the library is still being developed, and — as with all the libraries — feel free to contribute wherever you find issues.
|
||||
|
||||
{/* prettier-ignore */}
|
||||
<div className="max-w-xl bg-slate-300 px-4 py-2 rounded-md">
|
||||
Huge thanks to official maintainer, [Joseph Schultz](https://github.com/acupofjose). As well as [Will Iverson](https://github.com/wiverson), [Ben Randall](https://github.com/veleek), and [Rhuan Barros](https://github.com/rhuanbarros) for their help.
|
||||
|
||||
The C# client library is created and maintained by the Supabase community, and is not an official library. Please be tolerant of areas where the library is still being developed, and — as with all the libraries — feel free to contribute wherever you find issues.
|
||||
|
||||
Huge thanks to official maintainer, [Joseph Schultz](https://github.com/acupofjose). As well as [Will Iverson](https://github.com/wiverson), [Ben Randall](https://github.com/veleek), and [Rhuan Barros](https://github.com/rhuanbarros) for their help.
|
||||
|
||||
</div>
|
||||
</Admonition>
|
||||
@@ -1,29 +1,9 @@
|
||||
---
|
||||
id: introduction
|
||||
title: Introduction
|
||||
hideTitle: true
|
||||
---
|
||||
This reference documents every object and method available in Supabase's C# library, [supabase-csharp](https://www.nuget.org/packages/supabase-csharp). You can use `Supabase` to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
|
||||
|
||||
<div className="flex items-start gap-6 not-prose" id="introduction">
|
||||
<IconMenuCsharp width={35} height={35} />
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl text-foreground m-0">C# Client Library</h1>
|
||||
<h2 className="text-base font-mono text-foreground-light">
|
||||
@supabase-community/supabase-csharp
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<Admonition type="note">
|
||||
|
||||
{/* prettier-ignore */}
|
||||
<div className="max-w-xl">
|
||||
This reference documents every object and method available in Supabase's C# library, [supabase-csharp](https://www.nuget.org/packages/supabase-csharp). You can use `Supabase` to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
|
||||
</div>
|
||||
The C# client library is created and maintained by the Supabase community, and is not an official library. Please be tolerant of areas where the library is still being developed, and — as with all the libraries — feel free to contribute wherever you find issues.
|
||||
|
||||
{/* prettier-ignore */}
|
||||
<div className="max-w-xl bg-slate-300 px-4 py-2 rounded-md">
|
||||
Huge thanks to official maintainer, [Joseph Schultz](https://github.com/acupofjose). As well as [Will Iverson](https://github.com/wiverson), [Ben Randall](https://github.com/veleek), and [Rhuan Barros](https://github.com/rhuanbarros) for their help.
|
||||
|
||||
The C# client library is created and maintained by the Supabase community, and is not an official library. Please be tolerant of areas where the library is still being developed, and — as with all the libraries — feel free to contribute wherever you find issues.
|
||||
|
||||
Huge thanks to official maintainer, [Joseph Schultz](https://github.com/acupofjose). As well as [Will Iverson](https://github.com/wiverson), [Ben Randall](https://github.com/veleek), and [Rhuan Barros](https://github.com/rhuanbarros) for their help.
|
||||
|
||||
</div>
|
||||
</Admonition>
|
||||
@@ -0,0 +1,12 @@
|
||||
Supabase Auth can throw or return various errors when using the API. All errors originating from the `supabase.auth` namespace of the client library will be wrapped by the `AuthException` class.
|
||||
|
||||
Error objects are split in a few classes. `AuthApiException` is an exception which originates from the Supabase Auth API.
|
||||
|
||||
Errors originating from the server API classed as `AuthApiException` always have a `code` property that can be used to identify the error returned by the server. The `statusCode` property is also present, encoding the HTTP status code received in the response.
|
||||
|
||||
<AuthErrorCodesTable />
|
||||
|
||||
### Tips for better error handling
|
||||
|
||||
- Do not use string matching on error messages! Always use the error classes and `code` properties of error objects to identify the situation.
|
||||
- Although HTTP status codes generally don't change, they can suddenly change due to bugs, so avoid relying on them unless absolutely necessary.
|
||||
@@ -1,20 +1,3 @@
|
||||
---
|
||||
id: introduction
|
||||
title: Introduction
|
||||
hideTitle: true
|
||||
---
|
||||
This reference documents every object and method available in Supabase's Flutter library, [supabase-flutter](https://pub.dev/packages/supabase_flutter). You can use supabase-flutter to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
|
||||
|
||||
<div className="flex items-start gap-6 not-prose" id="introduction">
|
||||
<IconMenuFlutter width={35} height={35} />
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl text-foreground m-0">Flutter Client Library</h1>
|
||||
<h2 className="text-base font-mono text-foreground-light">supabase-flutter</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* prettier-ignore */}
|
||||
<div className="max-w-xl">
|
||||
This reference documents every object and method available in Supabase's Flutter library, [supabase-flutter](https://pub.dev/packages/supabase_flutter). You can use supabase-flutter to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
|
||||
|
||||
We also provide a [supabase](https://pub.dev/packages/supabase) package for non-Flutter projects.
|
||||
</div>
|
||||
We also provide a [supabase](https://pub.dev/packages/supabase) package for non-Flutter projects.
|
||||
@@ -1,24 +1,3 @@
|
||||
---
|
||||
id: introduction
|
||||
title: Introduction
|
||||
hideTitle: true
|
||||
---
|
||||
This reference documents every object and method available in Supabase's Flutter library, [supabase-flutter](https://pub.dev/packages/supabase_flutter). You can use supabase-flutter to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
|
||||
|
||||
<div className="flex items-start gap-6 not-prose" id="introduction">
|
||||
<IconMenuFlutter width={35} height={35} />
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl text-foreground m-0">Flutter Client Library</h1>
|
||||
<h2 className="text-base font-mono text-foreground-light">supabase-flutter</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-xl">
|
||||
<Admonition type="caution">
|
||||
|
||||
You're viewing the docs for an older version of the `supabase-flutter` library. Learn how to [upgrade to the latest version](/docs/reference/dart/v0/upgrade-guide).
|
||||
|
||||
</Admonition>
|
||||
This reference documents every object and method available in Supabase's Flutter library, [supabase-flutter](https://pub.dev/packages/supabase_flutter). You can use supabase-flutter to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
|
||||
|
||||
We also provide a [supabase](https://pub.dev/packages/supabase) package for non-Flutter projects.
|
||||
</div>
|
||||
We also provide a [supabase](https://pub.dev/packages/supabase) package for non-Flutter projects.
|
||||
@@ -1,20 +1,3 @@
|
||||
---
|
||||
id: introduction
|
||||
title: Introduction
|
||||
hideTitle: true
|
||||
---
|
||||
This reference documents every object and method available in Supabase's Flutter library, [supabase-flutter](https://pub.dev/packages/supabase_flutter). You can use supabase-flutter to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
|
||||
|
||||
<div className="flex items-start gap-6 not-prose" id="introduction">
|
||||
<IconMenuFlutter width={35} height={35} />
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl text-foreground m-0">Flutter Client Library</h1>
|
||||
<h2 className="text-base font-mono text-foreground-light">supabase-flutter</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* prettier-ignore */}
|
||||
<div className="max-w-xl">
|
||||
This reference documents every object and method available in Supabase's Flutter library, [supabase-flutter](https://pub.dev/packages/supabase_flutter). You can use supabase-flutter to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
|
||||
|
||||
We also provide a [supabase](https://pub.dev/packages/supabase) package for non-Flutter projects.
|
||||
</div>
|
||||
We also provide a [supabase](https://pub.dev/packages/supabase) package for non-Flutter projects.
|
||||
@@ -4,15 +4,4 @@ title: Introduction
|
||||
hideTitle: true
|
||||
---
|
||||
|
||||
<div className="flex items-start gap-6 not-prose" id="introduction">
|
||||
<IconMenuJavascript width={35} height={35} />
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl text-foreground m-0">JavaScript Client Library</h1>
|
||||
<h2 className="text-base font-mono text-foreground-light">@supabase/supabase-js</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* prettier-ignore */}
|
||||
<div className="max-w-xl">
|
||||
This reference documents every object and method available in Supabase's isomorphic JavaScript library, supabase-js. You can use supabase-js to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
|
||||
</div>
|
||||
This reference documents every object and method available in Supabase's isomorphic JavaScript library, `supabase-js`. You can use `supabase-js` to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
|
||||
@@ -1,25 +1 @@
|
||||
---
|
||||
id: introduction
|
||||
title: Introduction
|
||||
hideTitle: true
|
||||
---
|
||||
|
||||
<div className="flex items-start gap-6 not-prose" id="introduction">
|
||||
<IconMenuJavascript width={35} height={35} />
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl text-foreground m-0">JavaScript Client Library</h1>
|
||||
<h2 className="text-base font-mono text-foreground-light">@supabase/supabase-js</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-xl">
|
||||
<Admonition type="caution">
|
||||
|
||||
You're viewing the docs for an older version of the `supabase-js` library. Learn how to [upgrade to the latest version](/docs/reference/javascript/v1/upgrade-guide).
|
||||
|
||||
</Admonition>
|
||||
|
||||
<p>
|
||||
This reference documents every object and method available in Supabase's isomorphic JavaScript library, supabase-js. You can use supabase-js to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
|
||||
</p>
|
||||
</div>
|
||||
This reference documents every object and method available in Supabase's isomorphic JavaScript library, supabase-js. You can use supabase-js to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
id: release-notes
|
||||
title: Release Notes
|
||||
---
|
||||
|
||||
## js v1 this is the release notes file.
|
||||
@@ -1,30 +1,13 @@
|
||||
---
|
||||
id: introduction
|
||||
title: Introduction
|
||||
hideTitle: true
|
||||
---
|
||||
|
||||
<div className="flex items-start gap-6 not-prose" id="introduction">
|
||||
<IconMenuKotlin width={35} height={35} />
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl text-foreground m-0">Kotlin Client Library</h1>
|
||||
<h2 className="text-base font-mono text-foreground-light">@supabase-community/supabase-kt</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-xl">
|
||||
|
||||
This reference documents every object and method available in Supabase's Kotlin Multiplatform library, [supabase-kt](https://github.com/supabase-community/supabase-kt). You can use supabase-kt to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
|
||||
|
||||
To see supported Kotlin targets, check the corresponding module README on [GitHub](https://github.com/supabase-community/supabase-kt).
|
||||
|
||||
To migrate from version 1.4.X to 2.0.0, see the [migration guide](https://github.com/supabase-community/supabase-kt/blob/master/MIGRATION.md)
|
||||
|
||||
</div>
|
||||
<Admonition type="note">
|
||||
|
||||
<div className="max-w-xl bg-slate-300 px-4 py-2 rounded-md">
|
||||
The Kotlin client library is created and maintained by the Supabase community, and is not an official library. Please be tolerant of areas where the library is still being developed, and — as with all the libraries — feel free to contribute wherever you find issues.
|
||||
|
||||
Huge thanks to official maintainer, [jan-tennert](https://github.com/jan-tennert).
|
||||
|
||||
</div>
|
||||
</Admonition>
|
||||
@@ -0,0 +1,393 @@
|
||||
---
|
||||
id: installing
|
||||
title: 'Installing & Initialization'
|
||||
slug: installing
|
||||
custom_edit_url: https://github.com/supabase/supabase/edit/master/web/spec/supabase.yml
|
||||
---
|
||||
|
||||
### Add one or more modules to your project
|
||||
|
||||
<RefSubLayout.EducationRow>
|
||||
<RefSubLayout.Details>
|
||||
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
|
||||
<a href="https://github.com/supabase-community/supabase-kt/releases" style={{ marginRight: '8px' }}>
|
||||
<img src="https://img.shields.io/github/release/supabase-community/supabase-kt?label=stable" alt="latest stable supabase-kt version"/>
|
||||
</a>
|
||||
<a href="https://github.com/supabase-community/supabase-kt/releases">
|
||||
<img src="https://img.shields.io/maven-central/v/io.github.jan-tennert.supabase/supabase-kt?label=experimental" alt="latest supabase-kt version"/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
Add dependency to your build file using the BOM.
|
||||
|
||||
The available modules are:
|
||||
- [**gotrue-kt**](https://github.com/supabase-community/supabase-kt/tree/master/GoTrue)
|
||||
- [**realtime-kt**](https://github.com/supabase-community/supabase-kt/tree/master/Realtime)
|
||||
- [**storage-kt**](https://github.com/supabase-community/supabase-kt/tree/master/Storage)
|
||||
- [**functions-kt**](https://github.com/supabase-community/supabase-kt/tree/master/Functions)
|
||||
- [**postgrest-kt**](https://github.com/supabase-community/supabase-kt/tree/master/Postgrest)
|
||||
- Other plugins also available [here](https://github.com/supabase-community/supabase-kt/tree/master/plugins)
|
||||
|
||||
</RefSubLayout.Details>
|
||||
<RefSubLayout.Examples>
|
||||
<Tabs
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="kotlin"
|
||||
queryGroup="build-file"
|
||||
>
|
||||
<TabPanel id="kotlin" label="build.gradle.kts">
|
||||
|
||||
```kotlin
|
||||
implementation(platform("io.github.jan-tennert.supabase:bom:VERSION"))
|
||||
implementation("io.github.jan-tennert.supabase:postgrest-kt")
|
||||
implementation("io.github.jan-tennert.supabase:gotrue-kt")
|
||||
implementation("io.github.jan-tennert.supabase:realtime-kt")
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="groovy" label="build.gradle">
|
||||
|
||||
```groovy
|
||||
implementation platform("io.github.jan-tennert.supabase:bom:VERSION")
|
||||
implementation 'io.github.jan-tennert.supabase:postgrest-kt'
|
||||
implementation 'io.github.jan-tennert.supabase:gotrue-kt'
|
||||
implementation 'io.github.jan-tennert.supabase:realtime-kt'
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="xml" label="pom.xml">
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>io.github.jan-tennert.supabase</groupId>
|
||||
<artifactId>bom</artifactId>
|
||||
<version>VERSION</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.jan-tennert.supabase</groupId>
|
||||
<artifactId>postgrest-kt</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.jan-tennert.supabase</groupId>
|
||||
<artifactId>gotrue-kt</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.jan-tennert.supabase</groupId>
|
||||
<artifactId>realtime-kt</artifactId>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</RefSubLayout.Examples>
|
||||
|
||||
</RefSubLayout.EducationRow>
|
||||
|
||||
### Add Ktor Client Engine to each of your Kotlin targets (required)
|
||||
|
||||
<RefSubLayout.EducationRow>
|
||||
<RefSubLayout.Details>
|
||||
|
||||
You can find a list of engines [here](https://ktor.io/docs/http-client-engines.html)
|
||||
|
||||
</RefSubLayout.Details>
|
||||
<RefSubLayout.Examples>
|
||||
<Tabs
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="kotlin"
|
||||
queryGroup="build-file"
|
||||
>
|
||||
<TabPanel id="kotlin" label="build.gradle.kts">
|
||||
|
||||
```kotlin
|
||||
implementation("io.ktor:ktor-client-[engine]:KTOR_VERSION")
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="groovy" label="build.gradle">
|
||||
|
||||
```groovy
|
||||
implementation 'io.ktor:ktor-client-[engine]:KTOR_VERSION'
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="xml" label="pom.xml">
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>io.ktor</groupId>
|
||||
<artifactId>ktor-client-[engine]</artifactId>
|
||||
<version>KTOR_VERSION</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</RefSubLayout.Examples>
|
||||
|
||||
<RefSubLayout.Details>
|
||||
|
||||
Multiplatform example:
|
||||
|
||||
</RefSubLayout.Details>
|
||||
<RefSubLayout.Examples>
|
||||
|
||||
<Tabs
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="kotlin"
|
||||
queryGroup="build-file"
|
||||
>
|
||||
<TabPanel id="kotlin" label="build.gradle.kts">
|
||||
|
||||
```kotlin
|
||||
val commonMain by getting {
|
||||
dependencies {
|
||||
//supabase modules
|
||||
}
|
||||
}
|
||||
val jvmMain by getting {
|
||||
dependencies {
|
||||
implementation("io.ktor:ktor-client-cio:KTOR_VERSION")
|
||||
}
|
||||
}
|
||||
val androidMain by getting {
|
||||
dependsOn(jvmMain)
|
||||
}
|
||||
val jsMain by getting {
|
||||
dependencies {
|
||||
implementation("io.ktor:ktor-client-js:KTOR_VERSION")
|
||||
}
|
||||
}
|
||||
val iosMain by getting {
|
||||
dependencies {
|
||||
implementation("io.ktor:ktor-client-darwin:KTOR_VERSION")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
</RefSubLayout.Examples>
|
||||
|
||||
</RefSubLayout.EducationRow>
|
||||
|
||||
### Serialization
|
||||
|
||||
supabase-kt provides several different ways to encode and decode your custom objects.
|
||||
By default, [KotlinX Serialization](https://github.com/Kotlin/kotlinx.serialization) is used.
|
||||
|
||||
<RefSubLayout.EducationRow>
|
||||
<RefSubLayout.Details>
|
||||
|
||||
Use [KotlinX Serialization](https://github.com/Kotlin/kotlinx.serialization).
|
||||
|
||||
</RefSubLayout.Details>
|
||||
<RefSubLayout.Examples>
|
||||
|
||||
<Tabs
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="kotlin"
|
||||
queryGroup="build-file"
|
||||
>
|
||||
<TabPanel id="kotlin" label="build.gradle.kts">
|
||||
|
||||
```kotlin
|
||||
plugins {
|
||||
kotlin("plugin.serialization") version "KOTLIN_VERSION"
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="groovy" label="build.gradle">
|
||||
|
||||
```groovy
|
||||
plugins {
|
||||
id 'org.jetbrains.kotlin.plugin.serialization' version 'KOTLIN_VERSION'
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="xml" label="pom.xml">
|
||||
|
||||
```xml
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-maven-plugin</artifactId>
|
||||
<version>${kotlin.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>compile</id>
|
||||
<phase>compile</phase>
|
||||
<goals>
|
||||
<goal>compile</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<compilerPlugins>
|
||||
<plugin>kotlinx-serialization</plugin>
|
||||
</compilerPlugins>
|
||||
</configuration>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-maven-serialization</artifactId>
|
||||
<version>${kotlin.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
```kotlin
|
||||
val supabase = createSupabaseClient(supabaseUrl, supabaseKey) {
|
||||
//Already the default serializer, but you can provide a custom Json instance (optional):
|
||||
defaultSerializer = KotlinXSerializer(Json {
|
||||
//apply your custom config
|
||||
})
|
||||
}
|
||||
```
|
||||
</RefSubLayout.Examples>
|
||||
|
||||
</RefSubLayout.EducationRow>
|
||||
<RefSubLayout.EducationRow>
|
||||
<RefSubLayout.Details>
|
||||
|
||||
Use [Moshi](https://github.com/square/moshi).
|
||||
|
||||
</RefSubLayout.Details>
|
||||
<RefSubLayout.Examples>
|
||||
|
||||
<Tabs
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="kotlin"
|
||||
queryGroup="build-file"
|
||||
>
|
||||
<TabPanel id="kotlin" label="build.gradle.kts">
|
||||
|
||||
```kotlin
|
||||
implementation("io.github.jan-tennert.supabase:serializer-moshi:VERSION")
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="groovy" label="build.gradle">
|
||||
|
||||
```groovy
|
||||
implementation 'io.github.jan-tennert.supabase:serializer-moshi:VERSION'
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="xml" label="pom.xml">
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>io.github.jan-tennert.supabase</groupId>
|
||||
<artifactId>serializer-moshi</artifactId>
|
||||
<version>VERSION</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
```kotlin
|
||||
val supabase = createSupabaseClient(supabaseUrl, supabaseKey) {
|
||||
defaultSerializer = MoshiSerializer()
|
||||
}
|
||||
```
|
||||
|
||||
</RefSubLayout.Examples>
|
||||
|
||||
</RefSubLayout.EducationRow>
|
||||
<RefSubLayout.EducationRow>
|
||||
<RefSubLayout.Details>
|
||||
|
||||
Use [Jackson](https://github.com/FasterXML/jackson-module-kotlin).
|
||||
|
||||
</RefSubLayout.Details>
|
||||
<RefSubLayout.Examples>
|
||||
|
||||
<Tabs
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="kotlin"
|
||||
queryGroup="build-file"
|
||||
>
|
||||
<TabPanel id="kotlin" label="build.gradle.kts">
|
||||
|
||||
```kotlin
|
||||
implementation("io.github.jan-tennert.supabase:serializer-jackson:VERSION")
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="groovy" label="build.gradle">
|
||||
|
||||
```groovy
|
||||
implementation 'io.github.jan-tennert.supabase:serializer-jackson:VERSION'
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="xml" label="pom.xml">
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>io.github.jan-tennert.supabase</groupId>
|
||||
<artifactId>serializer-jackson</artifactId>
|
||||
<version>VERSION</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
```kotlin
|
||||
val supabase = createSupabaseClient(supabaseUrl, supabaseKey) {
|
||||
defaultSerializer = JacksonSerializer()
|
||||
}
|
||||
```
|
||||
|
||||
</RefSubLayout.Examples>
|
||||
|
||||
</RefSubLayout.EducationRow>
|
||||
<RefSubLayout.EducationRow>
|
||||
<RefSubLayout.Details>
|
||||
|
||||
Use custom serializer.
|
||||
|
||||
</RefSubLayout.Details>
|
||||
<RefSubLayout.Examples>
|
||||
|
||||
```kotlin
|
||||
class CustomSerializer: SupabaseSerializer {
|
||||
|
||||
override fun <T : Any> encode(type: KType, value: T): String {
|
||||
//encode value to string
|
||||
}
|
||||
|
||||
override fun <T : Any> decode(type: KType, value: String): T {
|
||||
//decode value
|
||||
}
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
val supabase = createSupabaseClient(supabaseUrl, supabaseKey) {
|
||||
defaultSerializer = CustomSerializer()
|
||||
}
|
||||
```
|
||||
|
||||
</RefSubLayout.Examples>
|
||||
|
||||
</RefSubLayout.EducationRow>
|
||||
@@ -0,0 +1,13 @@
|
||||
This reference documents every object and method available in Supabase's Kotlin Multiplatform library, [supabase-kt](https://github.com/supabase-community/supabase-kt). You can use supabase-kt to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
|
||||
|
||||
To see supported Kotlin targets, check the corresponding module README on [GitHub](https://github.com/supabase-community/supabase-kt).
|
||||
|
||||
To migrate from version 1.4.X to 2.0.0, see the [migration guide](https://github.com/supabase-community/supabase-kt/blob/master/MIGRATION.md)
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
The Kotlin client library is created and maintained by the Supabase community, and is not an official library. Please be tolerant of areas where the library is still being developed, and — as with all the libraries — feel free to contribute wherever you find issues.
|
||||
|
||||
Huge thanks to official maintainer, [jan-tennert](https://github.com/jan-tennert).
|
||||
|
||||
</Admonition>
|
||||
@@ -1,18 +1 @@
|
||||
---
|
||||
id: introduction
|
||||
title: Introduction
|
||||
hideTitle: true
|
||||
---
|
||||
|
||||
<div className="flex items-start gap-6 not-prose" id="introduction">
|
||||
<IconMenuPython width={35} height={35} />
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl text-foreground m-0">Python Client Library</h1>
|
||||
<h2 className="text-base font-mono text-foreground-light">@supabase/supabase-py</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-xl">
|
||||
{/* prettier-ignore */}
|
||||
<p>This reference documents every object and method available in Supabase's Python library, [supabase-py](https://github.com/supabase/supabase-py). You can use supabase-py to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.</p>
|
||||
</div>
|
||||
This reference documents every object and method available in Supabase's Python library, [supabase-py](https://github.com/supabase/supabase-py). You can use supabase-py to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
|
||||
@@ -1,20 +1 @@
|
||||
---
|
||||
id: introduction
|
||||
title: Introduction
|
||||
hideTitle: true
|
||||
---
|
||||
|
||||
<div className="flex items-start gap-6 not-prose" id="introduction">
|
||||
<IconMenuSwift width={35} height={35} />
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl text-foreground m-0">Swift Client Library</h1>
|
||||
<h2 className="text-base font-mono text-foreground-light">@supabase/supabase-swift</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* prettier-ignore */}
|
||||
<div className="max-w-xl">
|
||||
|
||||
This reference documents every object and method available in Supabase's Swift library, [supabase-swift](https://github.com/supabase/supabase-swift). You can use supabase-swift to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
|
||||
|
||||
</div>
|
||||
This reference documents every object and method available in Supabase's Swift library, [supabase-swift](https://github.com/supabase/supabase-swift). You can use supabase-swift to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
id: installing
|
||||
title: 'Installing'
|
||||
slug: installing
|
||||
custom_edit_url: https://github.com/supabase/supabase/edit/master/web/spec/supabase.yml
|
||||
---
|
||||
|
||||
### Install using Swift Package Manager
|
||||
|
||||
<RefSubLayout.EducationRow>
|
||||
<RefSubLayout.Details>
|
||||
|
||||
You can install Supabase package using Swift Package Manager.
|
||||
|
||||
The package exposes multiple libraries, you can choose between adding all of them using Supabase, or some of:
|
||||
|
||||
- `Auth`
|
||||
- `Realtime`
|
||||
- `Postgrest`
|
||||
- `Functions`
|
||||
- `Storage`
|
||||
|
||||
</RefSubLayout.Details>
|
||||
|
||||
<RefSubLayout.Examples>
|
||||
|
||||
<Tabs
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="swift"
|
||||
queryGroup="framework"
|
||||
>
|
||||
<TabPanel id="swift" label="Package.swift">
|
||||
|
||||
```swift
|
||||
let package = Package(
|
||||
...
|
||||
dependencies: [
|
||||
...
|
||||
.package(
|
||||
url: "https://github.com/supabase/supabase-swift.git",
|
||||
from: "2.0.0"
|
||||
),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "YourTargetName",
|
||||
dependencies: [
|
||||
.product(
|
||||
name: "Supabase", // Auth, Realtime, Postgrest, Functions, or Storage
|
||||
package: "supabase-swift"
|
||||
),
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
</RefSubLayout.Examples>
|
||||
</RefSubLayout.EducationRow>
|
||||
@@ -0,0 +1 @@
|
||||
This reference documents every object and method available in Supabase's Swift library, [supabase-swift](https://github.com/supabase/supabase-swift). You can use supabase-swift to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
|
||||
@@ -8,7 +8,6 @@ import SiteLayout from '~/layouts/SiteLayout'
|
||||
import { AuthContainer } from './auth/auth.client'
|
||||
import { DocsCommandMenu } from './command'
|
||||
import { QueryClientProvider } from './data/queryClient.client'
|
||||
import { ShortcutPreviewBuild } from './envs/staging.client'
|
||||
import { PageTelemetry } from './telemetry/telemetry.client'
|
||||
import { ScrollRestoration } from './ui/helpers.scroll.client'
|
||||
import { ThemeSandbox } from './ui/theme.client'
|
||||
@@ -18,27 +17,25 @@ import { ThemeSandbox } from './ui/theme.client'
|
||||
*/
|
||||
function GlobalProviders({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<ShortcutPreviewBuild>
|
||||
<QueryClientProvider>
|
||||
<AuthContainer>
|
||||
<PageTelemetry />
|
||||
<ScrollRestoration />
|
||||
<ThemeProvider defaultTheme="system" enableSystem disableTransitionOnChange>
|
||||
<CommandProvider>
|
||||
<div className="flex flex-col">
|
||||
<SiteLayout>
|
||||
<PortalToast />
|
||||
<PromoToast />
|
||||
{children}
|
||||
<DocsCommandMenu />
|
||||
</SiteLayout>
|
||||
<ThemeSandbox />
|
||||
</div>
|
||||
</CommandProvider>
|
||||
</ThemeProvider>
|
||||
</AuthContainer>
|
||||
</QueryClientProvider>
|
||||
</ShortcutPreviewBuild>
|
||||
<QueryClientProvider>
|
||||
<AuthContainer>
|
||||
<PageTelemetry />
|
||||
<ScrollRestoration />
|
||||
<ThemeProvider defaultTheme="system" enableSystem disableTransitionOnChange>
|
||||
<CommandProvider>
|
||||
<div className="flex flex-col">
|
||||
<SiteLayout>
|
||||
<PortalToast />
|
||||
<PromoToast />
|
||||
{children}
|
||||
<DocsCommandMenu />
|
||||
</SiteLayout>
|
||||
<ThemeSandbox />
|
||||
</div>
|
||||
</CommandProvider>
|
||||
</ThemeProvider>
|
||||
</AuthContainer>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@ import { redirect } from 'next/navigation'
|
||||
import { readFile, readdir } from 'node:fs/promises'
|
||||
import { extname, join, sep } from 'node:path'
|
||||
|
||||
import { pluckPromise } from '~/features/helpers.fn'
|
||||
import { cache_fullProcess_withDevCacheBust, existsFile } from '~/features/helpers.fs'
|
||||
import type { OrPromise } from '~/features/helpers.types'
|
||||
import { notFoundLink } from '~/features/recommendations/NotFound.utils'
|
||||
import { BASE_PATH, MISC_URL } from '~/lib/constants'
|
||||
import { generateOpenGraphImageMeta } from '~/features/seo/openGraph'
|
||||
import { BASE_PATH } from '~/lib/constants'
|
||||
import { GUIDES_DIRECTORY, isValidGuideFrontmatter, type GuideFrontmatter } from '~/lib/docs'
|
||||
import { newEditLink } from './GuidesMdx.template'
|
||||
|
||||
@@ -108,9 +110,6 @@ const genGuidesStaticParams = (directory?: string) => async () => {
|
||||
return result
|
||||
}
|
||||
|
||||
const pluckPromise = <T, K extends keyof T>(promise: Promise<T>, key: K) =>
|
||||
promise.then((data) => data[key])
|
||||
|
||||
const genGuideMeta =
|
||||
<Params,>(
|
||||
generate: (params: Params) => OrPromise<{ meta: GuideFrontmatter; pathname: `/${string}` }>
|
||||
@@ -136,12 +135,11 @@ const genGuideMeta =
|
||||
openGraph: {
|
||||
...parentOg,
|
||||
url: `${BASE_PATH}${pathname}`,
|
||||
images: {
|
||||
url: `${MISC_URL}/functions/v1/og-images?site=docs&type=${encodeURIComponent(ogType)}&title=${encodeURIComponent(meta.title)}&description=${encodeURIComponent(meta.description ?? 'undefined')}`,
|
||||
width: 800,
|
||||
height: 600,
|
||||
alt: meta.title,
|
||||
},
|
||||
images: generateOpenGraphImageMeta({
|
||||
type: ogType,
|
||||
title: meta.title,
|
||||
description: meta.description,
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import ButtonCard from '~/components/ButtonCard'
|
||||
import { Extensions } from '~/components/Extensions'
|
||||
import { JwtGenerator } from '~/components/JwtGenerator'
|
||||
import {
|
||||
AuthErrorCodesTable,
|
||||
AuthRateLimits,
|
||||
CreateClientSnippet,
|
||||
DatabaseSetup,
|
||||
@@ -41,6 +42,7 @@ const components = {
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
Admonition,
|
||||
AuthErrorCodesTable,
|
||||
AuthRateLimits,
|
||||
AuthSmsProviderConfig,
|
||||
AppleSecretGenerator,
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { keyBy, isPlainObject } from 'lodash'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
import { REFERENCES, clientSdkIds } from '~/content/navigation.references'
|
||||
import { parseTypeSpec } from '~/features/docs/Reference.typeSpec'
|
||||
import type { AbbrevCommonClientLibSection } from '~/features/docs/Reference.utils'
|
||||
import { deepFilterRec } from '~/features/helpers.fn'
|
||||
import type { Json } from '~/features/helpers.types'
|
||||
import commonClientLibSections from '~/spec/common-client-libs-sections.json' assert { type: 'json' }
|
||||
|
||||
const DOCS_DIRECTORY = join(dirname(fileURLToPath(import.meta.url)), '../..')
|
||||
const SPEC_DIRECTORY = join(DOCS_DIRECTORY, 'spec')
|
||||
const GENERATED_DIRECTORY = join(dirname(fileURLToPath(import.meta.url)), 'generated')
|
||||
|
||||
async function getSpec(specFile: string, { ext = 'yml' }: { ext?: string } = {}) {
|
||||
const specFullPath = join(SPEC_DIRECTORY, `${specFile}.${ext}`)
|
||||
const rawSpec = await readFile(specFullPath, 'utf-8')
|
||||
return ext === 'yml' ? parse(rawSpec) : rawSpec
|
||||
}
|
||||
|
||||
async function parseFnsList(rawSpec: Json): Promise<Array<{ id: unknown }>> {
|
||||
if (isPlainObject(rawSpec) && 'functions' in (rawSpec as object)) {
|
||||
const _rawSpec = rawSpec as { functions: unknown }
|
||||
if (Array.isArray(_rawSpec.functions)) {
|
||||
return _rawSpec.functions.filter(({ id }) => !!id)
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function genClientSdkSectionTree(fns: Array<{ id: unknown }>, excludeName: string) {
|
||||
const validSections = deepFilterRec(
|
||||
commonClientLibSections as Array<AbbrevCommonClientLibSection>,
|
||||
'items',
|
||||
(section) =>
|
||||
section.type === 'markdown' || section.type === 'category'
|
||||
? !('excludes' in section && section.excludes.includes(excludeName))
|
||||
: section.type === 'function'
|
||||
? fns.some(({ id }) => section.id === id)
|
||||
: true
|
||||
)
|
||||
return validSections
|
||||
}
|
||||
|
||||
export function flattenCommonClientLibSections(tree: Array<AbbrevCommonClientLibSection>) {
|
||||
return tree.reduce((acc, elem) => {
|
||||
if ('items' in elem) {
|
||||
const prunedElem = { ...elem }
|
||||
delete prunedElem.items
|
||||
acc.push(prunedElem)
|
||||
acc.push(...flattenCommonClientLibSections(elem.items))
|
||||
} else {
|
||||
acc.push(elem)
|
||||
}
|
||||
|
||||
return acc
|
||||
}, [] as Array<AbbrevCommonClientLibSection>)
|
||||
}
|
||||
|
||||
async function writeTypes() {
|
||||
const types = await parseTypeSpec()
|
||||
|
||||
await writeFile(
|
||||
join(GENERATED_DIRECTORY, 'typeSpec.json'),
|
||||
JSON.stringify(types, (key, value) => {
|
||||
if (key === 'methods') {
|
||||
return Object.fromEntries(value.entries())
|
||||
} else {
|
||||
return value
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async function writeReferenceSections() {
|
||||
return Promise.all(
|
||||
clientSdkIds
|
||||
.flatMap((sdkId) => {
|
||||
const versions = REFERENCES[sdkId].versions
|
||||
return versions.map((version) => ({
|
||||
sdkId,
|
||||
version,
|
||||
}))
|
||||
})
|
||||
.flatMap(async ({ sdkId, version }) => {
|
||||
const spec = await getSpec(REFERENCES[sdkId].meta[version].specFile)
|
||||
|
||||
const fnsList = await parseFnsList(spec)
|
||||
const pendingFnListWrite = writeFile(
|
||||
join(GENERATED_DIRECTORY, `${sdkId}.${version}.functions.json`),
|
||||
JSON.stringify(fnsList)
|
||||
)
|
||||
|
||||
const sectionTree = genClientSdkSectionTree(fnsList, REFERENCES[sdkId].meta[version].libId)
|
||||
const pendingSectionTreeWrite = writeFile(
|
||||
join(GENERATED_DIRECTORY, `${sdkId}.${version}.sections.json`),
|
||||
JSON.stringify(sectionTree)
|
||||
)
|
||||
|
||||
const flattened = flattenCommonClientLibSections(sectionTree)
|
||||
const pendingFlattenedWrite = writeFile(
|
||||
join(GENERATED_DIRECTORY, `${sdkId}.${version}.flat.json`),
|
||||
JSON.stringify(flattened)
|
||||
)
|
||||
|
||||
const sectionsBySlug = keyBy(flattened, (section) => section.slug)
|
||||
const pendingSlugDictionaryWrite = writeFile(
|
||||
join(GENERATED_DIRECTORY, `${sdkId}.${version}.bySlug.json`),
|
||||
JSON.stringify(sectionsBySlug)
|
||||
)
|
||||
|
||||
return [
|
||||
pendingFnListWrite,
|
||||
pendingSectionTreeWrite,
|
||||
pendingFlattenedWrite,
|
||||
pendingSlugDictionaryWrite,
|
||||
]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
await mkdir(GENERATED_DIRECTORY, { recursive: true })
|
||||
|
||||
await Promise.all([writeTypes(), writeReferenceSections()])
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
run()
|
||||
@@ -0,0 +1,100 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import type { ModuleTypes } from '~/features/docs/Reference.typeSpec'
|
||||
import type { AbbrevCommonClientLibSection } from '~/features/docs/Reference.utils'
|
||||
|
||||
let typeSpec: Array<ModuleTypes>
|
||||
|
||||
async function _typeSpecSingleton() {
|
||||
if (!typeSpec) {
|
||||
const rawJson = await readFile(
|
||||
join(process.cwd(), 'features/docs', './generated/typeSpec.json'),
|
||||
'utf-8'
|
||||
)
|
||||
typeSpec = JSON.parse(rawJson, (key, value) => {
|
||||
if (key === 'methods') {
|
||||
return new Map(Object.entries(value))
|
||||
} else {
|
||||
return value
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return typeSpec
|
||||
}
|
||||
|
||||
export async function getTypeSpec(ref: string) {
|
||||
const modules = await _typeSpecSingleton()
|
||||
|
||||
const delimiter = ref.indexOf('.')
|
||||
const refMod = ref.substring(0, delimiter)
|
||||
|
||||
const mod = modules.find((mod) => mod.name === refMod)
|
||||
return mod?.methods.get(ref)
|
||||
}
|
||||
|
||||
const functionsList = new Map<string, Array<{ id: unknown }>>()
|
||||
|
||||
export async function getFunctionsList(sdkId: string, version: string) {
|
||||
const key = `${sdkId}.${version}`
|
||||
if (!functionsList.has(key)) {
|
||||
const data = await readFile(
|
||||
join(process.cwd(), 'features/docs', `./generated/${sdkId}.${version}.functions.json`),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
functionsList.set(key, JSON.parse(data))
|
||||
}
|
||||
|
||||
return functionsList.get(key)
|
||||
}
|
||||
|
||||
const referenceSections = new Map<string, Array<AbbrevCommonClientLibSection>>()
|
||||
|
||||
export async function getReferenceSections(sdkId: string, version: string) {
|
||||
const key = `${sdkId}.${version}`
|
||||
if (!referenceSections.has(key)) {
|
||||
const data = await readFile(
|
||||
join(process.cwd(), 'features/docs', `./generated/${sdkId}.${version}.sections.json`),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
referenceSections.set(key, JSON.parse(data))
|
||||
}
|
||||
|
||||
return referenceSections.get(key)
|
||||
}
|
||||
|
||||
const flatSections = new Map<string, Array<AbbrevCommonClientLibSection>>()
|
||||
|
||||
export async function getFlattenedSections(sdkId: string, version: string) {
|
||||
const key = `${sdkId}.${version}`
|
||||
if (!flatSections.has(key)) {
|
||||
const data = await readFile(
|
||||
join(process.cwd(), 'features/docs', `./generated/${sdkId}.${version}.flat.json`),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
flatSections.set(key, JSON.parse(data))
|
||||
}
|
||||
|
||||
return flatSections.get(key)
|
||||
}
|
||||
|
||||
const sectionsBySlug = new Map<string, Map<string, AbbrevCommonClientLibSection>>()
|
||||
|
||||
export async function getSectionsBySlug(sdkId: string, version: string) {
|
||||
const key = `${sdkId}.${version}`
|
||||
if (!sectionsBySlug.has(key)) {
|
||||
const data = await readFile(
|
||||
join(process.cwd(), 'features/docs', `./generated/${sdkId}.${version}.bySlug.json`),
|
||||
'utf-8'
|
||||
)
|
||||
const asObject = JSON.parse(data)
|
||||
|
||||
sectionsBySlug.set(key, new Map(Object.entries(asObject)))
|
||||
}
|
||||
|
||||
return sectionsBySlug.get(key)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Github } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { cn } from 'ui'
|
||||
|
||||
import MenuIconPicker from '~/components/Navigation/NavigationMenu/MenuIconPicker'
|
||||
|
||||
interface ClientLibHeaderProps {
|
||||
menuData: {
|
||||
title: string
|
||||
icon?: string
|
||||
pkg: {
|
||||
name: string
|
||||
repo: string
|
||||
}
|
||||
}
|
||||
className?: string
|
||||
}
|
||||
|
||||
function ClientLibHeader({ menuData, className }: ClientLibHeaderProps) {
|
||||
return (
|
||||
<div className={cn('flex items-start gap-6', className)}>
|
||||
{'icon' in menuData && (
|
||||
<MenuIconPicker
|
||||
icon={menuData.icon}
|
||||
width={35}
|
||||
height={35}
|
||||
className="text-foreground-light"
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 id="introduction" className="text-3xl text-foreground">
|
||||
{menuData.title} Client Library
|
||||
</h1>
|
||||
<span
|
||||
className={cn('text-base font-mono text-foreground-light', 'flex items-center gap-2')}
|
||||
>
|
||||
{menuData.pkg.name}
|
||||
<Link
|
||||
href={menuData.pkg.repo}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="hover:text-brand focus-visible:text-brand transition-colors"
|
||||
>
|
||||
<span className="sr-only">View on GitHub</span>
|
||||
<Github size={18} />
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { ClientLibHeader }
|
||||
@@ -0,0 +1,67 @@
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, cn } from 'ui'
|
||||
|
||||
import { MDXRemoteBase } from '~/features/docs/MdxBase'
|
||||
import { getRefMarkdown } from '~/features/docs/Reference.mdx'
|
||||
import { ReferenceSectionWrapper } from '~/features/docs/Reference.ui.client'
|
||||
import commonClientLibSections from '~/spec/common-client-libs-sections.json' assert { type: 'json' }
|
||||
|
||||
function hasIntro(sections: typeof commonClientLibSections, excludeName: string) {
|
||||
return Boolean(
|
||||
sections[0]?.type === 'markdown' &&
|
||||
sections[0]?.slug === 'introduction' &&
|
||||
!(
|
||||
'excludes' in sections[0] &&
|
||||
Array.isArray(sections[0].excludes) &&
|
||||
sections[0].excludes?.includes(excludeName)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
interface ClientLibIntroductionProps {
|
||||
libPath: string
|
||||
excludeName: string
|
||||
version: string
|
||||
isLatestVersion: boolean
|
||||
}
|
||||
|
||||
export async function ClientLibIntroduction({
|
||||
libPath,
|
||||
excludeName,
|
||||
version,
|
||||
isLatestVersion,
|
||||
}: ClientLibIntroductionProps) {
|
||||
if (!hasIntro(commonClientLibSections, excludeName)) return null
|
||||
|
||||
const content = await getRefMarkdown(
|
||||
`${libPath}/${isLatestVersion ? '' : `${version}/`}introduction`
|
||||
)
|
||||
|
||||
return (
|
||||
<ReferenceSectionWrapper
|
||||
id="introduction"
|
||||
link={`/docs/reference/${libPath}/${isLatestVersion ? '' : `${version}/`}introduction`}
|
||||
className="prose"
|
||||
>
|
||||
<MDXRemoteBase source={content} />
|
||||
</ReferenceSectionWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export function OldVersionAlert({ libPath, className }: { libPath: string; className?: string }) {
|
||||
return (
|
||||
<Alert_Shadcn_ variant="warning" className={cn('not-prose', className)}>
|
||||
<AlertTriangle />
|
||||
<AlertTitle_Shadcn_ className="font-medium">Version out of date</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
There's a newer version of this library! Migrate to the{' '}
|
||||
<Link href={`/reference/${libPath}`} className="underline underline-offset-2">
|
||||
newest version
|
||||
</Link>
|
||||
.
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* The MDXProvider is necessary so that MDX partials will have access
|
||||
* to components.
|
||||
*
|
||||
* Since the reference pages are so heavy, keep the weight down by only
|
||||
* including the bare minimum.
|
||||
*/
|
||||
|
||||
import { MDXProvider } from '@mdx-js/react'
|
||||
import { type PropsWithChildren } from 'react'
|
||||
|
||||
import { Admonition } from 'ui'
|
||||
|
||||
const components = { Admonition }
|
||||
|
||||
export function MDXProviderReference({ children }: PropsWithChildren) {
|
||||
return <MDXProvider components={components}>{children}</MDXProvider>
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import matter from 'gray-matter'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { MDXRemoteBase } from '~/features/docs/MdxBase'
|
||||
import { components } from '~/features/docs/MdxBase.shared'
|
||||
import { RefSubLayout } from '~/features/docs/Reference.ui'
|
||||
import { cache_fullProcess_withDevCacheBust } from '~/features/helpers.fs'
|
||||
import { REF_DOCS_DIRECTORY } from '~/lib/docs'
|
||||
|
||||
async function getRefMarkdownInternal(relPath: string) {
|
||||
const fullPath = join(REF_DOCS_DIRECTORY, relPath + '.mdx')
|
||||
|
||||
try {
|
||||
if (!fullPath.startsWith(REF_DOCS_DIRECTORY)) {
|
||||
throw Error(`Accessing forbidden path outside of REF_DOCS_DIRECTORY: ${fullPath}`)
|
||||
}
|
||||
|
||||
const mdx = await readFile(fullPath, 'utf-8')
|
||||
const { content } = matter(mdx)
|
||||
return content
|
||||
} catch (err) {
|
||||
console.error(`Error fetching reference markdown from file: ${fullPath}:\n\n${err}`)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Caching this for the entire process is fine because the Markdown content is
|
||||
* baked into each deployment and cannot change. There's also nothing sensitive
|
||||
* here: this is just reading the public MDX files from the codebase.
|
||||
*/
|
||||
const getRefMarkdown = cache_fullProcess_withDevCacheBust(
|
||||
getRefMarkdownInternal,
|
||||
REF_DOCS_DIRECTORY,
|
||||
(filename: string) => JSON.stringify([filename.replace(/\.mdx$/, '')])
|
||||
)
|
||||
|
||||
interface MDXRemoteRefsProps {
|
||||
source: string
|
||||
}
|
||||
|
||||
function MDXRemoteRefs({ source }: MDXRemoteRefsProps) {
|
||||
const refComponents = { ...components, RefSubLayout }
|
||||
|
||||
return <MDXRemoteBase source={source} components={refComponents} />
|
||||
}
|
||||
|
||||
export { getRefMarkdown, MDXRemoteRefs }
|
||||
@@ -0,0 +1,240 @@
|
||||
'use client'
|
||||
|
||||
import * as Collapsible from '@radix-ui/react-collapsible'
|
||||
import { debounce } from 'lodash'
|
||||
import { ChevronUp } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import type { HTMLAttributes, MouseEvent, PropsWithChildren } from 'react'
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { cn } from 'ui'
|
||||
|
||||
import { BASE_PATH } from '~/lib/constants'
|
||||
import type { AbbrevCommonClientLibSection } from '~/features/docs/Reference.navigation'
|
||||
import { isElementInViewport } from '~/features/ui/helpers.dom'
|
||||
|
||||
export const ReferenceContentInitiallyScrolledContext = createContext<boolean>(false)
|
||||
|
||||
export function ReferenceContentScrollHandler({
|
||||
libPath,
|
||||
version,
|
||||
isLatestVersion,
|
||||
children,
|
||||
}: PropsWithChildren<{
|
||||
libPath: string
|
||||
version: string
|
||||
isLatestVersion: boolean
|
||||
}>) {
|
||||
const checkedPathnameOnLoad = useRef(false)
|
||||
const [initiallyScrolled, setInitiallyScrolled] = useState(false)
|
||||
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
if (!checkedPathnameOnLoad.current) {
|
||||
const initialSelectedSection = pathname.replace(
|
||||
`/reference/${libPath}/${isLatestVersion ? '' : `${version}/`}`,
|
||||
''
|
||||
)
|
||||
if (initialSelectedSection) {
|
||||
const section = document.getElementById(initialSelectedSection)
|
||||
section?.scrollIntoView()
|
||||
section?.querySelector('h2')?.focus()
|
||||
}
|
||||
|
||||
checkedPathnameOnLoad.current = true
|
||||
setInitiallyScrolled(true)
|
||||
}
|
||||
}, [pathname, libPath, version, isLatestVersion])
|
||||
|
||||
return (
|
||||
<ReferenceContentInitiallyScrolledContext.Provider value={initiallyScrolled}>
|
||||
{children}
|
||||
</ReferenceContentInitiallyScrolledContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function ReferenceNavigationScrollHandler({
|
||||
children,
|
||||
...rest
|
||||
}: PropsWithChildren & HTMLAttributes<HTMLDivElement>) {
|
||||
const ref = useRef<HTMLDivElement>()
|
||||
const initialScrollHappened = useContext(ReferenceContentInitiallyScrolledContext)
|
||||
|
||||
const scrollActiveIntoView = useCallback(() => {
|
||||
const currentLink = ref.current?.querySelector('[aria-current=page]') as HTMLElement
|
||||
if (currentLink && !isElementInViewport(currentLink)) {
|
||||
currentLink.scrollIntoView({
|
||||
block: 'center',
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (initialScrollHappened) {
|
||||
scrollActiveIntoView()
|
||||
}
|
||||
}, [initialScrollHappened, scrollActiveIntoView])
|
||||
|
||||
useEffect(() => {
|
||||
const debouncedScrollActiveIntoView = debounce(scrollActiveIntoView, 150)
|
||||
|
||||
window.addEventListener('scrollend', debouncedScrollActiveIntoView)
|
||||
return () => window.removeEventListener('scrollend', debouncedScrollActiveIntoView)
|
||||
}, [scrollActiveIntoView])
|
||||
|
||||
return (
|
||||
<div ref={ref} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function deriveHref(basePath: string, section: AbbrevCommonClientLibSection) {
|
||||
return 'slug' in section ? `${basePath}/${section.slug}` : ''
|
||||
}
|
||||
|
||||
function getLinkStyles(isActive: boolean, className?: string) {
|
||||
return cn(
|
||||
'text-sm text-foreground-lighter',
|
||||
!isActive && 'hover:text-foreground',
|
||||
isActive && 'text-brand',
|
||||
'transition-colors',
|
||||
className
|
||||
)
|
||||
}
|
||||
|
||||
export function RefLink({
|
||||
basePath,
|
||||
section,
|
||||
skipChildren = false,
|
||||
className,
|
||||
}: {
|
||||
basePath: string
|
||||
section: AbbrevCommonClientLibSection
|
||||
skipChildren?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const ref = useRef<HTMLAnchorElement>()
|
||||
|
||||
const pathname = usePathname()
|
||||
const href = deriveHref(basePath, section)
|
||||
const isActive =
|
||||
pathname === href || (pathname === basePath && href.replace(basePath, '') === '/introduction')
|
||||
|
||||
if (!('title' in section)) return null
|
||||
|
||||
const isCompoundSection = !skipChildren && 'items' in section && section.items.length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
{isCompoundSection ? (
|
||||
<CompoundRefLink basePath={basePath} section={section} />
|
||||
) : (
|
||||
<Link
|
||||
ref={ref}
|
||||
href={href}
|
||||
aria-current={isActive ? 'page' : false}
|
||||
className={getLinkStyles(isActive, className)}
|
||||
onClick={(evt: MouseEvent) => {
|
||||
/*
|
||||
* We don't actually want to navigate or rerender anything since
|
||||
* links are all to sections on the same page.
|
||||
*/
|
||||
evt.preventDefault()
|
||||
history.pushState({}, '', `${BASE_PATH}${href}`)
|
||||
|
||||
if ('slug' in section) {
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
const domElement = document.getElementById(section.slug)
|
||||
domElement?.scrollIntoView({
|
||||
behavior: reduceMotion ? 'auto' : 'smooth',
|
||||
})
|
||||
domElement?.querySelector('h2')?.focus()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{section.title}
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function useCompoundRefLinkActive(basePath: string, section: AbbrevCommonClientLibSection) {
|
||||
const [open, _setOpen] = useState(false)
|
||||
|
||||
const pathname = usePathname()
|
||||
const parentHref = deriveHref(basePath, section)
|
||||
const isParentActive = pathname === parentHref
|
||||
|
||||
const childHrefs = useMemo(
|
||||
() => new Set(section.items.map((item) => deriveHref(basePath, item))),
|
||||
[basePath, section]
|
||||
)
|
||||
const isChildActive = childHrefs.has(pathname)
|
||||
|
||||
const isActive = isParentActive || isChildActive
|
||||
|
||||
const setOpen = (open: boolean) => {
|
||||
// Disable closing if the section is active, to prevent the currently active
|
||||
// link disappearing
|
||||
if (open || !isActive) _setOpen(open)
|
||||
}
|
||||
|
||||
if (isActive && !open) {
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
return { open, setOpen, isActive }
|
||||
}
|
||||
|
||||
function CompoundRefLink({
|
||||
basePath,
|
||||
section,
|
||||
}: {
|
||||
basePath: string
|
||||
section: AbbrevCommonClientLibSection
|
||||
}) {
|
||||
const { open, setOpen, isActive } = useCompoundRefLinkActive(basePath, section)
|
||||
|
||||
return (
|
||||
<Collapsible.Root open={open} onOpenChange={setOpen}>
|
||||
<Collapsible.Trigger asChild disabled={isActive}>
|
||||
<button
|
||||
className={cn(
|
||||
'group',
|
||||
'cursor-pointer',
|
||||
'w-full',
|
||||
'flex items-center justify-between gap-2'
|
||||
)}
|
||||
>
|
||||
<span className={getLinkStyles(false)}>{section.title}</span>
|
||||
<ChevronUp
|
||||
width={16}
|
||||
className={cn(
|
||||
'group-disabled:cursor-not-allowed group-disabled:opacity-10',
|
||||
'data-open-parent:rotate-0 data-closed-parent:rotate-90',
|
||||
'transition'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content
|
||||
className={cn('border-l border-control pl-3 ml-1 data-open:mt-2 grid gap-2.5')}
|
||||
>
|
||||
<ul className="space-y-2">
|
||||
<RefLink basePath={basePath} section={section} skipChildren />
|
||||
{section.items.map((item, idx) => {
|
||||
return (
|
||||
<li key={`${section.id}-${idx}`}>
|
||||
<RefLink basePath={basePath} section={item} />
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Fragment, type PropsWithChildren } from 'react'
|
||||
|
||||
import { cn } from 'ui'
|
||||
|
||||
import MenuIconPicker from '~/components/Navigation/NavigationMenu/MenuIconPicker'
|
||||
import RefVersionDropdown from '~/components/RefVersionDropdown'
|
||||
import { getReferenceSections } from '~/features/docs/Reference.generated.singleton'
|
||||
import {
|
||||
RefLink,
|
||||
ReferenceNavigationScrollHandler,
|
||||
} from '~/features/docs/Reference.navigation.client'
|
||||
import { type AbbrevCommonClientLibSection } from '~/features/docs/Reference.utils'
|
||||
|
||||
interface ClientSdkNavigationProps {
|
||||
sdkId: string
|
||||
name: string
|
||||
menuData: { icon?: string }
|
||||
libPath: string
|
||||
version: string
|
||||
isLatestVersion: boolean
|
||||
}
|
||||
|
||||
async function ClientSdkNavigation({
|
||||
sdkId,
|
||||
name,
|
||||
menuData,
|
||||
libPath,
|
||||
version,
|
||||
isLatestVersion,
|
||||
}: ClientSdkNavigationProps) {
|
||||
const navSections = await getReferenceSections(sdkId, version)
|
||||
|
||||
const basePath = `/reference/${libPath}${isLatestVersion ? '' : `/${version}`}`
|
||||
|
||||
return (
|
||||
<ReferenceNavigationScrollHandler className="w-full flex flex-col pt-3 pb-5 gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{'icon' in menuData && <MenuIconPicker icon={menuData.icon} width={21} height={21} />}
|
||||
<span className="text-base text-brand-600">{name}</span>
|
||||
<RefVersionDropdown library={libPath} currentVersion={version} />
|
||||
</div>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{navSections.map((section) => (
|
||||
<Fragment key={section.id}>
|
||||
{section.type === 'category' ? (
|
||||
<li>
|
||||
<RefCategory basePath={basePath} section={section} />
|
||||
</li>
|
||||
) : (
|
||||
<li className={topLvlRefNavItemStyles}>
|
||||
<RefLink basePath={basePath} section={section} />
|
||||
</li>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</ul>
|
||||
</ReferenceNavigationScrollHandler>
|
||||
)
|
||||
}
|
||||
|
||||
const topLvlRefNavItemStyles = 'leading-5'
|
||||
|
||||
function RefCategory({
|
||||
basePath,
|
||||
section,
|
||||
}: {
|
||||
basePath: string
|
||||
section: AbbrevCommonClientLibSection
|
||||
}) {
|
||||
if (!('items' in section && section.items.length > 0)) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider />
|
||||
{'title' in section && <SideMenuTitle className="py-2">{section.title}</SideMenuTitle>}
|
||||
<ul className="space-y-2">
|
||||
{section.items.map((item) => (
|
||||
<li key={item.id} className={topLvlRefNavItemStyles}>
|
||||
<RefLink basePath={basePath} section={item} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return <hr className="w-full h-px my-3 bg-control" />
|
||||
}
|
||||
|
||||
function SideMenuTitle({ children, className }: PropsWithChildren<{ className?: string }>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'font-mono font-medium text-xs text-foreground tracking-wider uppercase',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { ClientSdkNavigation }
|
||||
export type { AbbrevCommonClientLibSection }
|
||||
@@ -0,0 +1,64 @@
|
||||
import { MenuId } from '~/components/Navigation/NavigationMenu/NavigationMenu'
|
||||
import * as NavItems from '~/components/Navigation/NavigationMenu/NavigationMenu.constants'
|
||||
import { REFERENCES } from '~/content/navigation.references'
|
||||
import { ClientLibHeader } from '~/features/docs/Reference.header'
|
||||
import { ClientLibIntroduction, OldVersionAlert } from '~/features/docs/Reference.introduction'
|
||||
import { ClientSdkNavigation } from '~/features/docs/Reference.navigation'
|
||||
import { ReferenceContentScrollHandler } from '~/features/docs/Reference.navigation.client'
|
||||
import { ClientLibRefSections } from '~/features/docs/Reference.sections'
|
||||
import { LayoutMainContent } from '~/layouts/DefaultLayout'
|
||||
import { SidebarSkeleton } from '~/layouts/MainSkeleton'
|
||||
|
||||
interface ClientSdkReferenceProps {
|
||||
sdkId: string
|
||||
libVersion: string
|
||||
}
|
||||
|
||||
export async function ClientSdkReferencePage({ sdkId, libVersion }: ClientSdkReferenceProps) {
|
||||
const libraryMeta = REFERENCES[sdkId]
|
||||
const versions = libraryMeta?.versions ?? []
|
||||
const isLatestVersion = libVersion === versions[0]
|
||||
|
||||
const menuData = NavItems[libraryMeta.meta[libVersion].libId]
|
||||
|
||||
return (
|
||||
<ReferenceContentScrollHandler
|
||||
libPath={libraryMeta.libPath}
|
||||
version={libVersion}
|
||||
isLatestVersion={isLatestVersion}
|
||||
>
|
||||
<SidebarSkeleton
|
||||
menuId={MenuId.RefJavaScriptV2}
|
||||
NavigationMenu={
|
||||
<ClientSdkNavigation
|
||||
sdkId={sdkId}
|
||||
name={menuData.title}
|
||||
menuData={menuData}
|
||||
libPath={libraryMeta.libPath}
|
||||
version={libVersion}
|
||||
isLatestVersion={isLatestVersion}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<LayoutMainContent>
|
||||
{!isLatestVersion && (
|
||||
<OldVersionAlert
|
||||
libPath={libraryMeta.libPath}
|
||||
className="z-10 fixed top-[calc(var(--header-height)+1rem)] right-4 w-84 max-w-[calc(100vw-2rem)]"
|
||||
/>
|
||||
)}
|
||||
<article className="@container/article">
|
||||
<ClientLibHeader menuData={menuData} className="mt-4 mb-8" />
|
||||
<ClientLibIntroduction
|
||||
libPath={libraryMeta.libPath}
|
||||
excludeName={libraryMeta.meta[libVersion].libId}
|
||||
version={libVersion}
|
||||
isLatestVersion={isLatestVersion}
|
||||
/>
|
||||
<ClientLibRefSections sdkId={sdkId} version={libVersion} />
|
||||
</article>
|
||||
</LayoutMainContent>
|
||||
</SidebarSkeleton>
|
||||
</ReferenceContentScrollHandler>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { Fragment } from 'react'
|
||||
|
||||
import { Tabs_Shadcn_, TabsContent_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_, cn } from 'ui'
|
||||
|
||||
import { REFERENCES } from '~/content/navigation.references'
|
||||
import { MDXRemoteRefs, getRefMarkdown } from '~/features/docs/Reference.mdx'
|
||||
import { MDXProviderReference } from '~/features/docs/Reference.mdx.client'
|
||||
import type { MethodTypes } from '~/features/docs/Reference.typeSpec'
|
||||
import {
|
||||
getFlattenedSections,
|
||||
getFunctionsList,
|
||||
getTypeSpec,
|
||||
} from '~/features/docs/Reference.generated.singleton'
|
||||
import {
|
||||
CollapsibleDetails,
|
||||
FnParameterDetails,
|
||||
RefSubLayout,
|
||||
ReturnTypeDetails,
|
||||
StickyHeader,
|
||||
} from '~/features/docs/Reference.ui'
|
||||
import type { AbbrevCommonClientLibSection } from '~/features/docs/Reference.utils'
|
||||
import { normalizeMarkdown } from '~/features/docs/Reference.utils'
|
||||
|
||||
type ClientLibRefSectionsProps = {
|
||||
sdkId: string
|
||||
version: string
|
||||
}
|
||||
|
||||
async function ClientLibRefSections({ sdkId, version }: ClientLibRefSectionsProps) {
|
||||
let flattenedSections = await getFlattenedSections(sdkId, version)
|
||||
flattenedSections = trimIntro(flattenedSections)
|
||||
|
||||
return (
|
||||
<MDXProviderReference>
|
||||
<div className="flex flex-col my-16 gap-16">
|
||||
{flattenedSections
|
||||
.filter((section) => section.type !== 'category')
|
||||
.map((section, idx) => (
|
||||
<Fragment key={`${section.id}-${idx}`}>
|
||||
<SectionDivider />
|
||||
<SectionSwitch sdkId={sdkId} version={version} section={section} />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</MDXProviderReference>
|
||||
)
|
||||
}
|
||||
|
||||
function trimIntro(sections: Array<AbbrevCommonClientLibSection>) {
|
||||
const hasIntro = sections[0]?.type === 'markdown' && sections[0]?.slug === 'introduction'
|
||||
if (hasIntro) {
|
||||
return sections.slice(1)
|
||||
}
|
||||
return sections
|
||||
}
|
||||
|
||||
function SectionDivider() {
|
||||
return <hr />
|
||||
}
|
||||
|
||||
type SectionSwitchProps = {
|
||||
sdkId: string
|
||||
version: string
|
||||
section: AbbrevCommonClientLibSection
|
||||
}
|
||||
|
||||
export function SectionSwitch({ sdkId, version, section }: SectionSwitchProps) {
|
||||
const libPath = REFERENCES[sdkId].libPath
|
||||
const isLatestVersion = version === REFERENCES[sdkId].versions[0]
|
||||
|
||||
const sectionLink = `/docs/reference/${libPath}/${isLatestVersion ? '' : `${version}/`}${section.slug}`
|
||||
|
||||
switch (section.type) {
|
||||
case 'markdown':
|
||||
return (
|
||||
<MarkdownSection
|
||||
libPath={libPath}
|
||||
version={version}
|
||||
isLatestVersion={isLatestVersion}
|
||||
link={sectionLink}
|
||||
section={section}
|
||||
/>
|
||||
)
|
||||
case 'function':
|
||||
return (
|
||||
<FunctionSection
|
||||
sdkId={sdkId}
|
||||
version={version}
|
||||
link={sectionLink}
|
||||
section={section}
|
||||
useTypeSpec={REFERENCES[sdkId].typeSpec}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
console.error(`Unhandled type in reference sections: ${section.type}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
interface MarkdownSectionProps {
|
||||
libPath: string
|
||||
version: string
|
||||
isLatestVersion: boolean
|
||||
link: string
|
||||
section: AbbrevCommonClientLibSection
|
||||
}
|
||||
|
||||
async function MarkdownSection({
|
||||
libPath,
|
||||
version,
|
||||
isLatestVersion,
|
||||
link,
|
||||
section,
|
||||
}: MarkdownSectionProps) {
|
||||
const content = await getRefMarkdown(
|
||||
section.meta?.shared
|
||||
? `shared/${section.id}`
|
||||
: `${libPath}/${isLatestVersion ? '' : `${version}/`}${section.id}`
|
||||
)
|
||||
|
||||
return (
|
||||
<RefSubLayout.EducationSection link={link} {...section}>
|
||||
<StickyHeader {...section} />
|
||||
<MDXRemoteRefs source={content} />
|
||||
</RefSubLayout.EducationSection>
|
||||
)
|
||||
}
|
||||
|
||||
interface FunctionSectionProps {
|
||||
sdkId: string
|
||||
version: string
|
||||
link: string
|
||||
section: AbbrevCommonClientLibSection
|
||||
useTypeSpec: boolean
|
||||
}
|
||||
|
||||
async function FunctionSection({
|
||||
sdkId,
|
||||
version,
|
||||
link,
|
||||
section,
|
||||
useTypeSpec,
|
||||
}: FunctionSectionProps) {
|
||||
const fns = await getFunctionsList(sdkId, version)
|
||||
|
||||
const fn = fns.find((fn) => fn.id === section.id)
|
||||
if (!fn) return null
|
||||
|
||||
let types: MethodTypes | undefined
|
||||
if (useTypeSpec && '$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(Boolean)
|
||||
.map(normalizeMarkdown)
|
||||
.join('\n\n')
|
||||
|
||||
return (
|
||||
<RefSubLayout.Section columns="double" link={link} {...section}>
|
||||
<StickyHeader {...section} className="col-[1_/_-1]" />
|
||||
<div className="overflow-hidden flex flex-col gap-8">
|
||||
<div className="prose break-words text-sm">
|
||||
<MDXRemoteRefs source={fullDescription} />
|
||||
</div>
|
||||
<FnParameterDetails
|
||||
parameters={
|
||||
'overwriteParams' in fn
|
||||
? (fn.overwriteParams as Array<object>).map((overwrittenParams) => ({
|
||||
...overwrittenParams,
|
||||
__overwritten: true,
|
||||
}))
|
||||
: 'params' in fn
|
||||
? (fn.params as Array<object>).map((param) => ({ ...param, __overwritten: true }))
|
||||
: types?.params
|
||||
}
|
||||
altParameters={types?.altSignatures?.map(({ params }) => params)}
|
||||
className="max-w-[80ch]"
|
||||
/>
|
||||
{!!types?.ret && <ReturnTypeDetails returnType={types.ret} />}
|
||||
</div>
|
||||
<div className="overflow-auto">
|
||||
{'examples' in fn && Array.isArray(fn.examples) && fn.examples.length > 0 && (
|
||||
<Tabs_Shadcn_ defaultValue={fn.examples[0].id}>
|
||||
<TabsList_Shadcn_ className="flex-wrap gap-2 border-0">
|
||||
{fn.examples.map((example) => (
|
||||
<TabsTrigger_Shadcn_
|
||||
key={example.id}
|
||||
value={example.id}
|
||||
className={cn(
|
||||
'px-2.5 py-1 rounded-full',
|
||||
'border-0 bg-surface-200 hover:bg-surface-300',
|
||||
'text-xs text-foreground-lighter',
|
||||
// Undoing styles from primitive component
|
||||
'data-[state=active]:border-0 data-[state=active]:shadow-0',
|
||||
'data-[state=active]:bg-foreground data-[state=active]:text-background',
|
||||
'transition'
|
||||
)}
|
||||
>
|
||||
{example.name}
|
||||
</TabsTrigger_Shadcn_>
|
||||
))}
|
||||
</TabsList_Shadcn_>
|
||||
{'examples' in fn &&
|
||||
Array.isArray(fn.examples) &&
|
||||
fn.examples.map((example) => (
|
||||
<TabsContent_Shadcn_ key={example.id} value={example.id}>
|
||||
<MDXRemoteRefs source={example.code} />
|
||||
<div className="flex flex-col gap-2">
|
||||
{!!example.data?.sql && (
|
||||
<CollapsibleDetails title="Data source" content={example.data.sql} />
|
||||
)}
|
||||
{!!example.response && (
|
||||
<CollapsibleDetails title="Response" content={example.response} />
|
||||
)}
|
||||
{!!example.description && (
|
||||
<CollapsibleDetails
|
||||
title="Notes"
|
||||
content={normalizeMarkdown(example.description)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent_Shadcn_>
|
||||
))}
|
||||
</Tabs_Shadcn_>
|
||||
)}
|
||||
</div>
|
||||
</RefSubLayout.Section>
|
||||
)
|
||||
}
|
||||
|
||||
export { ClientLibRefSections }
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { __parseTypeSpec } from './Reference.typeSpec'
|
||||
import { parseTypeSpec } from './Reference.typeSpec'
|
||||
|
||||
describe('TS type spec parsing', () => {
|
||||
it('matches snapshot', async () => {
|
||||
const parsed = await __parseTypeSpec()
|
||||
const parsed = await parseTypeSpec()
|
||||
const json = JSON.stringify(
|
||||
parsed,
|
||||
(key, value) => {
|
||||
|
||||
Loaded 100 of 557 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user