mirror of
https://github.com/supabase/supabase.git
synced 2026-10-13 05:09:12 +03:00
Merge branch 'master' into feature/user-chart
This commit is contained in:
449 files changed
+8078
-3406
No files matched your search
@@ -0,0 +1,253 @@
|
||||
---
|
||||
description: How to generate pages and interfaces in Studio, a web interface for managing Supabase projects
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
- Next.js app using pages router
|
||||
- Pages go in @apps/studio/pages
|
||||
- Project related pages go in @apps/studio/pages/projects/[ref]
|
||||
- Organization related pages go in @apps/studio/pages/org/[slug]
|
||||
- Studio specific components go in @apps/studio/components
|
||||
- Studio specific generic UI components go in @apps/studio/components/ui
|
||||
- Studio specific components related to individual pages go in @apps/studio/components/interfaces e.g. @apps/studio/components/interfaces/Auth
|
||||
- Generic helper functions go in @apps/studio/lib
|
||||
- Generic hooks go in @apps/studio/hooks
|
||||
|
||||
## Component system
|
||||
|
||||
Our primitive component system is in @packages/ui and is based off shadcn/ui components. These components can be shared across all @apps e.g. studio and docs. Do not introduce new ui components unless asked to.
|
||||
|
||||
- UI components are imported from this package across apps e.g. import { Button, Badge } from 'ui'
|
||||
- Some components have a _Shadcn_ namespace appended to component name e.g. import { Input*Shadcn* } from 'ui'
|
||||
- We should be using _Shadcn_ components where possible
|
||||
- Before composing interfaces, read @packages/ui/index.tsx file for a full list of available components
|
||||
|
||||
## Styling
|
||||
|
||||
We use Tailwind for styling.
|
||||
|
||||
- You should never use tailwind classes for colours and instead use classes we've defined ourselves
|
||||
- Backgrounds // most of the time you will not need to define a background
|
||||
- 'bg' used for main app surface background
|
||||
- 'bg-muted' for elevating content // you can use Card instead
|
||||
- 'bg-warning' for highlighting information that needs to be acted on
|
||||
- 'bg-destructive' for highlighting issues
|
||||
- Text
|
||||
- 'text-foreground' for primary text like headings
|
||||
- 'text-foreground-light' for body text
|
||||
- 'text-foreground-lighter' for subtle text
|
||||
- 'text-warning' for calling out information that needs action
|
||||
- 'text-destructive' for calling out when something went wrong
|
||||
- When needing to apply typography styles, read @apps/studio/styles/typography.scss and use one of the available classes instead of hard coding classes e.g. use "heading-default" instead of "text-sm font-medium"
|
||||
|
||||
## Page structure
|
||||
|
||||
When creating a new page follow these steps:
|
||||
|
||||
- Create the page in @apps/studio/pages
|
||||
- Use the PageLayout component that has the following props
|
||||
|
||||
```jsx
|
||||
export interface NavigationItem {
|
||||
id?: string
|
||||
label: string
|
||||
href?: string
|
||||
icon?: ReactNode
|
||||
onClick?: () => void
|
||||
badge?: string
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
interface PageLayoutProps {
|
||||
children?: ReactNode
|
||||
title?: string | ReactNode
|
||||
subtitle?: string | ReactNode
|
||||
icon?: ReactNode
|
||||
breadcrumbs?: Array<{
|
||||
label?: string
|
||||
href?: string
|
||||
element?: ReactNode
|
||||
}>
|
||||
primaryActions?: ReactNode
|
||||
secondaryActions?: ReactNode
|
||||
navigationItems?: NavigationItem[]
|
||||
className?: string
|
||||
size?: 'default' | 'full' | 'large' | 'small'
|
||||
isCompact?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
- If a page has page related actions, add them to primary and secondary action props e.g. Users page has "Create new user" action
|
||||
- If a page is within an existing section (e.g. Auth), you should use the related layout component e.g. AuthLayout
|
||||
- Create a new component in @apps/studio/components/interfaces for the contents of the page
|
||||
- Use ScaffoldContainer if the page should be center aligned in a container
|
||||
- Use ScaffoldSection, ScaffoldSectionTitle, ScaffoldSectionDescription if the page has multiple sections
|
||||
|
||||
### Page example
|
||||
|
||||
```jsx
|
||||
import { MyPageComponent } from 'components/interfaces/MyPage/MyPageComponent'
|
||||
import AuthLayout from './AuthLayout'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
import { ScaffoldContainer } from 'components/layouts/Scaffold'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
|
||||
const MyPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<ScaffoldContainer>
|
||||
<MyPageComponent />
|
||||
</ScaffoldContainer>
|
||||
)
|
||||
}
|
||||
|
||||
MyPage.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<AuthLayout>{page}</AuthLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
export default MyPage
|
||||
|
||||
export const MyPageComponent = () => (
|
||||
<ScaffoldSection isFullWidth>
|
||||
<div>
|
||||
<ScaffoldSectionTitle>My page section</ScaffoldSectionTitle>
|
||||
<ScaffoldSectionDescription>A brief description of the purpose of the page</ScaffoldSectionDescription>
|
||||
</div>
|
||||
// Content goes here
|
||||
</ScaffoldSection>
|
||||
)
|
||||
```
|
||||
|
||||
## Forms
|
||||
|
||||
- Build forms with `react-hook-form` + `zod`.
|
||||
- Use our `_Shadcn_` form primitives from `ui` and prefer `FormItemLayout` with layout="flex-row-reverse" for most controls (see `apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx`).
|
||||
- Keep imports from `ui` with `_Shadcn_` suffixes.
|
||||
- Forms should generally be wrapped in a Card unless specified
|
||||
|
||||
### Example (single field)
|
||||
|
||||
```tsx
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { Button, Form_Shadcn_, FormField_Shadcn_, FormControl_Shadcn_, Input_Shadcn_ } from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
const profileSchema = z.object({
|
||||
username: z.string().min(2, 'Username must be at least 2 characters'),
|
||||
})
|
||||
|
||||
export function ProfileForm() {
|
||||
const form = useForm<z.infer<typeof profileSchema>>({
|
||||
resolver: zodResolver(profileSchema),
|
||||
defaultValues: { username: '' },
|
||||
mode: 'onSubmit',
|
||||
reValidateMode: 'onBlur',
|
||||
})
|
||||
|
||||
function onSubmit(values: z.infer<typeof profileSchema>) {
|
||||
// handle values
|
||||
}
|
||||
|
||||
return (
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<Card>
|
||||
<CardContent className="space-y-6">
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Username"
|
||||
description="This is your public display name."
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ placeholder="shadcn" autoComplete="off" {...field} />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end">
|
||||
<Button type="primary" htmlType="submit">
|
||||
Submit
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Cards
|
||||
|
||||
- Use cards when needing to group related pieces of information
|
||||
- Cards can have sections with CardContent
|
||||
- Use CardFooter for actions
|
||||
- Only use CardHeader and CardTitle if the card content has not been described by the surrounding content e.g. Page title or ScaffoldSectionTitle
|
||||
- Use CardHeader and CardTitle when you are using multiple Cards to group related pieces of content e.g. Primary branch, Persistent branches, Preview branches
|
||||
|
||||
## Sheets
|
||||
|
||||
- Use a sheet when needing to reveal more complicated forms or information relating to an object and context switching away to a new page would be disruptive e.g. we list auth providers, clicking an auth provider opens a sheet with information about that provider and a form to enable, user can close sheet to go back to providers list
|
||||
|
||||
## Tables
|
||||
|
||||
- Use the generic ui table components for most tables
|
||||
- Tables are generally contained witin a card
|
||||
- If a table has associated actions, they should go above on right hand side
|
||||
- If a table has associated search or filters, they should go above on left hand side
|
||||
- If a table is the main content of a page, and it does not have search or filters, you can add table actions to primary and secondary actions of PageLayout
|
||||
- If a table is the main content of a page section, and it does not have search or filters, you can add table actions to the right of ScaffoldSectionTitle
|
||||
- For simple lists of objects you can use ResourceList with ResourceListItem instead
|
||||
|
||||
### Table example
|
||||
|
||||
```jsx
|
||||
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from 'ui'
|
||||
|
||||
;<Table>
|
||||
<TableCaption>A list of your recent invoices.</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">Invoice</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Method</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">INV001</TableCell>
|
||||
<TableCell>Paid</TableCell>
|
||||
<TableCell>Credit Card</TableCell>
|
||||
<TableCell className="text-right">$250.00</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
```
|
||||
|
||||
## Alerts
|
||||
|
||||
- Use Admonition component to alert users of important actions or restrictions in place
|
||||
- Place the Admonition either at the top of the contents of the page (below page title) or at the top of the related ScaffoldSection , below ScaffoldTitle
|
||||
- Use sparingly
|
||||
|
||||
### Alert example
|
||||
|
||||
```jsx
|
||||
<Admonition
|
||||
type="note"
|
||||
title="No authentication logs available for this user"
|
||||
description="Auth events such as logging in will be shown here"
|
||||
/>
|
||||
```
|
||||
@@ -30,6 +30,7 @@ jobs:
|
||||
with:
|
||||
sparse-checkout: |
|
||||
apps/docs
|
||||
packages
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
|
||||
@@ -68,8 +68,12 @@ jobs:
|
||||
run: |
|
||||
pnpm run codegen:examples
|
||||
pnpm run embeddings
|
||||
pnpm run embeddings:nimbus
|
||||
|
||||
- name: Refresh embeddings
|
||||
working-directory: ./apps/docs
|
||||
if: ${{ inputs.refresh }}
|
||||
run: pnpm run embeddings:refresh
|
||||
run: |
|
||||
pnpm run codegen:examples
|
||||
pnpm run embeddings:refresh
|
||||
pnpm run embeddings:nimbus:refresh
|
||||
@@ -63,6 +63,9 @@ typings/
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Next.js declaration files (auto-generated)
|
||||
next-env.d.ts
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
|
||||
@@ -47,10 +47,6 @@ const nextConfig = {
|
||||
// We are already running linting via GH action, this will skip linting during production build on Vercel
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
experimental: {
|
||||
// Ensure compatibility with Turbopack and Sharp
|
||||
serverComponentsExternalPackages: ['sharp'],
|
||||
},
|
||||
// Configure Sharp as an external package for server-side rendering
|
||||
serverExternalPackages: ['sharp'],
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"lint": "cross-env NODE_OPTIONS=--no-deprecation next lint",
|
||||
"migrate": "cross-env NODE_OPTIONS=--no-deprecation tsx scripts/migrate.ts",
|
||||
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
|
||||
"start": "cross-env NODE_OPTIONS=--no-deprecation next start --port 3030",
|
||||
"start": "cross-env NODE_OPTIONS=--no-deprecation next start",
|
||||
"vercel-build": "pnpm migrate && pnpm build",
|
||||
"typecheck_IGNORED": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config'
|
||||
import '@payloadcms/next/css'
|
||||
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
|
||||
|
||||
export const GET = GRAPHQL_PLAYGROUND_GET(config)
|
||||
@@ -1,8 +0,0 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config'
|
||||
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes'
|
||||
|
||||
export const POST = GRAPHQL_POST(config)
|
||||
|
||||
export const OPTIONS = REST_OPTIONS(config)
|
||||
@@ -102,7 +102,7 @@ export default buildConfig({
|
||||
// Global configuration for better performance
|
||||
globals: [],
|
||||
graphQL: {
|
||||
disable: process.env.NODE_ENV !== 'development', // Disable GraphQL in production for better performance
|
||||
disable: true,
|
||||
},
|
||||
// Reduce payload init overhead
|
||||
telemetry: false,
|
||||
|
||||
@@ -3,6 +3,8 @@ import { ApplicationError, UserError, clippy } from 'ai-commands/edge'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import OpenAI from 'openai'
|
||||
|
||||
import { isFeatureEnabled } from 'common/enabled-features'
|
||||
|
||||
export const runtime = 'edge'
|
||||
/* To avoid OpenAI errors, restrict to the Vercel Edge Function regions that
|
||||
overlap with the OpenAI API regions.
|
||||
@@ -54,7 +56,10 @@ export async function POST(req: NextRequest) {
|
||||
throw new UserError('Missing messages in request data')
|
||||
}
|
||||
|
||||
const response = await clippy(openai, supabaseClient, messages)
|
||||
const useAltSearchIndex = !isFeatureEnabled('search:fullIndex')
|
||||
const response = await clippy(openai, supabaseClient, messages, {
|
||||
useAltSearchIndex,
|
||||
})
|
||||
|
||||
// Proxy the streamed SSE response from OpenAI
|
||||
return new NextResponse(response.body, {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { headers } from 'next/headers'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
|
||||
import { _handleRevalidateRequest } from './route'
|
||||
import { _handleRevalidateRequest } from './route.utils'
|
||||
|
||||
// Mock Next.js modules
|
||||
vi.mock('next/cache', () => ({
|
||||
|
||||
@@ -1,109 +1,9 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { type Database } from 'common'
|
||||
import { revalidateTag } from 'next/cache'
|
||||
import { headers } from 'next/headers'
|
||||
import { type NextRequest } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { VALID_REVALIDATION_TAGS } from '~/features/helpers.fetch'
|
||||
|
||||
enum AuthorizationLevel {
|
||||
Unauthorized,
|
||||
Basic,
|
||||
Override,
|
||||
}
|
||||
|
||||
const requestBodySchema = z.object({
|
||||
tags: z.array(z.enum(VALID_REVALIDATION_TAGS)),
|
||||
})
|
||||
import { _handleRevalidateRequest } from './route.utils'
|
||||
|
||||
export const POST = handleError(_handleRevalidateRequest)
|
||||
|
||||
export async function _handleRevalidateRequest(request: NextRequest) {
|
||||
const requestHeaders = await headers()
|
||||
const authorization = requestHeaders.get('Authorization')
|
||||
if (!authorization) {
|
||||
return new Response('Missing Authorization header', { status: 401 })
|
||||
}
|
||||
|
||||
const basicKeys = process.env.DOCS_REVALIDATION_KEYS?.split(/\s*,\s*/) ?? []
|
||||
const overrideKeys = process.env.DOCS_REVALIDATION_OVERRIDE_KEYS?.split(/\s*,\s*/) ?? []
|
||||
if (basicKeys.length === 0 && overrideKeys.length === 0) {
|
||||
console.error('No keys configured for revalidation')
|
||||
return new Response('Internal server error', {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
|
||||
let authorizationLevel = AuthorizationLevel.Unauthorized
|
||||
const token = authorization.replace(/^Bearer /, '')
|
||||
if (overrideKeys.includes(token)) {
|
||||
authorizationLevel = AuthorizationLevel.Override
|
||||
} else if (basicKeys.includes(token)) {
|
||||
authorizationLevel = AuthorizationLevel.Basic
|
||||
}
|
||||
if (authorizationLevel === AuthorizationLevel.Unauthorized) {
|
||||
return new Response('Invalid Authorization header', { status: 401 })
|
||||
}
|
||||
|
||||
let result: z.infer<typeof requestBodySchema>
|
||||
try {
|
||||
result = requestBodySchema.parse(await request.json())
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return new Response(
|
||||
'Malformed request body: should be a JSON object with a "tags" array of strings.',
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const supabaseAdmin = createClient<Database>(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SECRET_KEY!
|
||||
)
|
||||
|
||||
if (authorizationLevel === AuthorizationLevel.Basic) {
|
||||
const { data: lastRevalidation, error } = await supabaseAdmin.rpc(
|
||||
'get_last_revalidation_for_tags',
|
||||
{
|
||||
tags: result.tags,
|
||||
}
|
||||
)
|
||||
if (error) {
|
||||
console.error(error)
|
||||
return new Response('Internal server error', { status: 500 })
|
||||
}
|
||||
|
||||
const sixHoursAgo = new Date()
|
||||
sixHoursAgo.setHours(sixHoursAgo.getHours() - 6)
|
||||
if (lastRevalidation?.some((revalidation) => new Date(revalidation.created_at) > sixHoursAgo)) {
|
||||
return new Response(
|
||||
'Your request includes a tag that has been revalidated within the last 6 hours. You can override this limit by authenticating with Override permissions.',
|
||||
{
|
||||
status: 429,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const { error } = await supabaseAdmin
|
||||
.from('validation_history')
|
||||
.insert(result.tags.map((tag) => ({ tag })))
|
||||
if (error) {
|
||||
console.error('Failed to update revalidation table: %o', error)
|
||||
}
|
||||
|
||||
result.tags.forEach((tag) => {
|
||||
revalidateTag(tag)
|
||||
})
|
||||
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleError(handleRequest: (request: NextRequest) => Promise<Response>) {
|
||||
return async function (request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { type Database } from 'common'
|
||||
import { revalidateTag } from 'next/cache'
|
||||
import { headers } from 'next/headers'
|
||||
import { type NextRequest } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { VALID_REVALIDATION_TAGS } from '~/features/helpers.fetch'
|
||||
|
||||
enum AuthorizationLevel {
|
||||
Unauthorized,
|
||||
Basic,
|
||||
Override,
|
||||
}
|
||||
|
||||
const requestBodySchema = z.object({
|
||||
tags: z.array(z.enum(VALID_REVALIDATION_TAGS)),
|
||||
})
|
||||
|
||||
export async function _handleRevalidateRequest(request: NextRequest) {
|
||||
const requestHeaders = await headers()
|
||||
const authorization = requestHeaders.get('Authorization')
|
||||
if (!authorization) {
|
||||
return new Response('Missing Authorization header', { status: 401 })
|
||||
}
|
||||
|
||||
const basicKeys = process.env.DOCS_REVALIDATION_KEYS?.split(/\s*,\s*/) ?? []
|
||||
const overrideKeys = process.env.DOCS_REVALIDATION_OVERRIDE_KEYS?.split(/\s*,\s*/) ?? []
|
||||
if (basicKeys.length === 0 && overrideKeys.length === 0) {
|
||||
console.error('No keys configured for revalidation')
|
||||
return new Response('Internal server error', {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
|
||||
let authorizationLevel = AuthorizationLevel.Unauthorized
|
||||
const token = authorization.replace(/^Bearer /, '')
|
||||
if (overrideKeys.includes(token)) {
|
||||
authorizationLevel = AuthorizationLevel.Override
|
||||
} else if (basicKeys.includes(token)) {
|
||||
authorizationLevel = AuthorizationLevel.Basic
|
||||
}
|
||||
if (authorizationLevel === AuthorizationLevel.Unauthorized) {
|
||||
return new Response('Invalid Authorization header', { status: 401 })
|
||||
}
|
||||
|
||||
let result: z.infer<typeof requestBodySchema>
|
||||
try {
|
||||
result = requestBodySchema.parse(await request.json())
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return new Response(
|
||||
'Malformed request body: should be a JSON object with a "tags" array of strings.',
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const supabaseAdmin = createClient<Database>(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SECRET_KEY!
|
||||
)
|
||||
|
||||
if (authorizationLevel === AuthorizationLevel.Basic) {
|
||||
const { data: lastRevalidation, error } = await supabaseAdmin.rpc(
|
||||
'get_last_revalidation_for_tags',
|
||||
{
|
||||
tags: result.tags,
|
||||
}
|
||||
)
|
||||
if (error) {
|
||||
console.error(error)
|
||||
return new Response('Internal server error', { status: 500 })
|
||||
}
|
||||
|
||||
const sixHoursAgo = new Date()
|
||||
sixHoursAgo.setHours(sixHoursAgo.getHours() - 6)
|
||||
if (lastRevalidation?.some((revalidation) => new Date(revalidation.created_at) > sixHoursAgo)) {
|
||||
return new Response(
|
||||
'Your request includes a tag that has been revalidated within the last 6 hours. You can override this limit by authenticating with Override permissions.',
|
||||
{
|
||||
status: 429,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const { error } = await supabaseAdmin
|
||||
.from('validation_history')
|
||||
.insert(result.tags.map((tag) => ({ tag })))
|
||||
if (error) {
|
||||
console.error('Failed to update revalidation table: %o', error)
|
||||
}
|
||||
|
||||
result.tags.forEach((tag) => {
|
||||
revalidateTag(tag)
|
||||
})
|
||||
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import MessageBird from './MessageBirdConfig.mdx'
|
||||
import Twilio from './TwilioConfig.mdx'
|
||||
import Vonage from './VonageConfig.mdx'
|
||||
import TextLocal from './TextLocalConfig.mdx'
|
||||
import { safeHistoryReplaceState } from '~/lib/historyUtils'
|
||||
|
||||
const reducer = (_, action: (typeof PhoneLoginsItems)[number] | undefined) => {
|
||||
const url = new URL(document.location.href)
|
||||
@@ -16,7 +17,7 @@ const reducer = (_, action: (typeof PhoneLoginsItems)[number] | undefined) => {
|
||||
} else {
|
||||
url.searchParams.delete('showSmsProvider')
|
||||
}
|
||||
window.history.replaceState(null, '', url)
|
||||
safeHistoryReplaceState(url.toString())
|
||||
return action
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
|
||||
import { isFeatureEnabled } from 'common'
|
||||
import type { ICommand } from 'ui-patterns/CommandMenu'
|
||||
import { useRegisterCommands } from 'ui-patterns/CommandMenu'
|
||||
|
||||
@@ -89,6 +90,7 @@ const navCommands = [
|
||||
value: 'Reference, API, SDK: Go to Dart reference (Flutter)',
|
||||
route: '/reference/dart/introduction',
|
||||
icon: () => <ArrowRight />,
|
||||
enabled: isFeatureEnabled('sdk:dart'),
|
||||
},
|
||||
{
|
||||
id: 'nav-ref-python',
|
||||
@@ -96,6 +98,7 @@ const navCommands = [
|
||||
value: 'Reference, API, SDK: Go to Python reference',
|
||||
route: '/reference/python/introduction',
|
||||
icon: () => <ArrowRight />,
|
||||
enabled: isFeatureEnabled('sdk:python'),
|
||||
},
|
||||
{
|
||||
id: 'nav-ref-csharp',
|
||||
@@ -103,6 +106,7 @@ const navCommands = [
|
||||
value: 'Reference, API, SDK: Go to C# reference',
|
||||
route: '/reference/csharp/introduction',
|
||||
icon: () => <ArrowRight />,
|
||||
enabled: isFeatureEnabled('sdk:csharp'),
|
||||
},
|
||||
{
|
||||
id: 'nav-ref-swift',
|
||||
@@ -110,6 +114,7 @@ const navCommands = [
|
||||
value: 'Reference, API, SDK: Go to Swift reference',
|
||||
route: '/reference/swift/introduction',
|
||||
icon: () => <ArrowRight />,
|
||||
enabled: isFeatureEnabled('sdk:swift'),
|
||||
},
|
||||
{
|
||||
id: 'nav-ref-kotlin',
|
||||
@@ -117,6 +122,7 @@ const navCommands = [
|
||||
value: 'Reference, API, SDK: Go to Kotlin reference',
|
||||
route: '/reference/kotlin/introduction',
|
||||
icon: () => <ArrowRight />,
|
||||
enabled: isFeatureEnabled('sdk:kotlin'),
|
||||
},
|
||||
{
|
||||
id: 'nav-ref-cli',
|
||||
@@ -143,11 +149,14 @@ const navCommands = [
|
||||
name: 'Go to Integrations',
|
||||
route: 'https://supabase.com/partners/integrations',
|
||||
icon: () => <ArrowRight />,
|
||||
enabled: isFeatureEnabled('integrations:partners'),
|
||||
},
|
||||
] satisfies ICommand[]
|
||||
] satisfies Array<ICommand & { enabled?: boolean }>
|
||||
|
||||
const filteredNavCommands = navCommands.filter((command) => command.enabled !== false)
|
||||
|
||||
const useDocsNavCommands = () => {
|
||||
useRegisterCommands('Go to', navCommands)
|
||||
useRegisterCommands('Go to', filteredNavCommands)
|
||||
}
|
||||
|
||||
export { useDocsNavCommands }
|
||||
@@ -38,4 +38,5 @@ export type NavMenuConstant = Readonly<{
|
||||
icon: string
|
||||
url?: `/${string}`
|
||||
items: ReadonlyArray<Partial<NavMenuSection>>
|
||||
enabled?: boolean
|
||||
}>
|
||||
@@ -9,6 +9,7 @@ const {
|
||||
billingAll: billingEnabled,
|
||||
docsCompliance: complianceEnabled,
|
||||
'docsSelf-hosting': selfHostingEnabled,
|
||||
integrationsPartners: integrationsEnabled,
|
||||
sdkCsharp: sdkCsharpEnabled,
|
||||
sdkDart: sdkDartEnabled,
|
||||
sdkKotlin: sdkKotlinEnabled,
|
||||
@@ -19,6 +20,7 @@ const {
|
||||
'billing:all',
|
||||
'docs:compliance',
|
||||
'docs:self-hosting',
|
||||
'integrations:partners',
|
||||
'sdk:csharp',
|
||||
'sdk:dart',
|
||||
'sdk:kotlin',
|
||||
@@ -128,6 +130,7 @@ export const GLOBAL_MENU_ITEMS: GlobalMenuItems = [
|
||||
hasLightIcon: true,
|
||||
href: '/guides/integrations' as `/${string}`,
|
||||
level: 'integrations',
|
||||
enabled: integrationsEnabled,
|
||||
},
|
||||
],
|
||||
],
|
||||
@@ -1659,8 +1662,8 @@ export const realtime: NavMenuConstant = {
|
||||
url: '/guides/realtime',
|
||||
},
|
||||
{
|
||||
name: 'Concepts',
|
||||
url: '/guides/realtime/concepts',
|
||||
name: 'Getting Started',
|
||||
url: '/guides/realtime/getting_started',
|
||||
},
|
||||
{
|
||||
name: 'Usage',
|
||||
@@ -1713,6 +1716,7 @@ export const realtime: NavMenuConstant = {
|
||||
enabled: billingEnabled,
|
||||
},
|
||||
{ name: 'Architecture', url: '/guides/realtime/architecture' },
|
||||
{ name: 'Concepts', url: '/guides/realtime/concepts' },
|
||||
{ name: 'Protocol', url: '/guides/realtime/protocol', items: [] },
|
||||
{ name: 'Benchmarks', url: '/guides/realtime/benchmarks' },
|
||||
],
|
||||
@@ -2625,6 +2629,7 @@ export const integrations: NavMenuConstant = {
|
||||
title: 'Integrations',
|
||||
icon: 'integrations',
|
||||
url: '/guides/integrations',
|
||||
enabled: integrationsEnabled,
|
||||
items: [
|
||||
{
|
||||
name: 'Overview',
|
||||
|
||||
@@ -146,6 +146,10 @@ const ContentLink = React.memo(function ContentLink(props: any) {
|
||||
const Content = (props) => {
|
||||
const { menu, id } = props
|
||||
|
||||
if (menu.enabled === false) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className={['relative w-full flex flex-col gap-0 pb-5'].join(' ')}>
|
||||
<Link href={menu.url ?? ''}>
|
||||
|
||||
@@ -84,12 +84,7 @@ const TopNavBar: FC = () => {
|
||||
<div className="hidden lg:flex items-center justify-end gap-3">
|
||||
{!isUserLoading && (
|
||||
<Button asChild>
|
||||
<a
|
||||
href="https://supabase.com/dashboard"
|
||||
className="h-[30px]"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
<a href="/dashboard" className="h-[30px]" target="_blank" rel="noreferrer noopener">
|
||||
{isLoggedIn ? 'Dashboard' : 'Sign up'}
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
@@ -76,8 +76,8 @@ $$;
|
||||
You can now search your documents using any Node.js application. This is intended to be run on a secure server route.
|
||||
|
||||
```js
|
||||
import { SupabaseVectorStore } from 'langchain/vectorstores/supabase'
|
||||
import { OpenAIEmbeddings } from 'langchain/embeddings/openai'
|
||||
import { SupabaseVectorStore } from '@langchain/community/vectorstores/supabase'
|
||||
import { OpenAIEmbeddings } from '@langchain/openai'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
@@ -111,8 +111,8 @@ export const run = async () => {
|
||||
Given the above `match_documents` Postgres function, you can also pass a filter parameter to only return documents with a specific metadata field value. This filter parameter is a JSON object, and the `match_documents` function will use the Postgres JSONB Containment operator `@>` to filter documents by the metadata field values you specify. See details on the [Postgres JSONB Containment operator](https://www.postgresql.org/docs/current/datatype-json.html#JSON-CONTAINMENT) for more information.
|
||||
|
||||
```js
|
||||
import { SupabaseVectorStore } from 'langchain/vectorstores/supabase'
|
||||
import { OpenAIEmbeddings } from 'langchain/embeddings/openai'
|
||||
import { SupabaseVectorStore } from '@langchain/community/vectorstores/supabase'
|
||||
import { OpenAIEmbeddings } from '@langchain/openai'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
// First, follow set-up instructions above
|
||||
@@ -150,8 +150,8 @@ export const run = async () => {
|
||||
You can also use query builder-style filtering ([similar to how the Supabase JavaScript library works](/docs/reference/javascript/using-filters)) instead of passing an object. Note that since the filter properties will be in the metadata column, you need to use arrow operators (`->` for integer or `->>` for text) as defined in [PostgREST API documentation](https://postgrest.org/en/stable/references/api/tables_views.html?highlight=operators#json-columns) and specify the data type of the property (e.g. the column should look something like `metadata->some_int_value::int`).
|
||||
|
||||
```js
|
||||
import { SupabaseFilterRPCCall, SupabaseVectorStore } from 'langchain/vectorstores/supabase'
|
||||
import { OpenAIEmbeddings } from 'langchain/embeddings/openai'
|
||||
import { SupabaseFilterRPCCall, SupabaseVectorStore } from '@langchain/community/vectorstores/supabase'
|
||||
import { OpenAIEmbeddings } from '@langchain/openai'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
// First, follow set-up instructions above
|
||||
|
||||
@@ -112,7 +112,7 @@ response = supabase.auth.sign_in_anonymously()
|
||||
|
||||
## Convert an anonymous user to a permanent user
|
||||
|
||||
Converting an anonymous user to a permanent user requires [linking an identity](/docs/guides/auth/auth-identity-linking#manual-linking-beta) to the user. This requires you to [enable manual linking](/dashboard/project/_/settings/auth) in your Supabase project.
|
||||
Converting an anonymous user to a permanent user requires [linking an identity](/docs/guides/auth/auth-identity-linking#manual-linking-beta) to the user. This requires you to [enable manual linking](/dashboard/project/_/auth/providers) in your Supabase project.
|
||||
|
||||
### Link an email / phone identity
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ In the Settings page, look for the **Sitekey** section and copy the key.
|
||||
|
||||
## Enable CAPTCHA protection for your Supabase project
|
||||
|
||||
Navigate to the **[Auth](/dashboard/project/_/settings/auth)** section of your Project Settings in the Supabase Dashboard and find the **Enable CAPTCHA protection** toggle under Settings > Authentication > Bot and Abuse Protection > Enable CAPTCHA protection.
|
||||
Navigate to the **[Auth](/dashboard/project/_/auth/protection)** section of your Project Settings in the Supabase Dashboard and find the **Enable CAPTCHA protection** toggle under Settings > Authentication > Bot and Abuse Protection > Enable CAPTCHA protection.
|
||||
|
||||
Select your CAPTCHA provider from the dropdown, enter your CAPTCHA **Secret key**, and click **Save**.
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ response = supabase.auth.link_identity({'provider': 'google'})
|
||||
</$Show>
|
||||
</Tabs>
|
||||
|
||||
In the example above, the user will be redirected to Google to complete the OAuth2.0 flow. Once the OAuth2.0 flow has completed successfully, the user will be redirected back to the application and the Google identity will be linked to the user. You can enable manual linking from your project's authentication [configuration options](/dashboard/project/_/settings/auth) or by setting the environment variable `GOTRUE_SECURITY_MANUAL_LINKING_ENABLED: true` when self-hosting.
|
||||
In the example above, the user will be redirected to Google to complete the OAuth2.0 flow. Once the OAuth2.0 flow has completed successfully, the user will be redirected back to the application and the Google identity will be linked to the user. You can enable manual linking from your project's authentication [configuration options](/dashboard/project/_/auth/providers) or by setting the environment variable `GOTRUE_SECURITY_MANUAL_LINKING_ENABLED: true` when self-hosting.
|
||||
|
||||
## Unlink an identity
|
||||
|
||||
|
||||
@@ -306,7 +306,7 @@ function AuthMFA() {
|
||||
|
||||
### 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).
|
||||
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/_/auth/mfa).
|
||||
|
||||
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. 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)
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ A non-exhaustive list of services that work with Supabase Auth is:
|
||||
- [ZeptoMail](https://www.zoho.com/zeptomail/help/smtp-home.html)
|
||||
- [Brevo](https://help.brevo.com/hc/en-us/articles/7924908994450-Send-transactional-emails-using-Brevo-SMTP)
|
||||
|
||||
Once you've set up your account with an email sending service, head to the [Authentication settings page](/dashboard/project/_/settings/auth) to enable and configure custom SMTP.
|
||||
Once you've set up your account with an email sending service, head to the [Authentication settings page](/dashboard/project/_/auth/smtp) to enable and configure custom SMTP.
|
||||
|
||||
You can also configure custom SMTP using the Management API:
|
||||
|
||||
|
||||
@@ -4,13 +4,21 @@ title: 'General configuration'
|
||||
subtitle: 'General configuration options for Supabase Auth'
|
||||
---
|
||||
|
||||
This section covers the [general configuration options](/dashboard/project/_/settings/auth) for Supabase Auth. If you are looking for another type of configuration, you may be interested in one of the following sections:
|
||||
This section covers the [general configuration options](/dashboard/project/_/auth) for Supabase Auth. If you are looking for another type of configuration, you may be interested in one of the following sections:
|
||||
|
||||
- [Provider-specific configuration](/dashboard/project/_/auth/providers)
|
||||
- [Rate limits](/dashboard/project/_/auth/rate-limits)
|
||||
- [Email Templates](/dashboard/project/_/auth/templates)
|
||||
- [Redirect URLs](/dashboard/project/_/auth/url-configuration)
|
||||
- [Auth Hooks](/dashboard/project/_/auth/hooks)
|
||||
- [Policies](/dashboard/project/_/auth/policies) to manage Row Level Security policies for your tables.
|
||||
- [Sign In / Providers](/dashboard/project/_/auth/providers) to configure authentication providers and login methods for your users.
|
||||
- [Third Party Auth](/dashboard/project/_/auth/third-party) to use third-party authentication (TPA) systems based on JWTs to access your project.
|
||||
- [Sessions](/dashboard/project/_/auth/sessions) to configure settings for user sessions and refresh tokens.
|
||||
- [Rate limits](/dashboard/project/_/auth/rate-limits) to safeguard against bursts of incoming traffic to prevent abuse and maximize stability.
|
||||
- [Email Templates](/dashboard/project/_/auth/templates) to configure what emails your users receive.
|
||||
- [Custom SMTP](/dashboard/project/_/auth/smtp) to configure how emails are sent.
|
||||
- [Multi-Factor](/dashboard/project/_/auth/mfa) to require users to provide additional verification factors to authenticate.
|
||||
- [URL Configuration](/dashboard/project/_/auth/url-configuration) to configure site URL and redirect URLs for authentication.
|
||||
- [Attack Protection](/dashboard/project/_/auth/protection) to configure security settings to protect your project from attacks.
|
||||
- [Auth Hooks (BETA)](/dashboard/project/_/auth/auth-hooks) to use Postgres functions or HTTP endpoints to customize the behavior of Supabase Auth to meet your needs.
|
||||
- [Audit Logs (BETA)](/dashboard/project/_/auth/audit-logs) to track and monitor auth events in your project.
|
||||
- [Advanced](/dashboard/project/_/auth/advanced) to configure advanced authentication server settings.
|
||||
|
||||
Supabase Auth provides these [general configuration options](/dashboard/project/_/settings/auth) to control user access to your application:
|
||||
|
||||
|
||||
@@ -165,6 +165,16 @@ val supabase = createSupabaseClient(
|
||||
</TabPanel>
|
||||
</$Show>
|
||||
|
||||
<TabPanel id="bash" label="cURL">
|
||||
|
||||
```bash
|
||||
curl 'https://<supabase-project>.supabase.co/rest/v1/my_table?select=id' \
|
||||
-H "apikey: $SUPABASE_PUBLISHABLE_KEY" \
|
||||
-H "Authorization: Bearer <your JWT here>"
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
|
||||
</Tabs>
|
||||
|
||||
In the past there was a recommendation to set custom headers on the Supabase client with the `Authorization` header including your custom JWT. This is no longer recommended as it's less flexible and causes confusion when combined with a user session from Supabase Auth.
|
||||
|
||||
@@ -55,11 +55,11 @@ There are three ways to limit the lifetime of a session:
|
||||
- Set an inactivity timeout, which terminates sessions that haven't been refreshed within the timeout duration.
|
||||
- Enforce a single-session per user, which only keeps the most recently active session.
|
||||
|
||||
To make sure that users are required to re-authenticate periodically, you can set a positive value for the **Time-box user sessions** option in the [Auth settings](/dashboard/project/_/settings/auth) for your project.
|
||||
To make sure that users are required to re-authenticate periodically, you can set a positive value for the **Time-box user sessions** option in the [Auth settings](/dashboard/project/_/auth/sessions) for your project.
|
||||
|
||||
To make sure that sessions expire after a period of inactivity, you can set a positive duration for the **Inactivity timeout** option in the [Auth settings](/dashboard/project/_/settings/auth).
|
||||
To make sure that sessions expire after a period of inactivity, you can set a positive duration for the **Inactivity timeout** option in the [Auth settings](/dashboard/project/_/auth/sessions).
|
||||
|
||||
You can also enforce only one active session per user per device or browser. When this is enabled, the session from the most recent sign in will remain active, while the rest are terminated. Enable this via the _Single session per user_ option in the [Auth settings](/dashboard/project/_/settings/auth).
|
||||
You can also enforce only one active session per user per device or browser. When this is enabled, the session from the most recent sign in will remain active, while the rest are terminated. Enable this via the _Single session per user_ option in the [Auth settings](/dashboard/project/_/auth/sessions).
|
||||
|
||||
Sessions are not proactively destroyed when you change these settings, but rather the check is enforced whenever a session is refreshed next. This can confuse developers because the actual duration of a session is the configured timeout plus the JWT expiration time. For single session per user, the effect will only be noticed at intervals of the JWT expiration time. Make sure you adjust this setting depending on your needs. We do not recommend going below 5 minutes for the JWT expiration time.
|
||||
|
||||
@@ -69,7 +69,7 @@ Otherwise sessions are progressively deleted from the database 24 hours after th
|
||||
|
||||
### What are recommended values for access token (JWT) expiration?
|
||||
|
||||
Most applications should use the default expiration time of 1 hour. This can be customized in your project's [Auth settings](/dashboard/project/_/settings/auth) in the Advanced Settings section.
|
||||
Most applications should use the default expiration time of 1 hour. This can be customized in your project's [Auth settings](/dashboard/project/_/auth/sessions) in the Advanced Settings section.
|
||||
|
||||
Setting a value over 1 hour is generally discouraged for security reasons, but it may make sense in certain situations.
|
||||
|
||||
@@ -93,7 +93,7 @@ The general rule is that a refresh token can only be used once. However, strictl
|
||||
- All clients such as browsers, mobile or desktop apps, and even some servers are inherently unreliable due to network issues. A request does not indicate that they received a response or even processed the response they received.
|
||||
- If a refresh token is revoked after being used only once, and the response wasn't received and processed by the client, when the client comes back online, it will attempt to use the refresh token that was already used. Since this might happen outside of the reuse interval, it can cause sudden and unexpected session termination.
|
||||
|
||||
Should the reuse attempt not fall under these two exceptions, the whole session is regarded as terminated and all refresh tokens belonging to it are marked as revoked. You can disable this behavior in the Advanced Settings of the [Auth settings](/dashboard/project/_/settings/auth) page, though it is generally not recommended.
|
||||
Should the reuse attempt not fall under these two exceptions, the whole session is regarded as terminated and all refresh tokens belonging to it are marked as revoked. You can disable this behavior in the Advanced Settings of the [Auth settings](/dashboard/project/_/auth/sessions) page, though it is generally not recommended.
|
||||
|
||||
The purpose of this mechanism is to guard against potential security issues where a refresh token could have been stolen from the user, for example by exposing it accidentally in logs that leak (like logging cookies, request bodies or URL params) or via vulnerable third-party servers. It does not guard against the case where a user's session is stolen from their device.
|
||||
|
||||
|
||||
@@ -176,6 +176,65 @@ supabase gen signing-key --algorithm ES256
|
||||
|
||||
Make sure you store this private key in a secure location, as it will not be extractable from Supabase.
|
||||
|
||||
To import the generated private key to your project, create a [new standby key](/dashboard/project/_/settings/jwt/signing-keys) from the dashboard:
|
||||
|
||||
```json
|
||||
{
|
||||
"kty": "EC",
|
||||
"kid": "3a18cfe2-7226-43b0-bbb4-7c5242f2406e",
|
||||
"d": "RDbwqThwtGP4WnvACvO_0nL0oMMSmMFSYMPosprlAog",
|
||||
"crv": "P-256",
|
||||
"x": "gyLVvp9dyEgylYH7nR2E2qdQ_-9Pv5i1tk7c2qZD4Nk",
|
||||
"y": "CD9RfYOTyjR5U-PC9UDlsthRpc7vAQQQ2FTt8UsX0fY"
|
||||
}
|
||||
```
|
||||
|
||||
Once imported, click **Rotate key** to activate your new signing key. Any JWT signed by your old key will continue to be usable until your old signing key is manually revoked.
|
||||
|
||||
To mint a new JWT using the asymmetric signing key, you need to set the following [JWT headers](/docs/guides/auth/jwts#introduction) to match your generated private key.
|
||||
|
||||
```json
|
||||
{
|
||||
"alg": "ES256",
|
||||
"kid": "3a18cfe2-7226-43b0-bbb4-7c5242f2406e",
|
||||
"typ": "JWT"
|
||||
}
|
||||
```
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
The `kid` header is used to identify your public key for verification. You must use the same value when importing on platform.
|
||||
|
||||
</Admonition>
|
||||
|
||||
In addition, you need to provide the following custom claims as the JWT payload.
|
||||
|
||||
```json
|
||||
{
|
||||
"sub": "ef0493c9-3582-425f-a362-aef909588df7",
|
||||
"role": "authenticated",
|
||||
"exp": 1757749466
|
||||
}
|
||||
```
|
||||
|
||||
- `sub` is an optional UUID that uniquely identifies a user you want to impersonate in `auth.users` table.
|
||||
- `role` must be set to an existing Postgres role in your database, such as `anon`, `authenticated`, or `service_role`.
|
||||
- `exp` must be set to a timestamp in the future (seconds since 1970) when this token expires. Prefer shorter-lived tokens.
|
||||
|
||||
For simplicity, use the following CLI command to generate tokens with the desired header and payload.
|
||||
|
||||
```bash
|
||||
supabase gen bearer-jwt --role authenticated --sub ef0493c9-3582-425f-a362-aef909588df7
|
||||
```
|
||||
|
||||
Finally, you can use your newly minted JWT by setting the `Authorization: Bearer <JWT>` header to all [Data API requests](/docs/guides/auth/jwts#using-custom-or-third-party-jwts).
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
A separate `apikey` header is required to access your project's APIs. This can be a [publishable, secret or the legacy `anon` or `service_role` keys](/docs/guides/api/api-keys). Using your minted JWT is not possible in this header.
|
||||
|
||||
</Admonition>
|
||||
|
||||
### Why is a 5 minute wait imposed when changing signing key states?
|
||||
|
||||
Changing a JWT signing key's state sets off many changes inside the Supabase platform. To ensure a consistent setup, most actions that change the state of a JWT signing key are throttled for approximately 5 minutes.
|
||||
|
||||
@@ -2,16 +2,51 @@
|
||||
id: 'auth-google'
|
||||
title: 'Login with Google'
|
||||
description: 'Use Sign in with Google on the web, in native apps or with Chrome extensions'
|
||||
tocVideo: 'vojHmGUGUGc'
|
||||
---
|
||||
|
||||
Supabase Auth supports Sign in with Google for the web, native Android applications, and Chrome extensions.
|
||||
Supabase Auth supports [Sign in with Google for the web](https://developers.google.com/identity/gsi/web/guides/overview), native applications ([Android](https://developer.android.com/identity/sign-in/credential-manager-siwg), [macOS and iOS](https://developers.google.com/identity/sign-in/ios/start-integrating)), and [Chrome extensions](https://cloud.google.com/identity-platform/docs/web/chrome-extension).
|
||||
|
||||
You can use Sign in with Google in two ways:
|
||||
|
||||
- [By writing application code](#application-code) for the web, native applications or Chrome extensions
|
||||
- [By using Google's pre-built solutions](#google-pre-built) such as [personalized sign-in buttons](https://developers.google.com/identity/gsi/web/guides/personalized-button), [One Tap](https://developers.google.com/identity/gsi/web/guides/features) or [automatic sign-in](https://developers.google.com/identity/gsi/web/guides/automatic-sign-in-sign-out)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Google Cloud project. Go to the [Google Cloud Platform](https://console.cloud.google.com/home/dashboard) and create a new project if necessary.
|
||||
You need to do some setup to get started with Sign in with Google:
|
||||
|
||||
## Configuration
|
||||
- Prepare a Google Cloud project. Go to the [Google Cloud Platform](https://console.cloud.google.com/home/dashboard) and create a new project if necessary.
|
||||
- Use the [Google Auth Platform console](https://console.cloud.google.com/auth/overview) to register and set up your application's:
|
||||
- [**Audience**](https://console.cloud.google.com/auth/audience) by configuring which Google users are allowed to sign in to your application.
|
||||
- [**Data Access (Scopes)**](https://console.cloud.google.com/auth/scopes) define what your application can do with your user's Google data and APIs, such as access profile information or more.
|
||||
- [**Branding**](https://console.cloud.google.com/auth/branding) and [**Verification**](https://console.cloud.google.com/auth/verification) show a logo and name instead of the Supabase project ID in the consent screen, improving user retention. Brand verification may take a few business days.
|
||||
|
||||
### Setup required scopes
|
||||
|
||||
Supabase Auth needs a few scopes granting access to profile data of your end users, which you have to configure in the [**Data Access (Scopes)**](https://console.cloud.google.com/auth/scopes) screen:
|
||||
|
||||
- `openid` (add manually)
|
||||
- `.../auth/userinfo.email` (added by default)
|
||||
- `...auth/userinfo.profile` (added by default)
|
||||
|
||||
If you add more scopes, especially those on the sensitive or restricted list your application might be subject to verification which may take a long time.
|
||||
|
||||
### Setup consent screen branding
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
It's strongly recommended you set up a custom domain and optionally verify your brand information with Google, as this makes phishing attempts easier to spot by your users.
|
||||
|
||||
</Admonition>
|
||||
|
||||
Google's consent screen is shown to users when they sign in. Optionally configure the following to improve the appearance of the screen, increasing the perception of trust by your users:
|
||||
|
||||
1. Set up a [custom domain for your project](/docs/guides/platform/custom-domains) to present the user with a clear relationship to the website they clicked Sign in with Google on.
|
||||
- A good approach is to use `auth.example.com` or `api.example.com`, if your application is hosted on `example.com`.
|
||||
- If you don't set this up, users will see `<project-id>.supabase.co` which does not inspire trust and can make your application more susceptible to successful phishing attempts.
|
||||
1. Verify your application's brand (logo and name) by configuring it in the [Branding](https://console.cloud.google.com/auth/branding) section of the Google Auth Platform console. Brand verification is not automatic and may take a few business days.
|
||||
|
||||
## Project setup
|
||||
|
||||
To support Sign In with Google, you need to configure the Google provider for your Supabase project.
|
||||
|
||||
@@ -24,126 +59,67 @@ To support Sign In with Google, you need to configure the Google provider for yo
|
||||
>
|
||||
<TabPanel id="web" label="Web">
|
||||
|
||||
For web applications, you can set up your signin button two different ways:
|
||||
Regardless of whether you use application code or Google's pre-built solutions to implement the sign in flow, you need to configure your project by obtaining a Client ID and Client Secret in the [Clients](https://console.cloud.google.com/auth/clients) section of the Google Auth Platform console:
|
||||
|
||||
- Use your own [application code](#application-code) for the button.
|
||||
- Use [Google's pre-built sign-in or One Tap flows](#google-pre-built).
|
||||
|
||||
### Application code configuration
|
||||
|
||||
To use your own application code:
|
||||
|
||||
1. In the Google Cloud console, go to the [Consent Screen configuration page](https://console.cloud.google.com/apis/credentials/consent). The consent screen is the view shown to your users when they consent to signing in to your app.
|
||||
1. Under **Authorized domains**, add your Supabase project's domain, which has the form `<PROJECT_ID>.supabase.co`.
|
||||
1. Configure the following non-sensitive scopes:
|
||||
- `.../auth/userinfo.email`
|
||||
- `...auth/userinfo.profile`
|
||||
- `openid`
|
||||
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**, add your site URL.
|
||||
1. Under **Authorized redirect URLs**, enter the callback URL from the [Supabase dashboard](/dashboard/project/_/auth/providers). Expand the Google Auth Provider section to display it.
|
||||
|
||||
<Admonition type="tip">
|
||||
|
||||
The redirect URL is visible to your users. You can customize it by configuring [custom domains](/docs/guides/platform/custom-domains).
|
||||
|
||||
</Admonition>
|
||||
|
||||
1. When you finish configuring your credentials, you will be shown your client ID and secret. Add these to the [Google Auth Provider section of the Supabase Dashboard](/dashboard/project/_/auth/providers).
|
||||
|
||||
Alternatively, you can configure Google authentication using the Management API:
|
||||
|
||||
```bash
|
||||
# First, get your access token from https://supabase.com/dashboard/account/tokens
|
||||
export SUPABASE_ACCESS_TOKEN="your-access-token"
|
||||
export PROJECT_REF="your-project-ref"
|
||||
|
||||
# Update auth config to enable Google provider
|
||||
curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \
|
||||
-H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"external_google_enabled": true,
|
||||
"external_google_client_id": "your-google-client-id",
|
||||
"external_google_secret": "your-google-client-secret"
|
||||
}'
|
||||
```
|
||||
|
||||
<Admonition type="tip">
|
||||
|
||||
In local development, you can add the client ID and secret to your `config.toml` file.
|
||||
|
||||
</Admonition>
|
||||
|
||||
### Google pre-built configuration
|
||||
|
||||
To use Google's pre-built signin buttons:
|
||||
|
||||
1. In the Google Cloud console, go to the [Consent Screen configuration page](https://console.cloud.google.com/apis/credentials/consent). The consent screen is the view shown to your users when they consent to signing in to your app.
|
||||
1. Configure the screen to your liking, making sure you add links to your app's privacy policy and terms of service.
|
||||
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. 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 **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.
|
||||
1. [Create a new OAuth client ID](https://console.cloud.google.com/auth/clients/create) and choose **Web application** for the application type.
|
||||
1. Under **Authorized JavaScript origins** add your application's URL. These should also be configured as the [Site URL or redirect configuration in your project](/docs/guides/auth/redirect-urls).
|
||||
- If your app is hosted on `https://example.com/app` add `https://example.com`.
|
||||
- Add `http://localhost:<port>` while developing locally. Remember to remove this when your application [goes into production](/docs/guides/deployment/going-into-prod).
|
||||
1. Under **Authorized redirect URIs** add your Supabase project's callback URL.
|
||||
- Access it from the [Google provider page on the Dashboard](/dashboard/project/_/auth/providers?provider=Google).
|
||||
- For local development, use `http://localhost:3000/auth/v1/callback`.
|
||||
1. Click `Create` and make sure you save the Client ID and Client Secret.
|
||||
- Add these values to the [Google provider page on the Dashboard](/dashboard/project/_/auth/providers?provider=Google).
|
||||
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="react-native" label="Expo React Native">
|
||||
|
||||
1. Configure OAuth credentials for your Google Cloud project in the [Credentials](https://console.cloud.google.com/apis/credentials) page of the console. When creating a new OAuth client ID, choose _Android_ or _iOS_ depending on the mobile operating system your app is built for.
|
||||
- For Android, use the instructions on screen to provide the SHA-1 certificate fingerprint used to sign your Android app.
|
||||
- You will have a different set of SHA-1 certificate fingerprints for testing locally and going to production. Make sure to add both to the Google Cloud Console, and add all of the Client IDs to the Supabase dashboard.
|
||||
- For iOS, use the instructions on screen to provide the app Bundle ID, and App Store ID and Team ID if the app is already published on the Apple App Store.
|
||||
2. Configure the [OAuth Consent Screen](https://console.cloud.google.com/apis/credentials/consent). This information is shown to the user when giving consent to your app. In particular, make sure you have set up links to your app's privacy policy and terms of service.
|
||||
3. Finally, add the client ID from step 1 in the [Google provider on the Supabase Dashboard](/dashboard/project/_/auth/providers), under _Client IDs_.
|
||||
|
||||
Note that you do not have to configure the OAuth flow in the Supabase Dashboard in order to use native sign in.
|
||||
1. [Create a new OAuth client ID](https://console.cloud.google.com/auth/clients/create) and choose **Android** or **iOS** depending on the OS you're building the app for.
|
||||
- For Android, use the instructions on screen to provide the SHA-1 certificate fingerprint used to sign your Android app.
|
||||
- You will have a different set of SHA-1 certificate fingerprints for testing locally and going to production. Make sure to add both to the Google Cloud Console, and add all of the Client IDs to the Supabase dashboard.
|
||||
- For iOS, use the instructions on screen to provide the app Bundle ID, and App Store ID and Team ID if the app is already published on the Apple App Store.
|
||||
1. Register the Client ID in the [Google provider page on the Dashboard](/dashboard/project/_/auth/providers?provider=Google).
|
||||
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="flutter" label="Flutter">
|
||||
<TabPanel id="flutter-mobile" label="Flutter (iOS and Android)">
|
||||
|
||||
### iOS and Android
|
||||
1. [Create a new OAuth client ID](https://console.cloud.google.com/auth/clients/create) and choose **Android** or **iOS** depending on the OS you're building the app for.
|
||||
- For Android, use the instructions on screen to provide the SHA-1 certificate fingerprint used to sign your Android app.
|
||||
- You will have a different set of SHA-1 certificate fingerprints for testing locally and going to production. Make sure to add both to the Google Cloud Console, and add all of the Client IDs to the Supabase dashboard.
|
||||
- For iOS, use the instructions on screen to provide the app Bundle ID, and App Store ID and Team ID if the app is already published on the Apple App Store.
|
||||
1. Register the Client ID in the [Google provider page on the Dashboard](/dashboard/project/_/auth/providers?provider=Google).
|
||||
- For iOS enable the `Skip nonce check` option.
|
||||
|
||||
1. Configure Web, Android, and iOS OAuth credentials. Follow the Platform integration instructions on the [README of google_sign_in package](https://pub.dev/packages/google_sign_in#platform-integration) for Android and iOS.
|
||||
- For both Android and iOS, you will need to create a Web client ID in the [Google Cloud Console](https://console.cloud.google.com/apis/credentials).
|
||||
- For Android, use the instructions on screen to provide the SHA-1 certificate fingerprint used to sign your Android app.
|
||||
- You will have a different set of SHA-1 certificate fingerprint for testing locally and going to production. Make sure to add both to the Google Cloud Console. and add all of the Client IDs to Supabase dashboard.
|
||||
- For iOS, use the instructions on screen to provide the app Bundle ID, and App Store ID and Team ID if the app is already published on the Apple App Store.
|
||||
2. Configure the [OAuth Consent Screen](https://console.cloud.google.com/apis/credentials/consent). This information is shown to the user when giving consent to your app. In particular, make sure you have set up links to your app's privacy policy and terms of service.
|
||||
3. Add only the web client ID from step 1 in the [Google provider on the Supabase Dashboard](/dashboard/project/_/auth/providers), under _Client IDs_. If you need iOS support, enable the `Skip nonce check` option.
|
||||
4. For iOS, add the `CFBundleURLTypes` attributes below into the `/ios/Runner/Info.plist` file.
|
||||
```xml
|
||||
<!-- Put me in the [my_project]/ios/Runner/Info.plist file -->
|
||||
<!-- Google Sign-in Section -->
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<!-- TODO Replace this value: -->
|
||||
<!-- Copied from GoogleService-Info.plist key REVERSED_CLIENT_ID -->
|
||||
<string>com.googleusercontent.apps.861823949799-vc35cprkp249096uujjn0vvnmcvjppkn</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<!-- End of the Google Sign-in Section -->
|
||||
```
|
||||
For iOS add a `CFBundleURLTypes` key in the `<project>/ios/Runner/Info.plist` file:
|
||||
|
||||
### Web, macOS, Windows, and Linux
|
||||
```xml
|
||||
<!-- Put me in the [my_project]/ios/Runner/Info.plist file -->
|
||||
<!-- Google Sign-in Section -->
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<!-- TODO Replace this value: -->
|
||||
<!-- Copied from GoogleService-Info.plist key REVERSED_CLIENT_ID -->
|
||||
<string>com.googleusercontent.apps.861823949799-vc35cprkp249096uujjn0vvnmcvjppkn</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<!-- End of the Google Sign-in Section -->
|
||||
```
|
||||
|
||||
To use the OAuth 2.0 flow, you will require the following information:
|
||||
</TabPanel>
|
||||
|
||||
1. Obtain OAuth credentials for your Google Cloud project in the [Credentials](https://console.developers.google.com/apis/credentials) page of the console. If you already have a web client ID, no need to create a new one. When creating a new credential, choose _Web application_. In _Authorized redirect URIs_ enter `https://<project-id>.supabase.co/auth/v1/callback`. This URL will be seen by your users, and you can customize it by configuring [custom domains](/docs/guides/platform/custom-domains).
|
||||
2. Configure the [OAuth Consent Screen](https://console.cloud.google.com/apis/credentials/consent). This information is shown to the user when giving consent to your app. Within _Authorized domains_ make sure you add your Supabase project's domain `<project-id>.supabase.co`. Configure the non-sensitive scopes by making sure the following ones are selected: `.../auth/userinfo.email`, `.../auth/userinfo.profile`, `openid`. If you're selecting other sensitive scopes, your app may require additional verification. In those cases, it's best to use [custom domains](/docs/guides/platform/custom-domains).
|
||||
3. Finally, add the client ID and secret from step 1 in the [Google provider on the Supabase Dashboard](/dashboard/project/_/auth/providers).
|
||||
<TabPanel id="flutter-other" label="Flutter (web, macOS, Windows, Linux)">
|
||||
|
||||
</TabPanel>
|
||||
Follow the same configuration guide as if your app was a Web application when building a desktop Flutter application.
|
||||
|
||||
</TabPanel>
|
||||
|
||||
<$Show if="sdk:swift">
|
||||
|
||||
@@ -195,41 +171,54 @@ To use Google's pre-built signin buttons:
|
||||
|
||||
<TabPanel id="android" label="Android (Kotlin)">
|
||||
|
||||
### Using Google sign-in on Android
|
||||
|
||||
1. Configure OAuth credentials for your Google Cloud project in the [Credentials](https://console.cloud.google.com/apis/credentials) page of the console. You need two client IDs, a web client ID and Android client ID. When creating a new Android OAuth client ID, use the instructions on screen to provide the SHA-1 certificate fingerprint used to sign your Android app.
|
||||
2. Configure the [OAuth Consent Screen](https://console.cloud.google.com/apis/credentials/consent). This information is shown to the user when giving consent to your app. In particular, make sure you have set up links to your app's privacy policy and terms of service.
|
||||
3. Finally, add the web client ID from step 1 in the [Google provider on the Supabase Dashboard](/dashboard/project/_/auth/providers), under _Client IDs_.
|
||||
|
||||
Note that you do not have to configure the OAuth flow in the Supabase Dashboard in order to use native sign in.
|
||||
|
||||
### Using Google sign-in with Kotlin Multiplatform
|
||||
|
||||
1. Configure OAuth credentials for your Google Cloud project in the [Credentials](https://console.cloud.google.com/apis/credentials) page of the console. When creating a new OAuth client ID, choose _Android_ or _iOS_ depending on the mobile operating system your app is built for.
|
||||
|
||||
- For Android, use the instructions on screen to provide the SHA-1 certificate fingerprint used to sign your Android app.
|
||||
- For iOS, use the instructions on screen to provide the app Bundle ID, and App Store ID and Team ID if the app is already published on the Apple App Store.
|
||||
|
||||
2. Configure the [OAuth Consent Screen](https://console.cloud.google.com/apis/credentials/consent). This information is shown to the user when giving consent to your app. In particular, make sure you have set up links to your app's privacy policy and terms of service.
|
||||
3. Finally, add the client ID from step 1 in the [Google provider on the Supabase Dashboard](/dashboard/project/_/auth/providers), under _Client IDs_.
|
||||
|
||||
Note that you do not have to configure the OAuth flow in the Supabase Dashboard in order to use native sign in.
|
||||
1. [Create a new OAuth client ID](https://console.cloud.google.com/auth/clients/create) and choose **Android** or **iOS** if also building an iOS app with Kotlin Multiplatform.
|
||||
- For Android, use the instructions on screen to provide the SHA-1 certificate fingerprint used to sign your Android app.
|
||||
- You will have a different set of SHA-1 certificate fingerprints for testing locally and going to production. Make sure to add both to the Google Cloud Console, and add all of the Client IDs to the Supabase dashboard.
|
||||
- For iOS (with Kotlin Multiplatform), use the instructions on screen to provide the app Bundle ID, and App Store ID and Team ID if the app is already published on the Apple App Store.
|
||||
1. Register the Client ID in the [Google provider page on the Dashboard](/dashboard/project/_/auth/providers?provider=Google).
|
||||
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="chrome-extensions" label="Chrome Extensions">
|
||||
|
||||
You will need to configure a client ID for your Chrome extension:
|
||||
|
||||
1. Configure OAuth credentials for your Google Cloud project in the [Credentials](https://console.cloud.google.com/apis/credentials) page of the console. When creating a new OAuth client ID, choose _Chrome extension_ for the application type. For _Item ID_ provide the unique ID of your Chrome extension. You can get this by calling `chrome.runtime.id` within the extension, or from the Web Store URL of the extension. For example, the [Google Translate extension](https://chrome.google.com/webstore/detail/google-translate/aapbdbdomjkkjkaonfhkkikfgjllcleb) has the Web Store URL `https://chrome.google.com/webstore/detail/google-translate/aapbdbdomjkkjkaonfhkkikfgjllcleb` and the last part `aapbdbdomjkkjkaonfhkkikfgjllcleb` is its unique ID.
|
||||
2. Configure the [OAuth Consent Screen](https://console.cloud.google.com/apis/credentials/consent). This information is shown to the user when giving consent to your app.
|
||||
3. Finally, add the client ID from step 1 in the [Google provider on the Supabase Dashboard](/dashboard/project/_/auth/providers), under _Client IDs_.
|
||||
|
||||
Note that you do not have to configure the OAuth flow in the Supabase Dashboard to sign in with Google inside Chrome extensions.
|
||||
1. [Create a new OAuth client ID](https://console.cloud.google.com/auth/clients/create) and choose **Chrome Extension** for application type.
|
||||
- Enter your extension's Item ID and optionally verify app ownership.
|
||||
1. Register the Client ID in the [Google provider page on the Dashboard](/dashboard/project/_/auth/providers?provider=Google) under _Client IDs_.
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
### Local development
|
||||
|
||||
To use the Google provider in local development:
|
||||
|
||||
1. Add a new environment variable:
|
||||
```env
|
||||
SUPABASE_AUTH_EXTERNAL_GOOGLE_CLIENT_SECRET="<client-secret>"
|
||||
```
|
||||
2. Configure the provider:
|
||||
```toml
|
||||
[auth.external.google]
|
||||
enabled = true
|
||||
client_id = "<client-id>"
|
||||
secret = "env(SUPABASE_AUTH_EXTERNAL_GOOGLE_CLIENT_SECRET)"
|
||||
skip_nonce_check = false
|
||||
```
|
||||
|
||||
If you have multiple client IDs, such as one for Web, iOS and Android, concatenate all of the client IDs with a comma but make sure the web's client ID is first in the list.
|
||||
|
||||
### Using the management API
|
||||
|
||||
Use the [PATCH `/v1/projects/{ref}/config/auth` Management API endpoint](/docs/reference/api/v1-update-auth-service-config) to configure the project's Auth settings programmatically. For configuring the Google provider send these options:
|
||||
|
||||
```json
|
||||
{
|
||||
"external_google_enabled": true,
|
||||
"external_google_client_id": "your-google-client-id",
|
||||
"external_google_secret": "your-google-client-secret"
|
||||
}
|
||||
```
|
||||
|
||||
## Signing users in
|
||||
|
||||
<Tabs
|
||||
@@ -287,7 +276,7 @@ const { data, error } = await supabase.auth.signInWithOAuth({
|
||||
})
|
||||
```
|
||||
|
||||
### Google pre-built
|
||||
### Google pre-built [#google-pre-built]
|
||||
|
||||
Most web apps and websites can utilize Google's [personalized sign-in buttons](https://developers.google.com/identity/gsi/web/guides/personalized-button), [One Tap](https://developers.google.com/identity/gsi/web/guides/features) or [automatic sign-in](https://developers.google.com/identity/gsi/web/guides/automatic-sign-in-sign-out) for the best user experience.
|
||||
|
||||
@@ -514,9 +503,7 @@ export default function () {
|
||||
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="flutter" label="Flutter">
|
||||
|
||||
### iOS and Android
|
||||
<TabPanel id="flutter-mobile" label="Flutter (iOS and Android)">
|
||||
|
||||
Google sign-in with Supabase is done through the [google_sign_in](https://pub.dev/packages/google_sign_in) package for iOS and Android.
|
||||
|
||||
@@ -540,32 +527,54 @@ Future<void> _nativeGoogleSignIn() async {
|
||||
/// iOS Client ID that you registered with Google Cloud.
|
||||
const iosClientId = 'my-ios.apps.googleusercontent.com';
|
||||
|
||||
final GoogleSignIn googleSignIn = GoogleSignIn(
|
||||
clientId: iosClientId,
|
||||
serverClientId: webClientId,
|
||||
);
|
||||
final googleUser = await googleSignIn.signIn();
|
||||
final googleAuth = await googleUser!.authentication;
|
||||
final accessToken = googleAuth.accessToken;
|
||||
final idToken = googleAuth.idToken;
|
||||
final scopes = ['email', 'profile'];
|
||||
final googleSignIn = GoogleSignIn.instance;
|
||||
|
||||
if (accessToken == null) {
|
||||
throw 'No Access Token found.';
|
||||
await googleSignIn.initialize(
|
||||
serverClientId: webClientId,
|
||||
clientId: iosClientId,
|
||||
);
|
||||
|
||||
final googleUser = await googleSignIn.attemptLightweightAuthentication();
|
||||
// or await googleSignIn.authenticate(); which will return a GoogleSignInAccount or throw an exception
|
||||
|
||||
if (googleUser == null) {
|
||||
throw AuthException('Failed to sign in with Google.');
|
||||
}
|
||||
|
||||
/// Authorization is required to obtain the access token with the appropriate scopes for Supabase authentication,
|
||||
/// while also granting permission to access user information.
|
||||
final authorization =
|
||||
await googleUser.authorizationClient.authorizationForScopes(scopes) ??
|
||||
await googleUser.authorizationClient.authorizeScopes(scopes);
|
||||
|
||||
final idToken = googleUser.authentication.idToken;
|
||||
|
||||
if (idToken == null) {
|
||||
throw 'No ID Token found.';
|
||||
throw AuthException('No ID Token found.');
|
||||
}
|
||||
|
||||
await supabase.auth.signInWithIdToken(
|
||||
provider: OAuthProvider.google,
|
||||
idToken: idToken,
|
||||
accessToken: accessToken,
|
||||
accessToken: authorization.accessToken,
|
||||
);
|
||||
}
|
||||
...
|
||||
```
|
||||
|
||||
### Web, macOS, Windows, and Linux
|
||||
<div className="video-container">
|
||||
<iframe
|
||||
src="https://www.youtube-nocookie.com/embed/utMg6fVmX0U"
|
||||
frameBorder="1"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="flutter-other" label="Flutter (web, macOS, Windows, Linux)">
|
||||
|
||||
Google sign-in with Supabase on Web, macOS, Windows, and Linux is done through the [`signInWithOAuth`](docs/reference/dart/auth-signinwithoauth) method.
|
||||
|
||||
@@ -797,12 +806,3 @@ chrome.identity.launchWebAuthFlow(
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
## Google consent screen
|
||||
|
||||

|
||||
|
||||
By default, the Google consent screen shows the root domain of the callback URL, where Google will send the authentication response. With Supabase Auth, it is your Supabase project's domain `(https://<your-project-ref>.supabase.co)`.
|
||||
|
||||
You can change this domain in the settings for your Google app. Go to Google App Platform, click on Branding, and then set your application home page, privacy policy, and terms of use. You can also add your logo.
|
||||
Next, go to Audience and click publish. Google will need to verify the app, which usually takes around 24 hours.
|
||||
+2
-2
@@ -9,7 +9,7 @@ Auth0 can be used as a third-party authentication provider alongside Supabase Au
|
||||
## 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).
|
||||
2. Add a new Third-party Auth integration in your project's [Authentication settings](/dashboard/project/_/auth/third-party).
|
||||
3. Assign the `role: 'authenticated'` custom claim to all JWTs by using an Auth0 Action.
|
||||
4. Finally setup the Supabase client in your application.
|
||||
|
||||
@@ -128,7 +128,7 @@ val supabase = createSupabaseClient(
|
||||
|
||||
## 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 dashboard navigate to your project's [Authentication settings](/dashboard/project/_/auth/third-party) 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:
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ Amazon Cognito User Pools (via AWS Amplify or on its own) can be used as a third
|
||||
## 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.
|
||||
2. Add a new Third-party Auth integration in your project's [Authentication settings](/dashboard/project/_/auth/third-party) 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.
|
||||
|
||||
@@ -142,7 +142,7 @@ suspend fun getAccessToken(): String? {
|
||||
|
||||
## 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 dashboard navigate to your project's [Authentication settings](/dashboard/project/_/auth/third-party) 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:
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ Firebase Auth can be used as a third-party authentication provider alongside Sup
|
||||
## 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).
|
||||
2. Add a new Third-party Auth integration in your project's [Authentication settings](/dashboard/project/_/auth/third-party).
|
||||
3. If you are using Third Party Auth when self hosting, 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.
|
||||
@@ -143,7 +143,7 @@ val supabase = createSupabaseClient(
|
||||
|
||||
## 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 dashboard navigate to your project's [Authentication settings](/dashboard/project/_/auth/third-party) 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:
|
||||
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@ WorkOS can be used as a third-party authentication provider alongside Supabase A
|
||||
## Getting started
|
||||
|
||||
1. First you need to add an integration to connect your Supabase project with your WorkOS tenant. You will need your WorkOS issuer. The issuer is `https://api.workos.com/user_management/<your-client-id>`. Substitute your [custom auth domain](https://workos.com/docs/custom-domains/auth-api) for "api.workos.com" if configured.
|
||||
2. Add a new Third-party Auth integration in your project's [Authentication settings](/dashboard/project/_/settings/auth).
|
||||
2. Add a new Third-party Auth integration in your project's [Authentication settings](/dashboard/project/_/auth/third-party).
|
||||
3. Set up a JWT template to assign the `role: 'authenticated'` claim to your access token.
|
||||
|
||||
## Setup the Supabase client library
|
||||
@@ -43,7 +43,7 @@ const supabase = createClient(
|
||||
|
||||
## 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 dashboard navigate to your project's [Authentication settings](/dashboard/project/_/auth/third-party) and find the Third-Party Auth section to add a new integration.
|
||||
|
||||
## Set up a JWT template to add the authenticated role.
|
||||
|
||||
|
||||
@@ -14,6 +14,12 @@ Edge Functions have access to these secrets by default:
|
||||
- `SUPABASE_SERVICE_ROLE_KEY`: The `service_role` key for your Supabase API. This is safe to use in Edge Functions, but it should NEVER be used in a browser. This key will bypass Row Level Security
|
||||
- `SUPABASE_DB_URL`: The URL for your Postgres database. You can use this to connect directly to your database
|
||||
|
||||
In a hosted environment, functions have access to the following environment variables:
|
||||
|
||||
- `SB_REGION`: The region function was invoked
|
||||
- `SB_EXECUTION_ID`: A UUID of function instance ([isolate](/docs/guides/functions/architecture#4-execution-mechanics-fast-and-isolated))
|
||||
- `DENO_DEPLOYMENT_ID`: Version of the function code (`{project_ref}_{function_id}_{version}`)
|
||||
|
||||
---
|
||||
|
||||
## Accessing environment variables
|
||||
|
||||
@@ -47,7 +47,7 @@ hideToc: true
|
||||
|
||||
Copy the `.env.example` file to `.env` and update the values with your Supabase project URL and anon key.
|
||||
|
||||
Lastly, [enable anonymous sign-ins](/dashboard/project/_/settings/auth) in the Auth settings.
|
||||
Lastly, [enable anonymous sign-ins](/dashboard/project/_/auth/providers) in the Auth settings.
|
||||
|
||||
<ProjectConfigVariables variable="url" />
|
||||
<ProjectConfigVariables variable="publishableKey" />
|
||||
|
||||
@@ -44,7 +44,7 @@ By default, Mailpit is available at [localhost:54324](http://localhost:54324) wh
|
||||
|
||||
### Going into production
|
||||
|
||||
The "default" email provided by Supabase is only for development purposes. It is [heavily restricted](/docs/guides/platform/going-into-prod#auth-rate-limits) to ensure that it is not used for spam. Before going into production, you must configure your own email provider. This is as simple as enabling a new SMTP credentials in your [project settings](/dashboard/project/_/settings/auth).
|
||||
The "default" email provided by Supabase is only for development purposes. It is [heavily restricted](/docs/guides/platform/going-into-prod#auth-rate-limits) to ensure that it is not used for spam. Before going into production, you must configure your own email provider. This is as simple as enabling a new SMTP credentials in your [project settings](/dashboard/project/_/auth/smtp).
|
||||
|
||||
## Linting your database
|
||||
|
||||
|
||||
@@ -33,7 +33,10 @@ Target organization - the organization you want to move the project to
|
||||
|
||||
- You need to be the owner of the source organization.
|
||||
- You need to be at least a member of the target organization you want to move the project to.
|
||||
- Projects with support tier add-ons cannot be transferred at this point. [Open a support ticket](/dashboard/support/new?category=billing&subject=Transfer%20project).
|
||||
- No active GitHub integration connection
|
||||
- No project-scoped roles pointing to the project (Team/Enterprise plan)
|
||||
- No log drains configured
|
||||
- Target organization is not managed by Vercel Marketplace (currently unsupported)
|
||||
|
||||
## Usage-billing and project add-ons
|
||||
|
||||
|
||||
@@ -8,9 +8,19 @@ hideToc: true
|
||||
|
||||
Supabase provides a globally distributed [Realtime](https://github.com/supabase/realtime) service with the following features:
|
||||
|
||||
- [Broadcast](/docs/guides/realtime/broadcast): Send low-latency messages using the client libraries, REST, or your Database.
|
||||
- [Presence](/docs/guides/realtime/presence): Track and synchronize shared state between users.
|
||||
- [Postgres Changes](/docs/guides/realtime/postgres-changes): Listen to Database changes and send them to authorized users.
|
||||
- [Broadcast](/docs/guides/realtime/broadcast): Send low-latency messages between clients. Perfect for real-time messaging, database changes, cursor tracking, game events, and custom notifications.
|
||||
- [Presence](/docs/guides/realtime/presence): Track and synchronize user state across clients. Ideal for showing who's online, or active participants.
|
||||
- [Postgres Changes](/docs/guides/realtime/postgres-changes): Listen to database changes in real-time.
|
||||
|
||||
## What can you build?
|
||||
|
||||
- **Chat applications** - Real-time messaging with typing indicators and online presence
|
||||
- **Collaborative tools** - Document editing, whiteboards, and shared workspaces
|
||||
- **Live dashboards** - Real-time data visualization and monitoring
|
||||
- **Multiplayer games** - Synchronized game state and player interactions
|
||||
- **Social features** - Live notifications, reactions, and user activity feeds
|
||||
|
||||
Check the [Getting Started](/docs/guides/realtime/getting-started) guide to get started.
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
@@ -1,116 +1,92 @@
|
||||
---
|
||||
id: 'channels'
|
||||
id: 'concepts'
|
||||
title: 'Realtime Concepts'
|
||||
description: 'Learn about Channels and other extensions in Supabase Realtime'
|
||||
subtitle: 'Learn about Channels and other extensions in Supabase Realtime'
|
||||
sidebar_label: 'Concepts'
|
||||
description: 'Useful concepts to understand Realtime and how it works'
|
||||
---
|
||||
|
||||
You can use Supabase Realtime to build real-time applications with collaborative/multiplayer functionality. It includes 3 core extensions:
|
||||
## Concepts
|
||||
|
||||
- [Broadcast](/docs/guides/realtime/broadcast): sends rapid, ephemeral messages to other connected clients. You can use it to track mouse movements, for example.
|
||||
- [Presence](/docs/guides/realtime/presence): sends user state between connected clients. You can use it to show an "online" status, which disappears when a user is disconnected.
|
||||
- [Postgres Changes](/docs/guides/realtime/postgres-changes): receives database changes in real-time.
|
||||
There are several concepts and terminology that is useful to understand how Realtime works.
|
||||
|
||||
- **Channels**: the foundation of Realtime. Think of them as rooms where clients can communicate and listen to events. Channels are identified by a topic name and if they are public or private.
|
||||
- **Topics**: the name of the channel. They are used to identify the channel and are a string used to identify the channel.
|
||||
- **Events**: the type of messages that can be sent and received.
|
||||
- **Payload**: the actual data that is sent and received and that the user will act upon.
|
||||
- **Concurrent Connections**: number of total channels subscribed for all clients.
|
||||
|
||||
## Channels
|
||||
|
||||
A Channel is the basic building block of Realtime. You can think of a Channel as a chatroom, similar to a Discord or Slack channel, where participants are able to see who's online and send and receive messages.
|
||||
Channels are the foundation of Realtime. Think of them as rooms where clients can communicate and listen to events. Channels are identified by a topic name and if they are public or private.
|
||||
|
||||
When you initialize your Supabase Realtime client, you define a `topic` that uniquely references a channel. Clients can bi-directionally send and receive messages over a Channel.
|
||||
For private channels, you need to use [Realtime Authorization](/docs/guides/realtime/authorization) to control access to the channel and if they are able to send messages.
|
||||
For public channels, any user can subscribe to the channel, send and receive messages.
|
||||
|
||||
```js
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
You can set your project to use only private channels or both private and public channels in the [Realtime Settings](/docs/guides/realtime/settings).
|
||||
|
||||
const supabase = createClient('https://<project>.supabase.co', '<sb_publishable_... or anon key>')
|
||||
<Admonition type="note">
|
||||
|
||||
const roomOne = supabase.channel('room-one') // set your topic here
|
||||
```
|
||||
If you have a private channel and a public channel with the same topic name, Realtime sees them as unique channels and won't send messages between them.
|
||||
|
||||
## Authorization
|
||||
</Admonition>
|
||||
|
||||
Authorization is done via RLS policies against the table `realtime.messages` which will determine if a user can connect to a Channel and if they are allowed to send messages to a Channel.
|
||||
## Database resources
|
||||
|
||||
By default, channels are public and you need to set that you want to use a private channel.
|
||||
Realtime uses several database connections to perform several operations. As a user, you are able to tune some of them using [Realtime Settings](/docs/guides/realtime/settings).
|
||||
|
||||
```js
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
const supabase = createClient('https://<project>.supabase.co', '<sb_publishable_... or anon key>')
|
||||
const roomOne = supabase.channel('private-room-one', { config: { private: true } })
|
||||
```
|
||||
### Database connections
|
||||
|
||||
## Broadcast
|
||||
Realtime uses several database connections to do several operations. Some of them, as a user, you are able to tune them.
|
||||
|
||||
Realtime Broadcast follows the [publish-subscribe pattern](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) where a client publishes messages to a channel based on a unique topic. For example, a user could send a message to a channel with topic `room-one`.
|
||||
The connections are:
|
||||
|
||||
```js
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
const supabase = createClient('https://<project>.supabase.co', '<sb_publishable_... or anon key>')
|
||||
const roomOne = supabase.channel('room-one') // set your topic here
|
||||
- **Migrations**: Two temporary connections to run database migrations when needed
|
||||
- **Authorization**: Configurable connection pool to check authorization policies on join
|
||||
- **Postgres Changes**: 3 connection pools required
|
||||
- **Subscription management**: To manage the subscribers to Postgres Changes
|
||||
- **Subscription cleanup**: To cleanup the subscribers to Postgres Changes
|
||||
- **WAL pull**: To pull the changes from the database
|
||||
|
||||
// ---cut---
|
||||
roomOne.send({
|
||||
type: 'broadcast',
|
||||
event: 'test',
|
||||
payload: { message: 'hello, world' },
|
||||
})
|
||||
```
|
||||
The number of connections varies based on the instance size and your configuration in [Realtime Settings](/docs/guides/realtime/settings).
|
||||
|
||||
Other clients can receive the message in real-time by subscribing to the Channel with topic `room-one`. These clients continue to receive messages as long as they are subscribed and connected to the same Channel topic.
|
||||
### Replication slots
|
||||
|
||||
You can also use [Broadcast using the REST API](/docs/guides/realtime/broadcast#broadcast-using-the-rest-api) and [Broadcast using the Database](/docs/guides/realtime/broadcast#broadcast-using-the-database) to send messages to a Channel which allows you to do more advanced use-cases.
|
||||
Realtime also uses, at maximum, 2 replication slots.
|
||||
|
||||
An example use-case is sharing a user's cursor position with other clients in an online game.
|
||||
- **Broadcast from database**: To broadcast the changes from the database to the clients
|
||||
- **Postgres Changes**: To listen to changes from the database
|
||||
|
||||
## Presence
|
||||
### Schema and tables
|
||||
|
||||
Presence can be used to share an individual's state with others within a Channel.
|
||||
The `realtime` schema creates the following tables:
|
||||
|
||||
```js
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
const supabase = createClient('https://<project>.supabase.co', '<sb_publishable_... or anon key>')
|
||||
const roomOne = supabase.channel('room-one') // set your topic here
|
||||
- `schema_migrations` - To track the migrations that have been run on the database from Realtime
|
||||
- `subscription` - Track the subscribers to Postgres Changes
|
||||
- `messages` - Partitioned table per day that's used for Authorization and Broadcast from database
|
||||
- **Authorization**: To check the authorization policies on join by checking if a given user can read and write to this table
|
||||
- **Broadcast from database**: Replication slot tracks a publication to this table to broadcast the changes to the connected clients.
|
||||
- The schema from the table is the following:
|
||||
```sql
|
||||
create table realtime.messages (
|
||||
topic text not null, -- The topic of the message
|
||||
extension text not null, -- The extension of the message (presence, broadcast)
|
||||
payload jsonb null, -- The payload of the message
|
||||
event text null, -- The event of the message
|
||||
private boolean null default false, -- If the message is going to use a private channel
|
||||
updated_at timestamp without time zone not null default now(), -- The timestamp of the message
|
||||
inserted_at timestamp without time zone not null default now(), -- The timestamp of the message
|
||||
id uuid not null default gen_random_uuid (), -- The id of the message
|
||||
constraint messages_pkey primary key (id, inserted_at)) partition by RANGE (inserted_at);
|
||||
```
|
||||
|
||||
// ---cut---
|
||||
const presenceTrackStatus = await roomOne.track({
|
||||
user: 'user-1',
|
||||
online_at: new Date().toISOString(),
|
||||
})
|
||||
```
|
||||
<Admonition type="note">
|
||||
|
||||
{/* supa-mdx-lint-disable-next-line Rule004ExcludeWords */}
|
||||
Each client maintains their own state, and this is then combined into a "shared state" for that Channel topic. It's commonly used for sharing statuses (e.g. "online" or "inactive"). The neat thing about Presence is that if a client is suddenly disconnected (for example, they go offline), their state is automatically removed from the shared state. If you've ever tried to build an “I'm online” feature which handles unexpected disconnects, you'll appreciate how useful this is.
|
||||
When a new client subscribes to a channel, it will immediately receive the channel's latest state in a single message because the state is held by the Realtime server.
|
||||
Realtime has a cleanup process that will delete tables older than 3 days.
|
||||
|
||||
## Postgres Changes
|
||||
</Admonition>
|
||||
|
||||
The Postgres Changes extension listens for database changes and sends them to clients. Clients are required to subscribe with a JWT dictating which changes they are allowed to receive based on the database's [Row Level Security](/docs/guides/database/postgres/row-level-security).
|
||||
### Functions
|
||||
|
||||
```js
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
const supabase = createClient('your_project_url', 'your_supabase_api_key')
|
||||
Realtime creates two functions on your database:
|
||||
|
||||
// ---cut---
|
||||
const allChanges = supabase
|
||||
.channel('schema-db-changes')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
},
|
||||
(payload) => console.log(payload)
|
||||
)
|
||||
.subscribe()
|
||||
```
|
||||
|
||||
Anyone with access to a valid JWT signed with the project's JWT secret is able to listen to your database's changes, unless tables have [Row Level Security](/docs/guides/database/postgres/row-level-security) enabled and policies in place.
|
||||
|
||||
Clients can choose to receive `INSERT`, `UPDATE`, `DELETE`, or `*` (all) changes for all changes in a schema, a table in a schema, or a column's value in a table. Your clients should only listen to tables in the `public` schema and you must first enable the tables you want your clients to listen to.
|
||||
|
||||
## Choosing between Broadcast and Postgres Changes for database changes
|
||||
|
||||
We recommend using Broadcast by default and using Broadcast from Database specifically as it will allow you to scale your application compared to Postgres Changes.
|
||||
|
||||
## Choosing between Broadcast and Presence
|
||||
|
||||
We recommend using Broadcast by default, and then Presence when required. Presence utilizes an in-memory conflict-free replicated data type (CRDT) to track and synchronize shared state in an eventually consistent manner. It computes the difference between existing state and new state changes and sends the necessary updates to clients via Broadcast. This is computationally heavy, so you should use it sparingly. If you use Presence, it's best to throttle your changes so that you are sending updates less frequently.
|
||||
- `realtime.send` - Inserts an entry into `realtime.messages` table that will trigger the replication slot to broadcast the changes to the clients. It also captures errors to prevent the trigger from breaking.
|
||||
- `realtime.broadcast_changes` - uses `realtime.send` to broadcast the changes with a format that is compatible with Postgres Changes
|
||||
@@ -0,0 +1,667 @@
|
||||
---
|
||||
id: 'getting-started'
|
||||
title: 'Getting Started with Realtime'
|
||||
description: 'Learn how to build real-time applications with Supabase Realtime'
|
||||
subtitle: 'Learn how to build real-time applications with Supabase Realtime'
|
||||
sidebar_label: 'Getting Started'
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
### 1. Install the client library
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="ts"
|
||||
queryGroup="language"
|
||||
>
|
||||
<TabPanel id="ts" label="TypeScript">
|
||||
|
||||
```bash
|
||||
npm install @supabase/supabase-js
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="dart" label="Flutter">
|
||||
|
||||
```bash
|
||||
flutter pub add supabase_flutter
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="swift" label="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",
|
||||
package: "supabase-swift"
|
||||
),
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python - PIP">
|
||||
|
||||
```bash
|
||||
pip install supabase
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python - Conda">
|
||||
|
||||
```bash
|
||||
conda install -c conda-forge supabase
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
### 2. Initialize the client
|
||||
|
||||
Get your project URL and key.
|
||||
<$Partial path="api_settings.mdx" />
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="ts"
|
||||
queryGroup="language"
|
||||
>
|
||||
<TabPanel id="ts" label="TypeScript">
|
||||
|
||||
```ts
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabase = createClient('https://<project>.supabase.co', '<anon_key or sb_publishable_key>')
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="dart" label="Flutter">
|
||||
|
||||
```dart
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
void main() async {
|
||||
await Supabase.initialize(
|
||||
url: 'https://<project>.supabase.co',
|
||||
anonKey: '<anon_key or sb_publishable_key>',
|
||||
);
|
||||
runApp(MyApp());
|
||||
}
|
||||
|
||||
final supabase = Supabase.instance.client;
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="swift" label="Swift">
|
||||
|
||||
```swift
|
||||
import Supabase
|
||||
|
||||
let supabase = SupabaseClient(
|
||||
supabaseURL: URL(string: "https://<project>.supabase.co")!,
|
||||
supabaseKey: "<anon_key or sb_publishable_key>"
|
||||
)
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
```python
|
||||
from supabase import create_client, Client
|
||||
|
||||
url: str = "https://<project>.supabase.co"
|
||||
key: str = "<anon_key or sb_publishable_key>"
|
||||
supabase: Client = create_client(url, key)
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
### 3. Create your first Channel
|
||||
|
||||
Channels are the foundation of Realtime. Think of them as rooms where clients can communicate. Each channel is identified by a topic name and if they are public or private.
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="ts"
|
||||
queryGroup="language"
|
||||
>
|
||||
<TabPanel id="ts" label="TypeScript">
|
||||
|
||||
```ts
|
||||
// Create a channel with a descriptive topic name
|
||||
const channel = supabase.channel('room:lobby:messages', {
|
||||
config: { private: true }, // Recommended for production
|
||||
})
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="dart" label="Flutter">
|
||||
|
||||
```dart
|
||||
// Create a channel with a descriptive topic name
|
||||
final channel = supabase.channel('room:lobby:messages');
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="swift" label="Swift">
|
||||
|
||||
```swift
|
||||
// Create a channel with a descriptive topic name
|
||||
let channel = supabase.channel("room:lobby:messages") {
|
||||
$0.isPrivate = true
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
```python
|
||||
# Create a channel with a descriptive topic name
|
||||
channel = supabase.channel('room:lobby:messages', params={config={private= True }})
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
### 4. Set up authorization
|
||||
|
||||
Since we're using a private channel, you need to create a basic RLS policy on the `realtime.messages` table to allow authenticated users to connect. Row Level Security (RLS) policies control who can access your Realtime channels based on user authentication and custom rules:
|
||||
|
||||
```sql
|
||||
-- Allow authenticated users to receive broadcasts
|
||||
CREATE POLICY "authenticated_users_can_receive" ON realtime.messages
|
||||
FOR SELECT TO authenticated USING (true);
|
||||
|
||||
-- Allow authenticated users to send broadcasts
|
||||
CREATE POLICY "authenticated_users_can_send" ON realtime.messages
|
||||
FOR INSERT TO authenticated WITH CHECK (true);
|
||||
```
|
||||
|
||||
### 5. Send and receive messages
|
||||
|
||||
There are three main ways to send messages with Realtime:
|
||||
|
||||
#### 5.1 using client libraries
|
||||
|
||||
Send and receive messages using the Supabase client:
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="ts"
|
||||
queryGroup="language"
|
||||
>
|
||||
<TabPanel id="ts" label="TypeScript">
|
||||
|
||||
```ts
|
||||
// Listen for messages
|
||||
channel
|
||||
.on('broadcast', { event: 'message_sent' }, (payload: { payload: any }) => {
|
||||
console.log('New message:', payload.payload)
|
||||
})
|
||||
.subscribe()
|
||||
|
||||
// Send a message
|
||||
channel.send({
|
||||
type: 'broadcast',
|
||||
event: 'message_sent',
|
||||
payload: {
|
||||
text: 'Hello, world!',
|
||||
user: 'john_doe',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="dart" label="Flutter">
|
||||
|
||||
```dart
|
||||
// Listen for messages
|
||||
channel.onBroadcast(
|
||||
event: 'message_sent',
|
||||
callback: (payload) {
|
||||
print('New message: ${payload['payload']}');
|
||||
},
|
||||
).subscribe();
|
||||
|
||||
// Send a message
|
||||
channel.sendBroadcastMessage(
|
||||
event: 'message_sent',
|
||||
payload: {
|
||||
'text': 'Hello, world!',
|
||||
'user': 'john_doe',
|
||||
'timestamp': DateTime.now().toIso8601String(),
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="swift" label="Swift">
|
||||
|
||||
```swift
|
||||
// Listen for messages
|
||||
await channel.onBroadcast(event: "message_sent") { message in
|
||||
print("New message: \(message.payload)")
|
||||
}
|
||||
|
||||
let status = await channel.subscribe()
|
||||
|
||||
// Send a message
|
||||
await channel.sendBroadcastMessage(
|
||||
event: "message_sent",
|
||||
payload: [
|
||||
"text": "Hello, world!",
|
||||
"user": "john_doe",
|
||||
"timestamp": ISO8601DateFormatter().string(from: Date())
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
```python
|
||||
# Listen for messages
|
||||
def message_handler(payload):
|
||||
print(f"New message: {payload['payload']}")
|
||||
|
||||
channel.on_broadcast(event="message_sent", callback=message_handler).subscribe()
|
||||
|
||||
# Send a message
|
||||
channel.send_broadcast_message(
|
||||
event="message_sent",
|
||||
payload={
|
||||
"text": "Hello, world!",
|
||||
"user": "john_doe",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
#### 5.2 using HTTP/REST API
|
||||
|
||||
Send messages via HTTP requests, perfect for server-side applications:
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="ts"
|
||||
queryGroup="language"
|
||||
>
|
||||
<TabPanel id="ts" label="TypeScript">
|
||||
|
||||
```ts
|
||||
// Send message via REST API
|
||||
const response = await fetch(`https://<project>.supabase.co/rest/v1/rpc/broadcast`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer <your-service-role-key>`,
|
||||
apikey: '<your-service-role-key>',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
topic: 'room:lobby:messages',
|
||||
event: 'message_sent',
|
||||
payload: {
|
||||
text: 'Hello from server!',
|
||||
user: 'system',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
private: true,
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="dart" label="Flutter">
|
||||
|
||||
```dart
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'dart:convert';
|
||||
|
||||
// Send message via REST API
|
||||
final response = await http.post(
|
||||
Uri.parse('https://<project>.supabase.co/rest/v1/rpc/broadcast'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer <your-service-role-key>',
|
||||
'apikey': '<your-service-role-key>',
|
||||
},
|
||||
body: jsonEncode({
|
||||
'topic': 'room:lobby:messages',
|
||||
'event': 'message_sent',
|
||||
'payload': {
|
||||
'text': 'Hello from server!',
|
||||
'user': 'system',
|
||||
'timestamp': DateTime.now().toIso8601String(),
|
||||
},
|
||||
'private': true,
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="swift" label="Swift">
|
||||
|
||||
```swift
|
||||
import Foundation
|
||||
|
||||
// Send message via REST API
|
||||
let url = URL(string: "https://<project>.supabase.co/rest/v1/rpc/broadcast")!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("Bearer <your-service-role-key>", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("<your-service-role-key>", forHTTPHeaderField: "apikey")
|
||||
|
||||
let payload = [
|
||||
"topic": "room:lobby:messages",
|
||||
"event": "message_sent",
|
||||
"payload": [
|
||||
"text": "Hello from server!",
|
||||
"user": "system",
|
||||
"timestamp": ISO8601DateFormatter().string(from: Date())
|
||||
],
|
||||
"private": true
|
||||
] as [String: Any]
|
||||
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: payload)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
```python
|
||||
import requests
|
||||
from datetime import datetime
|
||||
|
||||
# Send message via REST API
|
||||
response = requests.post(
|
||||
'https://<project>.supabase.co/rest/v1/rpc/broadcast',
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer <your-service-role-key>',
|
||||
'apikey': '<your-service-role-key>'
|
||||
},
|
||||
json={
|
||||
'topic': 'room:lobby:messages',
|
||||
'event': 'message_sent',
|
||||
'payload': {
|
||||
'text': 'Hello from server!',
|
||||
'user': 'system',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
},
|
||||
'private': True
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
#### 5.3 using database triggers
|
||||
|
||||
Automatically broadcast database changes using triggers. Choose the approach that best fits your needs:
|
||||
|
||||
**Using `realtime.broadcast_changes` (Best for mirroring database changes)**
|
||||
|
||||
```sql
|
||||
-- Create a trigger function for broadcasting database changes
|
||||
CREATE OR REPLACE FUNCTION broadcast_message_changes()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Broadcast to room-specific channel
|
||||
PERFORM realtime.broadcast_changes(
|
||||
'room:' || NEW.room_id::text || ':messages',
|
||||
TG_OP,
|
||||
TG_OP,
|
||||
TG_TABLE_NAME,
|
||||
TG_TABLE_SCHEMA,
|
||||
NEW,
|
||||
OLD
|
||||
);
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- Apply trigger to your messages table
|
||||
CREATE TRIGGER messages_broadcast_trigger
|
||||
AFTER INSERT OR UPDATE OR DELETE ON messages
|
||||
FOR EACH ROW EXECUTE FUNCTION broadcast_message_changes();
|
||||
```
|
||||
|
||||
**Using `realtime.send` (Best for custom notifications and filtered data)**
|
||||
|
||||
```sql
|
||||
-- Create a trigger function for custom notifications
|
||||
CREATE OR REPLACE FUNCTION notify_message_activity()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Send custom notification when new message is created
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
PERFORM realtime.send(
|
||||
'room:' || NEW.room_id::text || ':notifications',
|
||||
'message_created',
|
||||
jsonb_build_object(
|
||||
'message_id', NEW.id,
|
||||
'user_id', NEW.user_id,
|
||||
'room_id', NEW.room_id,
|
||||
'created_at', NEW.created_at
|
||||
),
|
||||
true -- private channel
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- Apply trigger to your messages table
|
||||
CREATE TRIGGER messages_notification_trigger
|
||||
AFTER INSERT ON messages
|
||||
FOR EACH ROW EXECUTE FUNCTION notify_message_activity();
|
||||
```
|
||||
|
||||
- **`realtime.broadcast_changes`** sends the full database change with metadata
|
||||
- **`realtime.send`** allows you to send custom payloads and control exactly what data is broadcast
|
||||
|
||||
## Essential best practices
|
||||
|
||||
### Use private channels
|
||||
|
||||
Always use private channels for production applications to ensure proper security and authorization:
|
||||
|
||||
```ts
|
||||
const channel = supabase.channel('room:123:messages', {
|
||||
config: { private: true },
|
||||
})
|
||||
```
|
||||
|
||||
### Follow naming conventions
|
||||
|
||||
**Channel Topics:** Use the pattern `scope:id:entity`
|
||||
|
||||
- `room:123:messages` - Messages in room 123
|
||||
- `game:456:moves` - Game moves for game 456
|
||||
- `user:789:notifications` - Notifications for user 789
|
||||
|
||||
### Clean up subscriptions
|
||||
|
||||
Always unsubscribe when you are done with a channel to ensure you free up resources:
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="ts"
|
||||
queryGroup="language"
|
||||
>
|
||||
<TabPanel id="ts" label="TypeScript">
|
||||
|
||||
```ts
|
||||
// React example
|
||||
import { useEffect } from 'react'
|
||||
|
||||
useEffect(() => {
|
||||
const channel = supabase.channel('room:123:messages')
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel)
|
||||
}
|
||||
}, [])
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="dart" label="Flutter">
|
||||
|
||||
```dart
|
||||
// Flutter example
|
||||
class _MyWidgetState extends State<MyWidget> {
|
||||
RealtimeChannel? _channel;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_channel = supabase.channel('room:123:messages');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_channel?.unsubscribe();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="swift" label="Swift">
|
||||
|
||||
```swift
|
||||
// SwiftUI example
|
||||
struct ContentView: View {
|
||||
@State private var channel: RealtimeChannelV2?
|
||||
|
||||
var body: some View {
|
||||
// Your UI here
|
||||
.onAppear {
|
||||
channel = supabase.realtimeV2.channel("room:123:messages")
|
||||
}
|
||||
.onDisappear {
|
||||
Task {
|
||||
await channel?.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
```python
|
||||
# Python example with context manager
|
||||
class RealtimeManager:
|
||||
def __init__(self):
|
||||
self.channel = None
|
||||
|
||||
def __enter__(self):
|
||||
self.channel = supabase.channel('room:123:messages')
|
||||
return self.channel
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
if self.channel:
|
||||
self.channel.unsubscribe()
|
||||
|
||||
# Usage
|
||||
with RealtimeManager() as channel:
|
||||
# Use channel here
|
||||
pass
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
## Choose the right feature
|
||||
|
||||
### When to use Broadcast
|
||||
|
||||
- Real-time messaging and notifications
|
||||
- Custom events and game state
|
||||
- Database change notifications (with triggers)
|
||||
- High-frequency updates (e.g. Cursor tracking)
|
||||
- Most use cases
|
||||
|
||||
### When to use Presence
|
||||
|
||||
- User online/offline status
|
||||
- Active user counters
|
||||
- Use minimally due to computational overhead
|
||||
|
||||
### When to use Postgres Changes
|
||||
|
||||
- Quick testing and development
|
||||
- Low amount of connected users
|
||||
|
||||
## Next steps
|
||||
|
||||
Now that you understand the basics, dive deeper into each feature:
|
||||
|
||||
### Core features
|
||||
|
||||
- **[Broadcast](/docs/guides/realtime/broadcast)** - Learn about sending messages, database triggers, and REST API usage
|
||||
- **[Presence](/docs/guides/realtime/presence)** - Implement user state tracking and online indicators
|
||||
- **[Postgres Changes](/docs/guides/realtime/postgres-changes)** - Understanding database change listeners (consider migrating to Broadcast)
|
||||
|
||||
### Security & configuration
|
||||
|
||||
- **[Authorization](/docs/guides/realtime/authorization)** - Set up RLS policies for private channels
|
||||
- **[Settings](/docs/guides/realtime/settings)** - Configure your Realtime instance for optimal performance
|
||||
|
||||
### Advanced topics
|
||||
|
||||
- **[Architecture](/docs/guides/realtime/architecture)** - Understand how Realtime works under the hood
|
||||
- **[Benchmarks](/docs/guides/realtime/benchmarks)** - Performance characteristics and scaling considerations
|
||||
- **[Quotas](/docs/guides/realtime/quotas)** - Usage limits and best practices
|
||||
|
||||
### Integration guides
|
||||
|
||||
- **[Realtime with Next.js](/docs/guides/realtime/realtime-with-nextjs)** - Build real-time Next.js applications
|
||||
- **[User Presence](/docs/guides/realtime/realtime-user-presence)** - Implement user presence features
|
||||
- **[Database Changes](/docs/guides/realtime/subscribing-to-database-changes)** - Listen to database changes
|
||||
|
||||
### Framework examples
|
||||
|
||||
- **[Flutter Integration](/docs/guides/realtime/realtime-listening-flutter)** - Build real-time Flutter applications
|
||||
|
||||
Ready to build something amazing? Start with the [Broadcast guide](/docs/guides/realtime/broadcast) to create your first real-time feature!
|
||||
@@ -191,7 +191,7 @@ System messages are sent by the server to inform the client about the status of
|
||||
|
||||
#### Payload of heartbeat
|
||||
|
||||
The heartbeat message should be sent at least every 30 seconds to avoid a connection timeout. Payload should be empty object.
|
||||
The heartbeat message should be sent at least every 25 seconds to avoid a connection timeout. Payload should be empty object.
|
||||
|
||||
#### Payload of access_token
|
||||
|
||||
|
||||
@@ -26,6 +26,12 @@ const supabase = createClient('your_project_url', 'your_supabase_api_key')
|
||||
await supabase.storage.from('bucket').remove(['object-path-2', 'folder/avatar2.png'])
|
||||
```
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
When deleting objects, there is a limit of 1000 objects at a time using the `remove` method.
|
||||
|
||||
</Admonition>
|
||||
|
||||
## RLS
|
||||
|
||||
To delete an object, the user must have the `delete` permission on the object. For example:
|
||||
|
||||
@@ -20,7 +20,7 @@ You can read about Unified egress, included quota, and how to check the egress u
|
||||
While pointing out the exact cause for egress may not be straightforward, there are various steps you can take to determine the source of these issues:
|
||||
|
||||
- Picking the "Top Paths" from the [log explorer](https://app.supabase.com/project/_/logs/explorer/templates) will help you identify heavily queried paths
|
||||
- By finding the most requested queries from Query performance report: https://app.supabase.com/project/_/reports/query-performance
|
||||
- By finding the most requested queries from Query performance report: https://app.supabase.com/project/_/advisors/query-performance
|
||||
- Supavisor Egress is independent of client. There is no direct relation between a single query and Supavisor egress, it is harder to debug and identify. But you can make use of frequent queries from the link in above step that also displays average number of rows which will help to identify queries with a large number of rows returned. While this does not display Supavisor queries specifically, it will give an overview of queries with lots of rows that can help.
|
||||
- For Storage Egress, all outgoing traffic for storage-related requests to download/view your Storage items are considered as Storage egress. We have a "Storage Egress Requests" template in logs explorer that you can use to get the number of requests for each Storage object
|
||||
- If you pull 1mb of data out of the database using the Supavisor connection in your edge function, but only sends 100kb back to the user, you will have the Egress from the Supavisor to your Edge function plus from the edge function to your user
|
||||
|
||||
+1
-1
@@ -13,6 +13,6 @@ You can run this query to check the current settings set for your roles: `SELECT
|
||||
|
||||
To increase the `statement_timeout` for a specific role, you may follow the instructions [here](/docs/guides/database/timeouts#changing-the-default-timeout). Note that it may require a quick reboot for the changes to take effect.
|
||||
|
||||
Additionally, to check how long a query is taking, you can check the Query Performance report which can give you more information on the query's performance: https://app.supabase.com/project/_/reports/query-performance. You can use the [query plan analyzer](https://www.postgresql.org/docs/current/sql-explain.html) on any expensive queries that you have identified: `explain analyze <query-statement-here>;`. For supabase-js/ PostgREST queries you can use `.explain()`.
|
||||
Additionally, to check how long a query is taking, you can check the Query Performance report which can give you more information on the query's performance: https://app.supabase.com/project/_/advisors/query-performance. You can use the [query plan analyzer](https://www.postgresql.org/docs/current/sql-explain.html) on any expensive queries that you have identified: `explain analyze <query-statement-here>;`. For supabase-js/ PostgREST queries you can use `.explain()`.
|
||||
|
||||
You can also make use of Postgres logs that will give you useful information like when the query was executed: https://app.supabase.com/project/_/logs/postgres-logs.
|
||||
+1
-1
@@ -10,7 +10,7 @@ database_id = "1da315be-38ac-487a-b178-f865a1a73c09"
|
||||
message = "insufficient privilege"
|
||||
---
|
||||
|
||||
If you see the error "insufficient privilege" when accessing [pg_stat_statements](/docs/guides/platform/performance#postgres-cumulative-statistics-system) or when accessing [Query Performance Report](/dashboard/project/_/reports/query-performance), it means that the Postgres role does not have required permissions.
|
||||
If you see the error "insufficient privilege" when accessing [pg_stat_statements](/docs/guides/platform/performance#postgres-cumulative-statistics-system) or when accessing [Query Performance Report](/dashboard/project/_/advisors/query-performance), it means that the Postgres role does not have required permissions.
|
||||
|
||||
In this case, you can run the below command to allow the Postgres role to read all statistics from the system:
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { HTMLAttributes, PropsWithChildren } from 'react'
|
||||
import { useContext, useEffect, useRef, useState } from 'react'
|
||||
import { useInView } from 'react-intersection-observer'
|
||||
import { safeHistoryReplaceState } from '~/lib/historyUtils'
|
||||
|
||||
import {
|
||||
cn,
|
||||
@@ -44,7 +45,7 @@ export function ReferenceSectionWrapper({
|
||||
initialScrollHappened &&
|
||||
window.scrollY > 0 /* Don't update on first navigation to introduction */
|
||||
) {
|
||||
window.history.replaceState(null, '', link)
|
||||
safeHistoryReplaceState(link)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useNavigationMenuContext } from '~/components/Navigation/NavigationMenu
|
||||
import { menuState } from '~/hooks/useMenuState'
|
||||
import Image from 'next/legacy/image'
|
||||
import { cn } from 'ui'
|
||||
import { safeHistoryReplaceState } from '~/lib/historyUtils'
|
||||
|
||||
interface ISectionContainer {
|
||||
id: string
|
||||
@@ -97,7 +98,7 @@ const StickyHeader: FC<StickyHeader> = ({ icon, ...props }) => {
|
||||
onChange: (inView, entry) => {
|
||||
if (inView && window) highlightSelectedNavItem(entry.target.attributes['data-ref-id'].value)
|
||||
if (inView && props.scrollSpyHeader) {
|
||||
window.history.replaceState(null, '', entry.target.id)
|
||||
safeHistoryReplaceState(entry.target.id)
|
||||
// if (setActiveRefItem) setActiveRefItem(entry.target.attributes['data-ref-id'].value)
|
||||
menuState.setMenuActiveRefId(entry.target.attributes['data-ref-id'].value)
|
||||
// router.push(`/reference/javascript/${entry.target.attributes['data-ref-id'].value}`, null, {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { highlightSelectedNavItem } from 'ui/src/components/CustomHTMLElements/C
|
||||
import { useRouter } from 'next/compat/router'
|
||||
import { useNavigationMenuContext } from '~/components/Navigation/NavigationMenu/NavigationMenu.Context'
|
||||
import { menuState } from '~/hooks/useMenuState'
|
||||
import { safeHistoryReplaceState } from '~/lib/historyUtils'
|
||||
|
||||
interface ISectionContainer {
|
||||
id: string
|
||||
@@ -60,7 +61,7 @@ const StickyHeader: FC<StickyHeader> = (props) => {
|
||||
onChange: (inView, entry) => {
|
||||
if (inView && window) highlightSelectedNavItem(entry.target.attributes['data-ref-id'].value)
|
||||
if (inView && props.scrollSpyHeader) {
|
||||
window.history.replaceState(null, '', entry.target.id)
|
||||
safeHistoryReplaceState(entry.target.id)
|
||||
// if (setActiveRefItem) setActiveRefItem(entry.target.attributes['data-ref-id'].value)
|
||||
menuState.setMenuActiveRefId(entry.target.attributes['data-ref-id'].value)
|
||||
// router.push(`/reference/javascript/${entry.target.attributes['data-ref-id'].value}`, null, {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { debounce } from 'lodash-es'
|
||||
|
||||
export const safeHistoryReplaceState = debounce((url: string) => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
if (url === window.location.href) return
|
||||
|
||||
try {
|
||||
window.history.replaceState(null, '', url)
|
||||
} catch (error) {
|
||||
console.warn('Failed to call history.replaceState:', error)
|
||||
}
|
||||
}, 120)
|
||||
Vendored
-5
@@ -1,5 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -156,9 +156,10 @@ const nextConfig = {
|
||||
]
|
||||
},
|
||||
typescript: {
|
||||
// WARNING: production builds can successfully complete even there are type errors
|
||||
// Typechecking is checked separately via .github/workflows/typecheck.yml
|
||||
ignoreBuildErrors: true,
|
||||
// On previews, typechecking is run via GitHub Action only for efficiency
|
||||
// On production, we turn it on to prevent errors from conflicting PRs getting into
|
||||
// prod
|
||||
ignoreBuildErrors: process.env.NEXT_PUBLIC_VERCEL_ENV === 'production' ? false : true,
|
||||
},
|
||||
eslint: {
|
||||
// We are already running linting via GH action, this will skip linting during production build on Vercel
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
"dev:secrets:pull": "AWS_PROFILE=supa-dev node ../../scripts/getSecrets.js -n local/docs",
|
||||
"dev:watch:troubleshooting": "node ./scripts/troubleshooting/watch.mjs",
|
||||
"embeddings": "tsx --conditions=react-server scripts/search/generate-embeddings.ts",
|
||||
"embeddings:nimbus": "ENABLED_FEATURES_OVERRIDE_DISABLE_ALL=true pnpm run embeddings",
|
||||
"embeddings:refresh": "pnpm run embeddings --refresh",
|
||||
"embeddings:nimbus:refresh": "ENABLED_FEATURES_OVERRIDE_DISABLE_ALL=true pnpm run embeddings:refresh",
|
||||
"last-changed": "tsx scripts/last-changed.ts",
|
||||
"last-changed:reset": "pnpm run last-changed -- --reset",
|
||||
"lint": "next lint",
|
||||
@@ -29,7 +31,7 @@
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"presync": "pnpm run codegen:graphql",
|
||||
"pretest": "pnpm run codegen:examples",
|
||||
"pretypecheck": "pnpm run codegen:graphql",
|
||||
"pretypecheck": "pnpm run codegen:graphql && next typegen",
|
||||
"start": "next start",
|
||||
"sync": "tsx --conditions=react-server ./resources/rootSync.ts",
|
||||
"test": "pnpm supabase start && pnpm run test:local && pnpm supabase stop",
|
||||
|
||||
@@ -50,6 +50,7 @@ Emmett Folger
|
||||
Eric Kharitonashvili
|
||||
Etienne Stalmans
|
||||
Fabrizio Fenoglio
|
||||
Fady A
|
||||
Felipe Stival
|
||||
Francesco Sansalvadore
|
||||
Greg Kress
|
||||
@@ -138,6 +139,7 @@ Tyler Fontaine
|
||||
Tyler Shukert
|
||||
TzeYiing L
|
||||
Wen Bo Xie
|
||||
Yuliya Marinova
|
||||
Yuri Santana
|
||||
____________
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 80 KiB |
@@ -3,6 +3,8 @@ import { convertPostgrestToApiError, type ApiErrorGeneric } from '~/app/api/util
|
||||
import { Result } from '~/features/helpers.fn'
|
||||
import { openAI } from '~/lib/openAi'
|
||||
import { supabase, type DatabaseCorrected } from '~/lib/supabase'
|
||||
|
||||
import { isFeatureEnabled } from '../../../../packages/common/enabled-features'
|
||||
import { GuideModel } from '../guide/guideModel'
|
||||
import {
|
||||
DB_METADATA_TAG_PLATFORM_CLI,
|
||||
@@ -13,6 +15,9 @@ import { ReferenceSDKFunctionModel, SDKLanguageValues } from '../reference/refer
|
||||
import { TroubleshootingModel } from '../troubleshooting/troubleshootingModel'
|
||||
import { SearchResultInterface } from './globalSearchInterface'
|
||||
|
||||
type SearchFunction = 'search_content' | 'search_content_nimbus'
|
||||
type SearchHybridFunction = 'search_content_hybrid' | 'search_content_hybrid_nimbus'
|
||||
|
||||
export abstract class SearchResultModel {
|
||||
static async search(
|
||||
args: RootQueryTypeSearchDocsArgs,
|
||||
@@ -22,9 +27,14 @@ export abstract class SearchResultModel {
|
||||
const includeFullContent = requestedFields.includes('content')
|
||||
const embeddingResult = await openAI().createContentEmbedding(query)
|
||||
|
||||
const useAltSearchIndex = !isFeatureEnabled('search:fullIndex')
|
||||
const searchFunction: SearchFunction = useAltSearchIndex
|
||||
? 'search_content_nimbus'
|
||||
: 'search_content'
|
||||
|
||||
return embeddingResult.flatMapAsync(async ({ embedding }) => {
|
||||
const matchResult = new Result(
|
||||
await supabase().rpc('search_content', {
|
||||
await supabase().rpc(searchFunction, {
|
||||
embedding,
|
||||
include_full_content: includeFullContent,
|
||||
max_result: args.limit ?? undefined,
|
||||
@@ -49,9 +59,14 @@ export abstract class SearchResultModel {
|
||||
const includeFullContent = requestedFields.includes('content')
|
||||
const embeddingResult = await openAI().createContentEmbedding(query)
|
||||
|
||||
const useAltSearchIndex = !isFeatureEnabled('search:fullIndex')
|
||||
const searchFunction: SearchHybridFunction = useAltSearchIndex
|
||||
? 'search_content_hybrid_nimbus'
|
||||
: 'search_content_hybrid'
|
||||
|
||||
return embeddingResult.flatMapAsync(async ({ embedding }) => {
|
||||
const matchResult = new Result(
|
||||
await supabase().rpc('search_content_hybrid', {
|
||||
await supabase().rpc(searchFunction, {
|
||||
query_text: query,
|
||||
query_embedding: embedding,
|
||||
include_full_content: includeFullContent,
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
export interface PageInfo {
|
||||
pageId: number
|
||||
path: string
|
||||
checksum: string
|
||||
sectionsCount: number
|
||||
}
|
||||
|
||||
export interface PageSectionForEmbedding {
|
||||
pageId: number
|
||||
path: string
|
||||
slug?: string
|
||||
heading?: string
|
||||
content: string
|
||||
input: string
|
||||
ragIgnore: boolean
|
||||
}
|
||||
|
||||
export interface PageSectionWithEmbedding extends PageSectionForEmbedding {
|
||||
embedding: number[]
|
||||
}
|
||||
|
||||
export interface ProcessingResult {
|
||||
successfulPages: Set<number>
|
||||
failedPages: Set<number>
|
||||
totalSectionsProcessed: number
|
||||
totalSectionsInserted: number
|
||||
}
|
||||
|
||||
export function createBatches<T>(array: T[], batchSize: number): T[][] {
|
||||
const batches: T[][] = []
|
||||
for (let i = 0; i < array.length; i += batchSize) {
|
||||
batches.push(array.slice(i, i + batchSize))
|
||||
}
|
||||
return batches
|
||||
}
|
||||
|
||||
export function mapEmbeddingsToSections(
|
||||
batch: PageSectionForEmbedding[],
|
||||
data: Array<{ embedding?: number[] }>,
|
||||
batchNumber: number
|
||||
): {
|
||||
sectionsWithEmbeddings: PageSectionWithEmbedding[]
|
||||
failedSectionIndexes: Set<number>
|
||||
} {
|
||||
const sectionsWithEmbeddings: PageSectionWithEmbedding[] = []
|
||||
const failedSectionIndexes: Set<number> = new Set()
|
||||
|
||||
if (batch.length !== data.length) {
|
||||
console.error(
|
||||
`Ignoring all embeddings returned from batch ${batchNumber} because returned number doesn't match input number`
|
||||
)
|
||||
batch.forEach((_, index) => {
|
||||
failedSectionIndexes.add(index)
|
||||
})
|
||||
}
|
||||
|
||||
for (let i = 0; i < batch.length; i++) {
|
||||
if (data[i].embedding) {
|
||||
sectionsWithEmbeddings.push({ ...batch[i], embedding: data[i].embedding! })
|
||||
} else {
|
||||
failedSectionIndexes.add(i)
|
||||
}
|
||||
}
|
||||
|
||||
return { sectionsWithEmbeddings, failedSectionIndexes }
|
||||
}
|
||||
|
||||
export function updatePageInsertionCounts(
|
||||
pageSectionsInserted: Map<number, number>,
|
||||
sectionsWithEmbeddings: PageSectionWithEmbedding[]
|
||||
) {
|
||||
sectionsWithEmbeddings.forEach((section) => {
|
||||
const current = pageSectionsInserted.get(section.pageId) || 0
|
||||
pageSectionsInserted.set(section.pageId, current + 1)
|
||||
})
|
||||
}
|
||||
|
||||
export function computePageResults(
|
||||
pageInfoMap: Map<number, PageInfo>,
|
||||
pageSectionsInserted: Map<number, number>,
|
||||
result: ProcessingResult
|
||||
) {
|
||||
for (const [pageId, pageInfo] of pageInfoMap) {
|
||||
const insertedCount = pageSectionsInserted.get(pageId) || 0
|
||||
if (insertedCount === pageInfo.sectionsCount && !result.failedPages.has(pageId)) {
|
||||
result.successfulPages.add(pageId)
|
||||
} else {
|
||||
result.failedPages.add(pageId)
|
||||
console.warn(
|
||||
`Page ${pageInfo.path}: inserted ${insertedCount}/${pageInfo.sectionsCount} sections`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function logFailedSections(
|
||||
batch: PageSectionForEmbedding[],
|
||||
inputs: string[],
|
||||
failedSectionIndexes: Set<number>
|
||||
) {
|
||||
failedSectionIndexes.forEach((i) => {
|
||||
console.error(
|
||||
`Failed to process section: ${batch[i].path}#${batch[i].slug} (content: "${inputs[i]?.slice(0, 50)}...")`
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1,24 +1,451 @@
|
||||
import '../utils/dotenv.js'
|
||||
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { OpenAI } from 'openai'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import type { Section } from '../helpers.mdx.js'
|
||||
import {
|
||||
type PageInfo,
|
||||
type PageSectionForEmbedding,
|
||||
type PageSectionWithEmbedding,
|
||||
type ProcessingResult,
|
||||
createBatches,
|
||||
mapEmbeddingsToSections,
|
||||
updatePageInsertionCounts,
|
||||
computePageResults,
|
||||
logFailedSections,
|
||||
} from './embeddings/utils.js'
|
||||
import { fetchAllSources } from './sources/index.js'
|
||||
|
||||
const CONFIG = {
|
||||
// OpenAI settings
|
||||
EMBEDDING_MODEL: 'text-embedding-ada-002' as const,
|
||||
EMBEDDING_DIMENSION: 1536, // Keep in sync with EMBEDDING_MODEL
|
||||
OPENAI_BATCH_SIZE: 128,
|
||||
OPENAI_MAX_RETRIES: 3,
|
||||
OPENAI_BASE_DELAY_MS: 500,
|
||||
/**
|
||||
* If context length is exceeded, truncate inputs over this character length
|
||||
* and retry. This is a character-based heuristic, not token-exact.
|
||||
*/
|
||||
EMBEDDING_TRUNCATE_CHAR_LIMIT: 16_000,
|
||||
|
||||
// Supabase settings
|
||||
SUPABASE_MAX_RETRIES: 2,
|
||||
SUPABASE_BASE_DELAY_MS: 100,
|
||||
|
||||
// Processing settings
|
||||
SOURCE_CONCURRENCY: 10,
|
||||
} as const
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function exponentialBackoff(attempt: number, baseDelay: number, maxDelay: number = 30_000): number {
|
||||
const exponentialDelay = baseDelay * Math.pow(2, attempt)
|
||||
const jitter = (Math.random() - 0.5) * 0.1 * exponentialDelay
|
||||
return Math.min(Math.max(0, exponentialDelay + jitter), maxDelay)
|
||||
}
|
||||
|
||||
async function withRetry<T>(
|
||||
operation: () => Promise<T>,
|
||||
maxRetries: number,
|
||||
baseDelay: number,
|
||||
operationName: string,
|
||||
shouldRetryOnError: (error: unknown) => boolean = () => true
|
||||
): Promise<T> {
|
||||
let lastError: Error
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await operation()
|
||||
} catch (error) {
|
||||
lastError = error as Error
|
||||
|
||||
// Allow caller to prevent redundant retries for specific errors
|
||||
if (!shouldRetryOnError?.(error)) {
|
||||
console.warn(`${operationName} encountered non-retryable error:`, lastError.message)
|
||||
throw lastError
|
||||
}
|
||||
|
||||
if (attempt === maxRetries) {
|
||||
console.error(`${operationName} failed after ${maxRetries + 1} attempts:`, lastError)
|
||||
throw lastError
|
||||
}
|
||||
|
||||
const delayMs = exponentialBackoff(attempt, baseDelay)
|
||||
console.warn(
|
||||
`${operationName} attempt ${attempt + 1} failed, retrying in ${delayMs}ms:`,
|
||||
lastError.message
|
||||
)
|
||||
await delay(delayMs)
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError!
|
||||
}
|
||||
|
||||
function isNimbusMode(): boolean {
|
||||
return process.env.ENABLED_FEATURES_OVERRIDE_DISABLE_ALL === 'true'
|
||||
}
|
||||
|
||||
function getPageTables() {
|
||||
const nimbus = isNimbusMode()
|
||||
return {
|
||||
pageTable: nimbus ? 'page_nimbus' : 'page',
|
||||
pageSectionTable: nimbus ? 'page_section_nimbus' : 'page_section',
|
||||
} as const
|
||||
}
|
||||
|
||||
function requireEnvOrThrow(names: string[]): void {
|
||||
const missing = names.filter((n) => !process.env[n])
|
||||
if (missing.length) {
|
||||
throw new Error(
|
||||
`Environment variables ${missing.join(', ')} are required: skipping embeddings generation`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function initSupabase(): SupabaseClient {
|
||||
return createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SECRET_KEY!, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
})
|
||||
}
|
||||
|
||||
type PreparedSections = {
|
||||
allSectionsToProcess: PageSectionForEmbedding[]
|
||||
pageInfoMap: Map<number, PageInfo>
|
||||
}
|
||||
|
||||
async function prepareSections(
|
||||
supabaseClient: SupabaseClient,
|
||||
pageTable: string,
|
||||
pageSectionTable: string,
|
||||
shouldRefresh: boolean,
|
||||
refreshVersion: string,
|
||||
refreshDate: Date,
|
||||
fullIndex = true,
|
||||
debug = false
|
||||
): Promise<PreparedSections> {
|
||||
const embeddingSources = await fetchAllSources(fullIndex)
|
||||
console.log(`Discovered ${embeddingSources.length} sources`)
|
||||
|
||||
const allSectionsToProcess: PageSectionForEmbedding[] = []
|
||||
const pageInfoMap = new Map<number, PageInfo>()
|
||||
|
||||
for (const sourceBatch of createBatches(embeddingSources, CONFIG.SOURCE_CONCURRENCY)) {
|
||||
await Promise.all(
|
||||
sourceBatch.map(async (embeddingSource) => {
|
||||
const { type, source, path } = embeddingSource
|
||||
|
||||
try {
|
||||
const {
|
||||
checksum,
|
||||
sections,
|
||||
meta = {},
|
||||
ragIgnore = false,
|
||||
}: {
|
||||
checksum: string
|
||||
sections: Section[]
|
||||
ragIgnore?: boolean
|
||||
meta?: Record<string, unknown>
|
||||
} = await embeddingSource.process()
|
||||
|
||||
const { error: fetchPageError, data: existingPage } = await supabaseClient
|
||||
.from(pageTable)
|
||||
.select('id, path, checksum')
|
||||
.filter('path', 'eq', path)
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
if (fetchPageError) throw fetchPageError
|
||||
|
||||
if (!shouldRefresh && existingPage?.checksum === checksum) {
|
||||
const { error: updatePageError } = await supabaseClient
|
||||
.from(pageTable)
|
||||
.update({
|
||||
type,
|
||||
source,
|
||||
meta,
|
||||
version: refreshVersion,
|
||||
last_refresh: refreshDate,
|
||||
})
|
||||
.filter('id', 'eq', existingPage.id)
|
||||
if (updatePageError) throw updatePageError
|
||||
return
|
||||
}
|
||||
|
||||
if (existingPage) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
!shouldRefresh
|
||||
? `[${path}] Docs have changed, removing old page sections and their embeddings`
|
||||
: `[${path}] Refresh flag set, removing old page sections and their embeddings`
|
||||
)
|
||||
}
|
||||
|
||||
const { error: deletePageSectionError } = await supabaseClient
|
||||
.from(pageSectionTable)
|
||||
.delete()
|
||||
.filter('page_id', 'eq', existingPage.id)
|
||||
if (deletePageSectionError) throw deletePageSectionError
|
||||
}
|
||||
|
||||
const { error: upsertPageError, data: page } = await supabaseClient
|
||||
.from(pageTable)
|
||||
.upsert(
|
||||
{
|
||||
checksum: null,
|
||||
path,
|
||||
type,
|
||||
source,
|
||||
meta,
|
||||
content: embeddingSource.extractIndexedContent(),
|
||||
version: refreshVersion,
|
||||
last_refresh: refreshDate,
|
||||
},
|
||||
{ onConflict: 'path' }
|
||||
)
|
||||
.select()
|
||||
.limit(1)
|
||||
.single()
|
||||
if (upsertPageError) throw upsertPageError
|
||||
|
||||
if (debug) {
|
||||
console.log(`[${path}] Preparing ${sections.length} page sections for processing`)
|
||||
}
|
||||
|
||||
pageInfoMap.set(page.id, {
|
||||
pageId: page.id,
|
||||
path,
|
||||
checksum,
|
||||
sectionsCount: sections.length,
|
||||
})
|
||||
|
||||
const sectionsForBatching = sections.map(({ slug, heading, content }) => ({
|
||||
pageId: page.id,
|
||||
path,
|
||||
slug,
|
||||
heading,
|
||||
content,
|
||||
input: content.replace(/\n/g, ' '),
|
||||
ragIgnore,
|
||||
}))
|
||||
allSectionsToProcess.push(...sectionsForBatching)
|
||||
} catch (err) {
|
||||
console.error(`Error preparing path '${path}' for processing.`)
|
||||
console.error(err)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Prepared ${allSectionsToProcess.length} sections for processing from ${pageInfoMap.size} pages`
|
||||
)
|
||||
return { allSectionsToProcess, pageInfoMap }
|
||||
}
|
||||
|
||||
async function processAndInsertEmbeddings(
|
||||
openai: OpenAI,
|
||||
supabaseClient: SupabaseClient,
|
||||
pageSectionTable: string,
|
||||
allSections: PageSectionForEmbedding[],
|
||||
pageInfoMap: Map<number, PageInfo>
|
||||
): Promise<ProcessingResult> {
|
||||
const result: ProcessingResult = {
|
||||
successfulPages: new Set(),
|
||||
failedPages: new Set(),
|
||||
totalSectionsProcessed: 0,
|
||||
totalSectionsInserted: 0,
|
||||
}
|
||||
|
||||
if (allSections.length === 0) {
|
||||
return result
|
||||
}
|
||||
|
||||
console.log(`Processing ${allSections.length} sections with embeddings + insertion`)
|
||||
|
||||
const embeddingBatches = createBatches(allSections, CONFIG.OPENAI_BATCH_SIZE)
|
||||
|
||||
// Track sections inserted per page
|
||||
const pageSectionsInserted = new Map<number, number>()
|
||||
|
||||
for (let batchIndex = 0; batchIndex < embeddingBatches.length; batchIndex++) {
|
||||
const batch = embeddingBatches[batchIndex]
|
||||
try {
|
||||
const batchResult = await processEmbeddingBatch(
|
||||
openai,
|
||||
batch,
|
||||
batchIndex,
|
||||
embeddingBatches.length
|
||||
)
|
||||
|
||||
result.totalSectionsProcessed += batchResult.processedCount
|
||||
|
||||
if (batchResult.sectionsWithEmbeddings.length > 0) {
|
||||
const insertedCount = await insertSectionBatch(
|
||||
supabaseClient,
|
||||
pageSectionTable,
|
||||
batchResult.sectionsWithEmbeddings
|
||||
)
|
||||
result.totalSectionsInserted += insertedCount
|
||||
updatePageInsertionCounts(pageSectionsInserted, batchResult.sectionsWithEmbeddings)
|
||||
}
|
||||
|
||||
// Mark failed section pages
|
||||
batchResult.failedSectionIndexes.forEach((i) => {
|
||||
result.failedPages.add(batch[i].pageId)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Batch ${batchIndex + 1} completely failed:`, error)
|
||||
batch.forEach((section) => result.failedPages.add(section.pageId))
|
||||
}
|
||||
|
||||
if (batchIndex < embeddingBatches.length - 1) {
|
||||
await delay(CONFIG.OPENAI_BASE_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
computePageResults(pageInfoMap, pageSectionsInserted, result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
type BatchEmbeddingResult = {
|
||||
sectionsWithEmbeddings: PageSectionWithEmbedding[]
|
||||
failedSectionIndexes: Set<number>
|
||||
processedCount: number
|
||||
}
|
||||
|
||||
async function processEmbeddingBatch(
|
||||
openai: OpenAI,
|
||||
batch: PageSectionForEmbedding[],
|
||||
batchIndex: number,
|
||||
totalBatches: number
|
||||
): Promise<BatchEmbeddingResult> {
|
||||
const inputs = batch.map((section) => section.input)
|
||||
console.log(
|
||||
`Processing embedding batch ${batchIndex + 1}/${totalBatches} (${inputs.length} sections)`
|
||||
)
|
||||
|
||||
// Helper to identify context length exceeded errors from OpenAI
|
||||
const isContextLengthError = (err: unknown) => {
|
||||
if (!(err instanceof OpenAI.APIError)) return false
|
||||
|
||||
const message = err.error?.message as string
|
||||
const status = err.status
|
||||
return status === 400 && message.toLowerCase().includes('context')
|
||||
}
|
||||
|
||||
let embeddingResponse: OpenAI.Embeddings.CreateEmbeddingResponse
|
||||
try {
|
||||
embeddingResponse = await withRetry(
|
||||
() =>
|
||||
openai.embeddings.create({
|
||||
model: CONFIG.EMBEDDING_MODEL,
|
||||
input: inputs,
|
||||
}),
|
||||
CONFIG.OPENAI_MAX_RETRIES,
|
||||
CONFIG.OPENAI_BASE_DELAY_MS,
|
||||
`OpenAI embedding batch ${batchIndex + 1}`,
|
||||
(err) => !isContextLengthError(err)
|
||||
)
|
||||
} catch (err) {
|
||||
if (!isContextLengthError(err)) {
|
||||
throw err
|
||||
}
|
||||
|
||||
// Context length exceeded: truncate problematic sections and try once more
|
||||
const limit = CONFIG.EMBEDDING_TRUNCATE_CHAR_LIMIT
|
||||
const truncatedInputs = inputs.map((s) => (s.length > limit ? s.slice(0, limit) : s))
|
||||
const truncatedCount = truncatedInputs.filter((s, i) => s !== inputs[i]).length
|
||||
console.warn(
|
||||
`OpenAI embedding batch ${batchIndex + 1}: context length exceeded. ` +
|
||||
`Truncating ${truncatedCount} overly long section(s) to ${limit} chars and retrying once.`
|
||||
)
|
||||
|
||||
embeddingResponse = await openai.embeddings.create({
|
||||
model: CONFIG.EMBEDDING_MODEL,
|
||||
input: truncatedInputs,
|
||||
})
|
||||
|
||||
// Replace inputs with truncated inputs for downstream bookkeeping
|
||||
for (let i = 0; i < inputs.length; i++) inputs[i] = truncatedInputs[i]
|
||||
}
|
||||
|
||||
const { sectionsWithEmbeddings, failedSectionIndexes } = mapEmbeddingsToSections(
|
||||
batch,
|
||||
embeddingResponse.data,
|
||||
batchIndex
|
||||
)
|
||||
logFailedSections(batch, inputs, failedSectionIndexes)
|
||||
|
||||
return {
|
||||
sectionsWithEmbeddings,
|
||||
failedSectionIndexes,
|
||||
processedCount: inputs.length,
|
||||
}
|
||||
}
|
||||
|
||||
async function insertSectionBatch(
|
||||
supabaseClient: SupabaseClient,
|
||||
pageSectionTable: string,
|
||||
sectionsWithEmbeddings: PageSectionWithEmbedding[]
|
||||
): Promise<number> {
|
||||
if (sectionsWithEmbeddings.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const pageSectionsToInsert = sectionsWithEmbeddings.map((section) => ({
|
||||
page_id: section.pageId,
|
||||
slug: section.slug,
|
||||
heading: section.heading,
|
||||
content: section.content,
|
||||
embedding: section.embedding,
|
||||
rag_ignore: section.ragIgnore,
|
||||
}))
|
||||
|
||||
await withRetry(
|
||||
async () => {
|
||||
const { error } = await supabaseClient.from(pageSectionTable).insert(pageSectionsToInsert)
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Supabase insert error: ${error.message}`)
|
||||
}
|
||||
},
|
||||
CONFIG.SUPABASE_MAX_RETRIES,
|
||||
CONFIG.SUPABASE_BASE_DELAY_MS,
|
||||
`Insert batch of ${sectionsWithEmbeddings.length} sections`
|
||||
)
|
||||
|
||||
return sectionsWithEmbeddings.length
|
||||
}
|
||||
|
||||
const args = parseArgs({
|
||||
options: {
|
||||
refresh: {
|
||||
type: 'boolean',
|
||||
},
|
||||
debug: {
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
async function generateEmbeddings() {
|
||||
const shouldRefresh = Boolean(args.values.refresh)
|
||||
const debug = Boolean(args.values.debug)
|
||||
|
||||
const requiredEnvVars = [
|
||||
const nimbus = isNimbusMode()
|
||||
if (nimbus) {
|
||||
console.log('Running in Nimbus mode - will filter content based on disabled feature flags')
|
||||
}
|
||||
|
||||
requireEnvOrThrow([
|
||||
'DOCS_GITHUB_APP_ID',
|
||||
'DOCS_GITHUB_APP_INSTALLATION_ID',
|
||||
'DOCS_GITHUB_APP_PRIVATE_KEY',
|
||||
@@ -27,217 +454,136 @@ async function generateEmbeddings() {
|
||||
'NEXT_PUBLIC_SUPABASE_URL',
|
||||
'OPENAI_API_KEY',
|
||||
'SUPABASE_SECRET_KEY',
|
||||
]
|
||||
])
|
||||
|
||||
const missingEnvVars = requiredEnvVars.filter((name) => !process.env[name])
|
||||
if (missingEnvVars.length > 0) {
|
||||
throw new Error(
|
||||
`Environment variables ${missingEnvVars.join(
|
||||
', '
|
||||
)} are required: skipping embeddings generation`
|
||||
)
|
||||
}
|
||||
const supabaseClient = initSupabase()
|
||||
|
||||
const supabaseClient = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SECRET_KEY!,
|
||||
{
|
||||
auth: {
|
||||
persistSession: false,
|
||||
autoRefreshToken: false,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Use this version to track which pages to purge
|
||||
// after the refresh
|
||||
const refreshVersion = uuidv4()
|
||||
|
||||
const refreshDate = new Date()
|
||||
|
||||
const embeddingSources = await fetchAllSources()
|
||||
const { pageTable, pageSectionTable } = getPageTables()
|
||||
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
|
||||
|
||||
console.log(`Discovered ${embeddingSources.length} pages`)
|
||||
console.log(
|
||||
shouldRefresh
|
||||
? 'Refresh flag set, re-generating all pages'
|
||||
: 'Checking which pages are new or have changed'
|
||||
)
|
||||
|
||||
if (!shouldRefresh) {
|
||||
console.log('Checking which pages are new or have changed')
|
||||
} else {
|
||||
console.log('Refresh flag set, re-generating all pages')
|
||||
const { allSectionsToProcess, pageInfoMap } = await prepareSections(
|
||||
supabaseClient,
|
||||
pageTable,
|
||||
pageSectionTable,
|
||||
shouldRefresh,
|
||||
refreshVersion,
|
||||
refreshDate,
|
||||
!nimbus,
|
||||
debug
|
||||
)
|
||||
|
||||
let processingResult: ProcessingResult
|
||||
try {
|
||||
processingResult = await processAndInsertEmbeddings(
|
||||
openai,
|
||||
supabaseClient,
|
||||
pageSectionTable,
|
||||
allSectionsToProcess,
|
||||
pageInfoMap
|
||||
)
|
||||
console.log(
|
||||
`Processing complete: ${processingResult.totalSectionsInserted}/${processingResult.totalSectionsProcessed} sections inserted successfully`
|
||||
)
|
||||
console.log(
|
||||
`Page summary: ${processingResult.successfulPages.size} successful, ${processingResult.failedPages.size} failed`
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Critical error during embedding processing:', error)
|
||||
console.log('Exiting due to complete processing failure')
|
||||
return
|
||||
}
|
||||
|
||||
for (const embeddingSource of embeddingSources) {
|
||||
const { type, source, path } = embeddingSource
|
||||
console.log(`\nUpdating checksums for ${processingResult.successfulPages.size} successful pages`)
|
||||
const successfulChecksumUpdates = await updateSuccessfulChecksums(
|
||||
supabaseClient,
|
||||
pageTable,
|
||||
pageInfoMap,
|
||||
processingResult
|
||||
)
|
||||
console.log(
|
||||
`Successfully updated checksums for ${successfulChecksumUpdates}/${processingResult.successfulPages.size} successful pages`
|
||||
)
|
||||
|
||||
try {
|
||||
const {
|
||||
checksum,
|
||||
sections,
|
||||
meta = {},
|
||||
ragIgnore = false,
|
||||
}: {
|
||||
checksum: string
|
||||
sections: Section[]
|
||||
ragIgnore?: boolean
|
||||
meta?: Record<string, unknown>
|
||||
} = await embeddingSource.process()
|
||||
logFailedPages(pageInfoMap, processingResult)
|
||||
|
||||
// Check for existing page in DB and compare checksums
|
||||
const { error: fetchPageError, data: existingPage } = await supabaseClient
|
||||
.from('page')
|
||||
.select('id, path, checksum')
|
||||
.filter('path', 'eq', path)
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
if (fetchPageError) {
|
||||
throw fetchPageError
|
||||
}
|
||||
|
||||
// We use checksum to determine if this page & its sections need to be regenerated
|
||||
if (!shouldRefresh && existingPage?.checksum === checksum) {
|
||||
// No content/embedding update required on this page
|
||||
// Update other meta info
|
||||
const { error: updatePageError } = await supabaseClient
|
||||
.from('page')
|
||||
.update({
|
||||
type,
|
||||
source,
|
||||
meta,
|
||||
version: refreshVersion,
|
||||
last_refresh: refreshDate,
|
||||
})
|
||||
.filter('id', 'eq', existingPage.id)
|
||||
|
||||
if (updatePageError) {
|
||||
throw updatePageError
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (existingPage) {
|
||||
if (!shouldRefresh) {
|
||||
console.log(
|
||||
`[${path}] Docs have changed, removing old page sections and their embeddings`
|
||||
)
|
||||
} else {
|
||||
console.log(`[${path}] Refresh flag set, removing old page sections and their embeddings`)
|
||||
}
|
||||
|
||||
const { error: deletePageSectionError } = await supabaseClient
|
||||
.from('page_section')
|
||||
.delete()
|
||||
.filter('page_id', 'eq', existingPage.id)
|
||||
|
||||
if (deletePageSectionError) {
|
||||
throw deletePageSectionError
|
||||
}
|
||||
}
|
||||
|
||||
// Create/update page record. Intentionally clear checksum until we
|
||||
// have successfully generated all page sections.
|
||||
const { error: upsertPageError, data: page } = await supabaseClient
|
||||
.from('page')
|
||||
.upsert(
|
||||
{
|
||||
checksum: null,
|
||||
path,
|
||||
type,
|
||||
source,
|
||||
meta,
|
||||
content: embeddingSource.extractIndexedContent(),
|
||||
version: refreshVersion,
|
||||
last_refresh: refreshDate,
|
||||
},
|
||||
{ onConflict: 'path' }
|
||||
)
|
||||
.select()
|
||||
.limit(1)
|
||||
.single()
|
||||
|
||||
if (upsertPageError) {
|
||||
throw upsertPageError
|
||||
}
|
||||
|
||||
console.log(`[${path}] Adding ${sections.length} page sections (with embeddings)`)
|
||||
for (const { slug, heading, content } of sections) {
|
||||
// OpenAI recommends replacing newlines with spaces for best results (specific to embeddings)
|
||||
// force a redeploy
|
||||
const input = content.replace(/\n/g, ' ')
|
||||
|
||||
try {
|
||||
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
|
||||
|
||||
const embeddingResponse = await openai.embeddings.create({
|
||||
model: 'text-embedding-ada-002',
|
||||
input,
|
||||
})
|
||||
|
||||
const [responseData] = embeddingResponse.data
|
||||
|
||||
const { error: insertPageSectionError } = await supabaseClient
|
||||
.from('page_section')
|
||||
.insert({
|
||||
page_id: page.id,
|
||||
slug,
|
||||
heading,
|
||||
content,
|
||||
token_count: embeddingResponse.usage.total_tokens,
|
||||
embedding: responseData.embedding,
|
||||
rag_ignore: ragIgnore,
|
||||
})
|
||||
.select()
|
||||
.limit(1)
|
||||
.single()
|
||||
|
||||
if (insertPageSectionError) {
|
||||
throw insertPageSectionError
|
||||
}
|
||||
} catch (err) {
|
||||
// TODO: decide how to better handle failed embeddings
|
||||
console.error(
|
||||
`Failed to generate embeddings for '${path}' page section starting with '${input.slice(
|
||||
0,
|
||||
40
|
||||
)}...'`
|
||||
)
|
||||
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Set page checksum so that we know this page was stored successfully
|
||||
const { error: updatePageError } = await supabaseClient
|
||||
.from('page')
|
||||
.update({ checksum })
|
||||
.filter('id', 'eq', page.id)
|
||||
|
||||
if (updatePageError) {
|
||||
throw updatePageError
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Page '${path}' or one/multiple of its page sections failed to store properly. Page has been marked with null checksum to indicate that it needs to be re-generated.`
|
||||
)
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Removing old pages and their sections`)
|
||||
|
||||
// Delete pages that have been removed (and their sections via cascade)
|
||||
const { error: deletePageError } = await supabaseClient
|
||||
.from('page')
|
||||
.delete()
|
||||
.filter('version', 'neq', refreshVersion)
|
||||
|
||||
if (deletePageError) {
|
||||
throw deletePageError
|
||||
}
|
||||
await purgeOldPages(supabaseClient, pageTable, refreshVersion)
|
||||
|
||||
console.log('Embedding generation complete')
|
||||
}
|
||||
|
||||
async function updateSuccessfulChecksums(
|
||||
supabaseClient: SupabaseClient,
|
||||
pageTable: string,
|
||||
pageInfoMap: Map<number, PageInfo>,
|
||||
processingResult: ProcessingResult
|
||||
): Promise<number> {
|
||||
let successfulChecksumUpdates = 0
|
||||
const pageIds = Array.from(processingResult.successfulPages)
|
||||
const batches = createBatches(pageIds, CONFIG.SOURCE_CONCURRENCY)
|
||||
|
||||
for (const batch of batches) {
|
||||
const results = await Promise.all(
|
||||
batch.map(async (pageId) => {
|
||||
const pageInfo = pageInfoMap.get(pageId)
|
||||
if (!pageInfo) {
|
||||
console.error(`Missing page info for pageId ${pageId}`)
|
||||
return 0
|
||||
}
|
||||
|
||||
try {
|
||||
const { error: updatePageError } = await supabaseClient
|
||||
.from(pageTable)
|
||||
.update({ checksum: pageInfo.checksum })
|
||||
.eq('id', pageId)
|
||||
if (updatePageError) {
|
||||
console.error(`Failed to update checksum for page ${pageInfo.path}:`, updatePageError)
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
} catch (error) {
|
||||
console.error(`Error updating checksum for page ${pageInfo.path}:`, error)
|
||||
return 0
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
successfulChecksumUpdates += results.reduce((sum, x) => sum + x, 0)
|
||||
}
|
||||
|
||||
return successfulChecksumUpdates
|
||||
}
|
||||
|
||||
function logFailedPages(pageInfoMap: Map<number, PageInfo>, processingResult: ProcessingResult) {
|
||||
if (processingResult.failedPages.size === 0) return
|
||||
console.log(`\nFailed pages:`)
|
||||
for (const pageId of processingResult.failedPages) {
|
||||
const pageInfo = pageInfoMap.get(pageId)
|
||||
if (pageInfo) console.log(` - ${pageInfo.path}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function purgeOldPages(
|
||||
supabaseClient: SupabaseClient,
|
||||
pageTable: string,
|
||||
refreshVersion: string
|
||||
) {
|
||||
console.log(`Removing old pages and their sections`)
|
||||
const { error: deletePageError } = await supabaseClient
|
||||
.from(pageTable)
|
||||
.delete()
|
||||
.filter('version', 'neq', refreshVersion)
|
||||
if (deletePageError) throw deletePageError
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await generateEmbeddings()
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { type GuideModel } from '../../../resources/guide/guideModel.js'
|
||||
import { GuideModelLoader } from '../../../resources/guide/guideModelLoader.js'
|
||||
import {
|
||||
GitHubDiscussionLoader,
|
||||
@@ -28,7 +29,7 @@ export type SearchSource =
|
||||
export async function fetchGuideSources() {
|
||||
const guides = (await GuideModelLoader.allFromFs()).unwrapLeft()
|
||||
|
||||
return guides.map((guide) => MarkdownLoader.fromGuideModel('guide', guide))
|
||||
return guides.map((guide: GuideModel) => MarkdownLoader.fromGuideModel('guide', guide))
|
||||
}
|
||||
|
||||
export async function fetchOpenApiReferenceSource() {
|
||||
@@ -125,27 +126,29 @@ export async function fetchLintWarningsGuideSources() {
|
||||
/**
|
||||
* Fetches all the sources we want to index for search
|
||||
*/
|
||||
export async function fetchAllSources() {
|
||||
export async function fetchAllSources(fullIndex: boolean) {
|
||||
const guideSources = fetchGuideSources()
|
||||
const lintWarningsGuideSources = fetchLintWarningsGuideSources()
|
||||
const openApiReferenceSource = fetchOpenApiReferenceSource()
|
||||
const jsLibReferenceSource = fetchJsLibReferenceSource()
|
||||
const dartLibReferenceSource = fetchDartLibReferenceSource()
|
||||
const pythonLibReferenceSource = fetchPythonLibReferenceSource()
|
||||
const cSharpLibReferenceSource = fetchCSharpLibReferenceSource()
|
||||
const swiftLibReferenceSource = fetchSwiftLibReferenceSource()
|
||||
const ktLibReferenceSource = fetchKtLibReferenceSource()
|
||||
const cliReferenceSource = fetchCliLibReferenceSource()
|
||||
const dartLibReferenceSource = fullIndex ? fetchDartLibReferenceSource() : []
|
||||
const pythonLibReferenceSource = fullIndex ? fetchPythonLibReferenceSource() : []
|
||||
const cSharpLibReferenceSource = fullIndex ? fetchCSharpLibReferenceSource() : []
|
||||
const swiftLibReferenceSource = fullIndex ? fetchSwiftLibReferenceSource() : []
|
||||
const ktLibReferenceSource = fullIndex ? fetchKtLibReferenceSource() : []
|
||||
const cliReferenceSource = fullIndex ? fetchCliLibReferenceSource() : []
|
||||
|
||||
const partnerIntegrationSources = fetchPartners()
|
||||
.then((partners) =>
|
||||
partners
|
||||
? Promise.all(
|
||||
partners.map((partner) => new IntegrationLoader(partner.slug, partner).load())
|
||||
)
|
||||
: []
|
||||
)
|
||||
.then((data) => data.flat())
|
||||
const partnerIntegrationSources = fullIndex
|
||||
? fetchPartners()
|
||||
.then((partners) =>
|
||||
partners
|
||||
? Promise.all(
|
||||
partners.map((partner) => new IntegrationLoader(partner.slug, partner).load())
|
||||
)
|
||||
: []
|
||||
)
|
||||
.then((data) => data.flat())
|
||||
: []
|
||||
|
||||
const githubDiscussionSources = fetchDiscussions(
|
||||
'supabase',
|
||||
|
||||
@@ -820,7 +820,7 @@
|
||||
{
|
||||
"id": "supabase-postgres-config",
|
||||
"title": "Manage Postgres configurations",
|
||||
"slug": "supabase-ssl-enforcement",
|
||||
"slug": "supabase-postgres-config",
|
||||
"type": "cli-command"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1356,7 +1356,7 @@ functions:
|
||||
- If the session's access token is expired or is about to expire, this method will use the refresh token to refresh the session.
|
||||
- When using in a browser, or you've called `startAutoRefresh()` in your environment (React Native, etc.) this function always returns a valid access token without refreshing the session itself, as this is done in the background. This function returns very fast.
|
||||
- **IMPORTANT SECURITY NOTICE:** If using an insecure storage medium, such as cookies or request headers, the user object returned by this function **must not be trusted**. Always verify the JWT using `getClaims()` or your own JWT verification library to securely establish the user's identity and access. You can also use `getUser()` to fetch the user object directly from the Auth server for this purpose.
|
||||
- When using in a browser, this function is synchronized accross all tabs using the [LockManager](https://developer.mozilla.org/en-US/docs/Web/API/LockManager) API. In other environments make sure you've defined a proper `lock` property, if necessary, to make sure there are no race conditions while the session is being refreshed.
|
||||
- When using in a browser, this function is synchronized across all tabs using the [LockManager](https://developer.mozilla.org/en-US/docs/Web/API/LockManager) API. In other environments make sure you've defined a proper `lock` property, if necessary, to make sure there are no race conditions while the session is being refreshed.
|
||||
examples:
|
||||
- id: get-the-session-data
|
||||
name: Get the session data
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"DOCS_GITHUB_APP_PRIVATE_KEY",
|
||||
"DOCS_REVALIDATION_KEYS",
|
||||
"DOCS_REVALIDATION_OVERRIDE_KEYS",
|
||||
"ENABLED_FEATURES_OVERRIDE_DISABLE_ALL",
|
||||
"GITHUB_ACTIONS",
|
||||
"FORCE_ASSET_CDN",
|
||||
"LOGFLARE_INGESTION_API_KEY",
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
// eslint-disable-next-line no-restricted-exports
|
||||
export default defineConfig({
|
||||
test: {
|
||||
// Exclude examples from test discovery (does not affect tsconfig scanning)
|
||||
exclude: ['examples/**/*', '**/node_modules/**'],
|
||||
setupFiles: ['vitest.setup.ts'],
|
||||
},
|
||||
plugins: [tsconfigPaths({ root: import.meta.dirname })],
|
||||
// Restrict tsconfig-paths to only use this app's tsconfig
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
root: import.meta.dirname,
|
||||
// Prevent scanning tsconfig files in subfolders like examples/**
|
||||
projects: ['tsconfig.json'],
|
||||
}),
|
||||
],
|
||||
})
|
||||
@@ -23,7 +23,7 @@ RUN apt-get update -qq && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
update-ca-certificates
|
||||
|
||||
RUN npm install -g pnpm@9.15.5
|
||||
RUN npm install -g pnpm@10.16.1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -69,9 +69,10 @@ const Pagination = () => {
|
||||
}
|
||||
)
|
||||
|
||||
const count = data?.is_estimate ? formatEstimatedCount(data.count) : data?.count.toLocaleString()
|
||||
const maxPages = Math.ceil((data?.count ?? 0) / tableEditorSnap.rowsPerPage)
|
||||
const totalPages = (data?.count ?? 0) > 0 ? maxPages : 1
|
||||
const count = data?.count ?? 0
|
||||
const countString = data?.is_estimate ? formatEstimatedCount(count) : count.toLocaleString()
|
||||
const maxPages = Math.ceil(count / tableEditorSnap.rowsPerPage)
|
||||
const totalPages = count > 0 ? maxPages : 1
|
||||
|
||||
const onPreviousPage = () => {
|
||||
if (page > 1) {
|
||||
@@ -202,7 +203,7 @@ const Pagination = () => {
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="text-xs text-foreground-light">
|
||||
{`${count} ${data.count === 0 || data.count > 1 ? `records` : 'record'}`}{' '}
|
||||
{`${countString} ${count === 0 || count > 1 ? `records` : 'record'}`}{' '}
|
||||
{data.is_estimate ? '(estimated)' : ''}
|
||||
</p>
|
||||
|
||||
@@ -217,7 +218,7 @@ const Pagination = () => {
|
||||
icon={<HelpCircle />}
|
||||
onClick={() => {
|
||||
// Show warning if either NOT a table entity, or table rows estimate is beyond threshold
|
||||
if (rowsCountEstimate === null || data.count > THRESHOLD_COUNT) {
|
||||
if (rowsCountEstimate === null || count > THRESHOLD_COUNT) {
|
||||
setIsConfirmFetchExactCountModalOpen(true)
|
||||
} else snap.setEnforceExactCount(true)
|
||||
}}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ref as valtioRef } from 'valtio'
|
||||
import { handleCopyCell } from 'components/grid/SupabaseGrid.utils'
|
||||
import { formatForeignKeys } from 'components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.utils'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { InlineLink } from 'components/ui/InlineLink'
|
||||
import { useForeignKeyConstraintsQuery } from 'data/database/foreign-key-constraints-query'
|
||||
import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants'
|
||||
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
||||
@@ -14,6 +15,7 @@ import { useCsvFileDrop } from 'hooks/ui/useCsvFileDrop'
|
||||
import { useTableEditorStateSnapshot } from 'state/table-editor'
|
||||
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
|
||||
import { Button, cn } from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
import type { Filter, GridProps, SupaRow } from '../../types'
|
||||
import { useOnRowsChange } from './Grid.utils'
|
||||
@@ -58,6 +60,7 @@ export const Grid = memo(
|
||||
|
||||
const { data: org } = useSelectedOrganizationQuery()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const isBranch = project?.parent_project_ref !== undefined
|
||||
|
||||
const onRowsChange = useOnRowsChange(rows)
|
||||
|
||||
@@ -77,6 +80,9 @@ export const Grid = memo(
|
||||
const isForeignTable = tableEntityType === ENTITY_TYPE.FOREIGN_TABLE
|
||||
const isTableEmpty = (rows ?? []).length === 0
|
||||
|
||||
const isForeignTableMissingVaultKeyError =
|
||||
isForeignTable && isError && error.message.includes('query vault failed')
|
||||
|
||||
const { mutate: sendEvent } = useSendEventMutation()
|
||||
|
||||
const { isDraggedOver, onDragOver, onFileDrop } = useCsvFileDrop({
|
||||
@@ -149,20 +155,51 @@ export const Grid = memo(
|
||||
className="absolute top-9 p-2 w-full z-[1] pointer-events-none"
|
||||
>
|
||||
{isLoading && <GenericSkeletonLoader />}
|
||||
{isError && (
|
||||
<AlertError
|
||||
className="pointer-events-auto"
|
||||
error={error}
|
||||
subject="Failed to retrieve rows from table"
|
||||
>
|
||||
{filters.length > 0 && (
|
||||
{isError ? (
|
||||
isForeignTableMissingVaultKeyError ? (
|
||||
<Admonition
|
||||
type="warning"
|
||||
className="pointer-events-auto"
|
||||
title="Failed to retrieve rows from foreign table"
|
||||
>
|
||||
<p>
|
||||
Verify that the filter values are correct, as the error may stem from an
|
||||
incorrectly applied filter
|
||||
The key that's used to retrieve data from your foreign table is either
|
||||
incorrect or missing. Verify the key in your{' '}
|
||||
<InlineLink href={`/project/${project?.ref}/integrations?category=wrapper`}>
|
||||
wrapper's settings
|
||||
</InlineLink>{' '}
|
||||
or in{' '}
|
||||
<InlineLink href={`/project/${project?.ref}/integrations/vault/overview`}>
|
||||
Vault
|
||||
</InlineLink>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</AlertError>
|
||||
)}
|
||||
{isBranch && (
|
||||
<p>
|
||||
Note: Vault keys from the main project do not sync to branches. You may add
|
||||
them manually into{' '}
|
||||
<InlineLink href={`/project/${project?.ref}/integrations/vault/overview`}>
|
||||
Vault
|
||||
</InlineLink>{' '}
|
||||
if you want to query foreign tables while on a branch.
|
||||
</p>
|
||||
)}
|
||||
</Admonition>
|
||||
) : (
|
||||
<AlertError
|
||||
className="pointer-events-auto"
|
||||
error={error}
|
||||
subject="Failed to retrieve rows from table"
|
||||
>
|
||||
{filters.length > 0 && (
|
||||
<p>
|
||||
Verify that the filter values are correct, as the error may stem from an
|
||||
incorrectly applied filter
|
||||
</p>
|
||||
)}
|
||||
</AlertError>
|
||||
)
|
||||
) : null}
|
||||
{isSuccess && (
|
||||
<>
|
||||
{(filters ?? []).length === 0 ? (
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { useTableRowsCountQuery } from 'data/table-rows/table-rows-count-query'
|
||||
import { fetchAllTableRows, useTableRowsQuery } from 'data/table-rows/table-rows-query'
|
||||
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { RoleImpersonationState } from 'lib/role-impersonation'
|
||||
@@ -84,7 +84,7 @@ const DefaultHeader = () => {
|
||||
|
||||
const snap = useTableEditorTableStateSnapshot()
|
||||
const tableEditorSnap = useTableEditorStateSnapshot()
|
||||
const { can: canCreateColumns } = useAsyncCheckProjectPermissions(
|
||||
const { can: canCreateColumns } = useAsyncCheckPermissions(
|
||||
PermissionAction.TENANT_SQL_ADMIN_WRITE,
|
||||
'columns'
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useParams } from 'common/hooks'
|
||||
import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip'
|
||||
import { useAPIKeyDeleteMutation } from 'data/api-keys/api-key-delete-mutation'
|
||||
import { APIKeysData } from 'data/api-keys/api-keys-query'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal'
|
||||
|
||||
interface APIKeyDeleteDialogProps {
|
||||
@@ -19,7 +19,7 @@ export const APIKeyDeleteDialog = ({ apiKey, lastSeen }: APIKeyDeleteDialogProps
|
||||
const { ref: projectRef } = useParams()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const { can: canDeleteAPIKeys } = useAsyncCheckProjectPermissions(
|
||||
const { can: canDeleteAPIKeys } = useAsyncCheckPermissions(
|
||||
PermissionAction.TENANT_SQL_ADMIN_WRITE,
|
||||
'*'
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ export const APIKeyRow = ({
|
||||
apiKey: Extract<APIKeysData[number], { type: 'secret' | 'publishable' }>
|
||||
lastSeen?: { timestamp: string }
|
||||
}) => {
|
||||
const MotionTableRow = motion(TableRow)
|
||||
const MotionTableRow = motion.create(TableRow)
|
||||
|
||||
return (
|
||||
<MotionTableRow
|
||||
|
||||
@@ -11,7 +11,7 @@ import CopyButton from 'components/ui/CopyButton'
|
||||
import { useAPIKeyIdQuery } from 'data/api-keys/[id]/api-key-id-query'
|
||||
import { APIKeysData } from 'data/api-keys/api-keys-query'
|
||||
import { apiKeysKeys } from 'data/api-keys/keys'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
export function ApiKeyPill({
|
||||
@@ -28,8 +28,10 @@ export function ApiKeyPill({
|
||||
const isSecret = apiKey.type === 'secret'
|
||||
|
||||
// Permission check for revealing/copying secret API keys
|
||||
const { can: canManageSecretKeys, isLoading: isLoadingPermission } =
|
||||
useAsyncCheckProjectPermissions(PermissionAction.READ, 'service_api_keys')
|
||||
const { can: canManageSecretKeys, isLoading: isLoadingPermission } = useAsyncCheckPermissions(
|
||||
PermissionAction.READ,
|
||||
'service_api_keys'
|
||||
)
|
||||
|
||||
// This query only runs when show=true (enabled: show)
|
||||
// It fetches the fully revealed API key when needed
|
||||
|
||||
@@ -6,16 +6,16 @@ import { useParams } from 'common'
|
||||
import CopyButton from 'components/ui/CopyButton'
|
||||
import { FormHeader } from 'components/ui/Forms/FormHeader'
|
||||
import { useAPIKeysQuery } from 'data/api-keys/api-keys-query'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import {
|
||||
cn,
|
||||
EyeOffIcon,
|
||||
Input_Shadcn_,
|
||||
Skeleton,
|
||||
WarningIcon,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
WarningIcon,
|
||||
} from 'ui'
|
||||
|
||||
// to add in later with follow up PR
|
||||
@@ -36,7 +36,7 @@ export const PublishableAPIKeys = () => {
|
||||
[apiKeysData]
|
||||
)
|
||||
|
||||
const { can: canReadAPIKeys, isLoading: isPermissionsLoading } = useAsyncCheckProjectPermissions(
|
||||
const { can: canReadAPIKeys, isLoading: isPermissionsLoading } = useAsyncCheckPermissions(
|
||||
PermissionAction.TENANT_SQL_ADMIN_WRITE,
|
||||
'*'
|
||||
)
|
||||
@@ -106,7 +106,7 @@ const ApiKeyInput = () => {
|
||||
[apiKeysData]
|
||||
)
|
||||
|
||||
const { can: canReadAPIKeys, isLoading: isPermissionsLoading } = useAsyncCheckProjectPermissions(
|
||||
const { can: canReadAPIKeys, isLoading: isPermissionsLoading } = useAsyncCheckPermissions(
|
||||
PermissionAction.TENANT_SQL_ADMIN_WRITE,
|
||||
'*'
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ import AlertError from 'components/ui/AlertError'
|
||||
import { FormHeader } from 'components/ui/Forms/FormHeader'
|
||||
import { APIKeysData, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
|
||||
import useLogsQuery from 'hooks/analytics/useLogsQuery'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { Card, CardContent, EyeOffIcon, Skeleton, cn } from 'ui'
|
||||
import {
|
||||
Table,
|
||||
@@ -58,7 +58,7 @@ export const SecretAPIKeys = () => {
|
||||
isError: isErrorApiKeys,
|
||||
} = useAPIKeysQuery({ projectRef, reveal: false })
|
||||
|
||||
const { can: canReadAPIKeys, isLoading: isLoadingPermissions } = useAsyncCheckProjectPermissions(
|
||||
const { can: canReadAPIKeys, isLoading: isLoadingPermissions } = useAsyncCheckPermissions(
|
||||
PermissionAction.TENANT_SQL_ADMIN_WRITE,
|
||||
'*'
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useMemo } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useAPIKeysQuery } from 'data/api-keys/api-keys-query'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
|
||||
interface ApiKeysVisibilityState {
|
||||
hasApiKeys: boolean
|
||||
@@ -19,7 +19,7 @@ interface ApiKeysVisibilityState {
|
||||
*/
|
||||
export function useApiKeysVisibility(): ApiKeysVisibilityState {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { can: canReadAPIKeys } = useAsyncCheckProjectPermissions(PermissionAction.READ, 'api_keys')
|
||||
const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.READ, 'api_keys')
|
||||
|
||||
const { data: apiKeysData, isLoading } = useAPIKeysQuery({
|
||||
projectRef,
|
||||
|
||||
@@ -12,7 +12,7 @@ import NoPermission from 'components/ui/NoPermission'
|
||||
import UpgradeToPro from 'components/ui/UpgradeToPro'
|
||||
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
|
||||
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { IS_PLATFORM } from 'lib/constants'
|
||||
import {
|
||||
@@ -43,11 +43,11 @@ const FormSchema = z.object({
|
||||
export const AdvancedAuthSettingsForm = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: organization } = useSelectedOrganizationQuery()
|
||||
const { can: canReadConfig } = useAsyncCheckProjectPermissions(
|
||||
const { can: canReadConfig } = useAsyncCheckPermissions(
|
||||
PermissionAction.READ,
|
||||
'custom_config_gotrue'
|
||||
)
|
||||
const { can: canUpdateConfig } = useAsyncCheckProjectPermissions(
|
||||
const { can: canUpdateConfig } = useAsyncCheckPermissions(
|
||||
PermissionAction.UPDATE,
|
||||
'custom_config_gotrue'
|
||||
)
|
||||
|
||||
@@ -8,11 +8,10 @@ import { boolean, object } from 'yup'
|
||||
import { useParams } from 'common'
|
||||
import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold'
|
||||
import { InlineLink } from 'components/ui/InlineLink'
|
||||
import { NoPermission } from 'components/ui/NoPermission'
|
||||
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
|
||||
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
|
||||
import { useTablesQuery } from 'data/tables/tables-query'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import {
|
||||
AlertDescription_Shadcn_,
|
||||
@@ -40,8 +39,11 @@ const AUDIT_LOG_ENTRIES_TABLE = 'audit_log_entries'
|
||||
export const AuditLogsForm = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const canReadConfig = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue')
|
||||
const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue')
|
||||
|
||||
const { can: canUpdateConfig } = useAsyncCheckPermissions(
|
||||
PermissionAction.UPDATE,
|
||||
'custom_config_gotrue'
|
||||
)
|
||||
|
||||
const { data: tables = [] } = useTablesQuery({
|
||||
projectRef: project?.ref,
|
||||
@@ -91,10 +93,6 @@ export const AuditLogsForm = () => {
|
||||
)
|
||||
}
|
||||
|
||||
if (!canReadConfig) {
|
||||
return <NoPermission resourceText="view audit logs settings" />
|
||||
}
|
||||
|
||||
return (
|
||||
<ScaffoldSection isFullWidth>
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { components } from 'data/api'
|
||||
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
|
||||
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
|
||||
import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { Button, Form, Input, Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
@@ -38,7 +38,7 @@ export const ProviderForm = ({ config, provider, isActive }: ProviderFormProps)
|
||||
const [open, setOpen] = useState(false)
|
||||
const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation()
|
||||
|
||||
const { can: canUpdateConfig } = useAsyncCheckProjectPermissions(
|
||||
const { can: canUpdateConfig } = useAsyncCheckPermissions(
|
||||
PermissionAction.UPDATE,
|
||||
'custom_config_gotrue'
|
||||
)
|
||||
|
||||
@@ -436,7 +436,7 @@ const EXTERNAL_PROVIDER_APPLE = {
|
||||
},
|
||||
EXTERNAL_APPLE_CLIENT_ID: {
|
||||
title: 'Client IDs',
|
||||
description: `Comma separated list of allowed Apple app (Web, OAuth, iOS, macOS, watchOS, or tvOS) bundle IDs for native sign in, or service IDs for Sign in with Apple JS. [Learn more](https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js)`,
|
||||
description: `Comma separated list of allowed Apple app (Web, OAuth, iOS, macOS, watchOS, or tvOS) bundle IDs for native sign in, or service IDs for Sign in with Apple JS. [Learn more](https://developer.apple.com/documentation/signinwithapplejs)`,
|
||||
type: 'string',
|
||||
},
|
||||
EXTERNAL_APPLE_SECRET: {
|
||||
|
||||
+3
-3
@@ -13,7 +13,7 @@ import { InlineLink } from 'components/ui/InlineLink'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
|
||||
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
|
||||
import {
|
||||
AlertDescription_Shadcn_,
|
||||
@@ -54,11 +54,11 @@ export const BasicAuthSettingsForm = () => {
|
||||
} = useAuthConfigQuery({ projectRef })
|
||||
const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation()
|
||||
|
||||
const { can: canReadConfig, isSuccess: isPermissionsLoaded } = useAsyncCheckProjectPermissions(
|
||||
const { can: canReadConfig, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions(
|
||||
PermissionAction.READ,
|
||||
'custom_config_gotrue'
|
||||
)
|
||||
const { can: canUpdateConfig } = useAsyncCheckProjectPermissions(
|
||||
const { can: canUpdateConfig } = useAsyncCheckPermissions(
|
||||
PermissionAction.UPDATE,
|
||||
'custom_config_gotrue'
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ import CodeEditor from 'components/ui/CodeEditor/CodeEditor'
|
||||
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
|
||||
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
|
||||
import { useValidateSpamMutation, ValidateSpamResponse } from 'data/auth/validate-spam-mutation'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import type { FormSchema } from 'types'
|
||||
import {
|
||||
Button,
|
||||
@@ -40,7 +40,7 @@ interface TemplateEditorProps {
|
||||
|
||||
const TemplateEditor = ({ template }: TemplateEditorProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { can: canUpdateConfig } = useAsyncCheckProjectPermissions(
|
||||
const { can: canUpdateConfig } = useAsyncCheckPermissions(
|
||||
PermissionAction.UPDATE,
|
||||
'custom_config_gotrue'
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ChevronDown } from 'lucide-react'
|
||||
import { useParams } from 'common'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import {
|
||||
Button,
|
||||
@@ -33,10 +33,7 @@ export const AddHookDropdown = ({
|
||||
const { data: organization } = useSelectedOrganizationQuery()
|
||||
|
||||
const { data: authConfig } = useAuthConfigQuery({ projectRef })
|
||||
const { can: canUpdateAuthHook } = useAsyncCheckProjectPermissions(
|
||||
PermissionAction.AUTH_EXECUTE,
|
||||
'*'
|
||||
)
|
||||
const { can: canUpdateAuthHook } = useAsyncCheckPermissions(PermissionAction.AUTH_EXECUTE, '*')
|
||||
|
||||
const hooks: Hook[] = HOOKS_DEFINITIONS.map((definition) => {
|
||||
return {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Check, Webhook } from 'lucide-react'
|
||||
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { Badge, Input, copyToClipboard } from 'ui'
|
||||
import { Hook } from './hooks.constants'
|
||||
|
||||
@@ -13,10 +13,7 @@ interface HookCardProps {
|
||||
}
|
||||
|
||||
export const HookCard = ({ hook, onSelect }: HookCardProps) => {
|
||||
const { can: canUpdateAuthHook } = useAsyncCheckProjectPermissions(
|
||||
PermissionAction.AUTH_EXECUTE,
|
||||
'*'
|
||||
)
|
||||
const { can: canUpdateAuthHook } = useAsyncCheckPermissions(PermissionAction.AUTH_EXECUTE, '*')
|
||||
|
||||
return (
|
||||
<div className="bg-surface-100 border-default overflow-hidden border shadow px-5 py-4 flex flex-row first:rounded-t-md last:rounded-b-md space-x-4">
|
||||
|
||||
@@ -11,7 +11,7 @@ import NoPermission from 'components/ui/NoPermission'
|
||||
import UpgradeToPro from 'components/ui/UpgradeToPro'
|
||||
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
|
||||
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { IS_PLATFORM } from 'lib/constants'
|
||||
import {
|
||||
@@ -96,11 +96,11 @@ export const MfaAuthSettingsForm = () => {
|
||||
|
||||
const [isConfirmationModalVisible, setIsConfirmationModalVisible] = useState(false)
|
||||
|
||||
const { can: canReadConfig } = useAsyncCheckProjectPermissions(
|
||||
const { can: canReadConfig } = useAsyncCheckPermissions(
|
||||
PermissionAction.READ,
|
||||
'custom_config_gotrue'
|
||||
)
|
||||
const { can: canUpdateConfig } = useAsyncCheckProjectPermissions(
|
||||
const { can: canUpdateConfig } = useAsyncCheckPermissions(
|
||||
PermissionAction.UPDATE,
|
||||
'custom_config_gotrue'
|
||||
)
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ import { UseFormReturn } from 'react-hook-form'
|
||||
|
||||
import { useDatabaseRolesQuery } from 'data/database-roles/database-roles-query'
|
||||
import { useTablesQuery } from 'data/tables/tables-query'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import {
|
||||
Button,
|
||||
@@ -62,7 +62,7 @@ export const PolicyDetailsV2 = ({
|
||||
}: PolicyDetailsV2Props) => {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const [open, setOpen] = useState(false)
|
||||
const { can: canUpdatePolicies } = useAsyncCheckProjectPermissions(
|
||||
const { can: canUpdatePolicies } = useAsyncCheckPermissions(
|
||||
PermissionAction.TENANT_SQL_ADMIN_WRITE,
|
||||
'tables'
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { useDatabasePolicyUpdateMutation } from 'data/database-policies/database-policy-update-mutation'
|
||||
import { databasePoliciesKeys } from 'data/database-policies/keys'
|
||||
import { QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import {
|
||||
Button,
|
||||
@@ -67,7 +67,7 @@ export const PolicyEditorPanel = memo(function ({
|
||||
const queryClient = useQueryClient()
|
||||
const { data: selectedProject } = useSelectedProjectQuery()
|
||||
|
||||
const { can: canUpdatePolicies } = useAsyncCheckProjectPermissions(
|
||||
const { can: canUpdatePolicies } = useAsyncCheckPermissions(
|
||||
PermissionAction.TENANT_SQL_ADMIN_WRITE,
|
||||
'tables'
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Edit, MoreVertical, Trash } from 'lucide-react'
|
||||
|
||||
import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip'
|
||||
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
|
||||
import {
|
||||
@@ -37,7 +37,7 @@ export const PolicyRow = ({
|
||||
onSelectDeletePolicy = noop,
|
||||
}: PolicyRowProps) => {
|
||||
const aiSnap = useAiAssistantStateSnapshot()
|
||||
const { can: canUpdatePolicies } = useAsyncCheckProjectPermissions(
|
||||
const { can: canUpdatePolicies } = useAsyncCheckPermissions(
|
||||
PermissionAction.TENANT_SQL_ADMIN_WRITE,
|
||||
'policies'
|
||||
)
|
||||
|
||||
+3
-3
@@ -5,7 +5,7 @@ import { Lock, Table } from 'lucide-react'
|
||||
import { useParams } from 'common'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { EditorTablePageLink } from 'data/prefetchers/project.$ref.editor.$id'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
|
||||
import { AiIconAnimation, Badge, CardTitle } from 'ui'
|
||||
|
||||
@@ -35,11 +35,11 @@ export const PolicyTableRowHeader = ({
|
||||
const { ref } = useParams()
|
||||
const aiSnap = useAiAssistantStateSnapshot()
|
||||
|
||||
const { can: canCreatePolicies } = useAsyncCheckProjectPermissions(
|
||||
const { can: canCreatePolicies } = useAsyncCheckPermissions(
|
||||
PermissionAction.TENANT_SQL_ADMIN_WRITE,
|
||||
'policies'
|
||||
)
|
||||
const { can: canToggleRLS } = useAsyncCheckProjectPermissions(
|
||||
const { can: canToggleRLS } = useAsyncCheckPermissions(
|
||||
PermissionAction.TENANT_SQL_ADMIN_WRITE,
|
||||
'tables'
|
||||
)
|
||||
|
||||
+3
-3
@@ -12,7 +12,7 @@ import { InlineLink } from 'components/ui/InlineLink'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
|
||||
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import {
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
@@ -80,11 +80,11 @@ export const ProtectionAuthSettingsForm = () => {
|
||||
})
|
||||
const [hidden, setHidden] = useState(true)
|
||||
|
||||
const { can: canReadConfig } = useAsyncCheckProjectPermissions(
|
||||
const { can: canReadConfig } = useAsyncCheckPermissions(
|
||||
PermissionAction.READ,
|
||||
'custom_config_gotrue'
|
||||
)
|
||||
const { can: canUpdateConfig } = useAsyncCheckProjectPermissions(
|
||||
const { can: canUpdateConfig } = useAsyncCheckPermissions(
|
||||
PermissionAction.UPDATE,
|
||||
'custom_config_gotrue'
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ import NoPermission from 'components/ui/NoPermission'
|
||||
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
|
||||
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
|
||||
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -32,11 +32,11 @@ import { isSmtpEnabled } from '../SmtpForm/SmtpForm.utils'
|
||||
|
||||
export const RateLimits = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { can: canUpdateConfig } = useAsyncCheckProjectPermissions(
|
||||
const { can: canUpdateConfig } = useAsyncCheckPermissions(
|
||||
PermissionAction.UPDATE,
|
||||
'custom_config_gotrue'
|
||||
)
|
||||
const { can: canReadConfig } = useAsyncCheckProjectPermissions(
|
||||
const { can: canReadConfig } = useAsyncCheckPermissions(
|
||||
PermissionAction.READ,
|
||||
'custom_config_gotrue'
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Globe, Trash } from 'lucide-react'
|
||||
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { EmptyListState } from 'components/ui/States'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { Button, Checkbox_Shadcn_ } from 'ui'
|
||||
import { ValueContainer } from './ValueContainer'
|
||||
|
||||
@@ -24,7 +24,7 @@ export const RedirectUrlList = ({
|
||||
onSelectRemoveURLs,
|
||||
onSelectClearSelection,
|
||||
}: RedirectUrlListProps) => {
|
||||
const { can: canUpdateConfig } = useAsyncCheckProjectPermissions(
|
||||
const { can: canUpdateConfig } = useAsyncCheckPermissions(
|
||||
PermissionAction.UPDATE,
|
||||
'custom_config_gotrue'
|
||||
)
|
||||
|
||||
+28
-33
@@ -11,7 +11,7 @@ import NoPermission from 'components/ui/NoPermission'
|
||||
import UpgradeToPro from 'components/ui/UpgradeToPro'
|
||||
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
|
||||
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { IS_PLATFORM } from 'lib/constants'
|
||||
import {
|
||||
@@ -44,10 +44,7 @@ function HoursOrNeverText({ value }: { value: number }) {
|
||||
|
||||
const RefreshTokenSchema = z.object({
|
||||
REFRESH_TOKEN_ROTATION_ENABLED: z.boolean(),
|
||||
SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: z.coerce
|
||||
.number()
|
||||
.positive()
|
||||
.min(0, 'Must be a value more than 0'),
|
||||
SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: z.coerce.number().min(0, 'Must be a value more than 0'),
|
||||
})
|
||||
|
||||
const UserSessionsSchema = z.object({
|
||||
@@ -68,11 +65,11 @@ export const SessionsAuthSettingsForm = () => {
|
||||
const [isUpdatingRefreshTokens, setIsUpdatingRefreshTokens] = useState(false)
|
||||
const [isUpdatingUserSessions, setIsUpdatingUserSessions] = useState(false)
|
||||
|
||||
const { can: canReadConfig } = useAsyncCheckProjectPermissions(
|
||||
const { can: canReadConfig } = useAsyncCheckPermissions(
|
||||
PermissionAction.READ,
|
||||
'custom_config_gotrue'
|
||||
)
|
||||
const { can: canUpdateConfig } = useAsyncCheckProjectPermissions(
|
||||
const { can: canUpdateConfig } = useAsyncCheckPermissions(
|
||||
PermissionAction.UPDATE,
|
||||
'custom_config_gotrue'
|
||||
)
|
||||
@@ -202,32 +199,30 @@ export const SessionsAuthSettingsForm = () => {
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
{refreshTokenForm.watch('REFRESH_TOKEN_ROTATION_ENABLED') && (
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
control={refreshTokenForm.control}
|
||||
name="SECURITY_REFRESH_TOKEN_REUSE_INTERVAL"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Refresh token reuse interval"
|
||||
description="Time interval where the same refresh token can be used multiple times to request for an access token. Recommendation: 10 seconds."
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<PrePostTab postTab="seconds">
|
||||
<Input_Shadcn_
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
disabled={!canUpdateConfig}
|
||||
/>
|
||||
</PrePostTab>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
)}
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
control={refreshTokenForm.control}
|
||||
name="SECURITY_REFRESH_TOKEN_REUSE_INTERVAL"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Refresh token reuse interval"
|
||||
description="Time interval where the same refresh token can be used multiple times to request for an access token. Recommendation: 10 seconds."
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<PrePostTab postTab="seconds">
|
||||
<Input_Shadcn_
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
disabled={!canUpdateConfig}
|
||||
/>
|
||||
</PrePostTab>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end space-x-2">
|
||||
{refreshTokenForm.formState.isDirty && (
|
||||
<Button type="default" onClick={() => refreshTokenForm.reset()}>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useParams } from 'common'
|
||||
import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold'
|
||||
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
|
||||
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import {
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
@@ -36,7 +36,7 @@ const SiteUrl = () => {
|
||||
const { mutate: updateAuthConfig } = useAuthConfigUpdateMutation()
|
||||
const [isUpdatingSiteUrl, setIsUpdatingSiteUrl] = useState(false)
|
||||
|
||||
const { can: canUpdateConfig } = useAsyncCheckProjectPermissions(
|
||||
const { can: canUpdateConfig } = useAsyncCheckPermissions(
|
||||
PermissionAction.UPDATE,
|
||||
'custom_config_gotrue'
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ScaffoldSection } from 'components/layouts/Scaffold'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
|
||||
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import {
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
@@ -53,11 +53,11 @@ export const SmtpForm = () => {
|
||||
const [enableSmtp, setEnableSmtp] = useState(false)
|
||||
const [hidden, setHidden] = useState(true)
|
||||
|
||||
const { can: canReadConfig } = useAsyncCheckProjectPermissions(
|
||||
const { can: canReadConfig } = useAsyncCheckPermissions(
|
||||
PermissionAction.READ,
|
||||
'custom_config_gotrue'
|
||||
)
|
||||
const { can: canUpdateConfig } = useAsyncCheckProjectPermissions(
|
||||
const { can: canUpdateConfig } = useAsyncCheckPermissions(
|
||||
PermissionAction.UPDATE,
|
||||
'custom_config_gotrue'
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
ThirdPartyAuthIntegration,
|
||||
useThirdPartyAuthIntegrationsQuery,
|
||||
} from 'data/third-party-auth/integrations-query'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { cn } from 'ui'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { AddIntegrationDropdown } from './AddIntegrationDropdown'
|
||||
@@ -49,7 +49,7 @@ export const ThirdPartyAuthForm = () => {
|
||||
useState<ThirdPartyAuthIntegration>()
|
||||
|
||||
const { mutateAsync: deleteIntegration } = useDeleteThirdPartyAuthIntegrationMutation()
|
||||
const { can: canUpdateConfig } = useAsyncCheckProjectPermissions(
|
||||
const { can: canUpdateConfig } = useAsyncCheckPermissions(
|
||||
PermissionAction.UPDATE,
|
||||
'custom_config_gotrue'
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ChevronDown, Mail, UserPlus } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
|
||||
import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from 'ui'
|
||||
import CreateUserModal from './CreateUserModal'
|
||||
@@ -12,11 +12,11 @@ import InviteUserModal from './InviteUserModal'
|
||||
export const AddUserDropdown = () => {
|
||||
const showSendInvitation = useIsFeatureEnabled('authentication:show_send_invitation')
|
||||
|
||||
const { can: canInviteUsers } = useAsyncCheckProjectPermissions(
|
||||
const { can: canInviteUsers } = useAsyncCheckPermissions(
|
||||
PermissionAction.AUTH_EXECUTE,
|
||||
'invite_user'
|
||||
)
|
||||
const { can: canCreateUsers } = useAsyncCheckProjectPermissions(
|
||||
const { can: canCreateUsers } = useAsyncCheckPermissions(
|
||||
PermissionAction.AUTH_EXECUTE,
|
||||
'create_user'
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as z from 'zod'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useUserCreateMutation } from 'data/auth/user-create-mutation'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import {
|
||||
Button,
|
||||
Checkbox_Shadcn_,
|
||||
@@ -38,7 +38,7 @@ const CreateUserFormSchema = z.object({
|
||||
|
||||
const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { can: canCreateUsers } = useAsyncCheckProjectPermissions(
|
||||
const { can: canCreateUsers } = useAsyncCheckPermissions(
|
||||
PermissionAction.AUTH_EXECUTE,
|
||||
'create_user'
|
||||
)
|
||||
|
||||
@@ -3,7 +3,6 @@ import { toast } from 'sonner'
|
||||
|
||||
import { useUserDeleteMutation } from 'data/auth/user-delete-mutation'
|
||||
import { User } from 'data/auth/users-infinite-query'
|
||||
import { timeout } from 'lib/helpers'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
|
||||
interface DeleteUserModalProps {
|
||||
@@ -29,7 +28,6 @@ export const DeleteUserModal = ({
|
||||
})
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
await timeout(200)
|
||||
if (!projectRef) return console.error('Project ref is required')
|
||||
if (selectedUser?.id === undefined) {
|
||||
return toast.error(`Failed to delete user: User ID not found`)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { toast } from 'sonner'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useUserInviteMutation } from 'data/auth/user-invite-mutation'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { Button, Form, Input, Modal } from 'ui'
|
||||
|
||||
export type InviteUserModalProps = {
|
||||
@@ -22,7 +22,7 @@ const InviteUserModal = ({ visible, setVisible }: InviteUserModalProps) => {
|
||||
setVisible(false)
|
||||
},
|
||||
})
|
||||
const { can: canInviteUsers } = useAsyncCheckProjectPermissions(
|
||||
const { can: canInviteUsers } = useAsyncCheckPermissions(
|
||||
PermissionAction.AUTH_EXECUTE,
|
||||
'invite_user'
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useUserSendMagicLinkMutation } from 'data/auth/user-send-magic-link-mut
|
||||
import { useUserSendOTPMutation } from 'data/auth/user-send-otp-mutation'
|
||||
import { useUserUpdateMutation } from 'data/auth/user-update-mutation'
|
||||
import { User } from 'data/auth/users-infinite-query'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { timeout } from 'lib/helpers'
|
||||
@@ -64,24 +64,21 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
|
||||
}
|
||||
)
|
||||
|
||||
const { can: canUpdateUser } = useAsyncCheckProjectPermissions(PermissionAction.AUTH_EXECUTE, '*')
|
||||
const { can: canSendMagicLink } = useAsyncCheckProjectPermissions(
|
||||
const { can: canUpdateUser } = useAsyncCheckPermissions(PermissionAction.AUTH_EXECUTE, '*')
|
||||
const { can: canSendMagicLink } = useAsyncCheckPermissions(
|
||||
PermissionAction.AUTH_EXECUTE,
|
||||
'send_magic_link'
|
||||
)
|
||||
const { can: canSendRecovery } = useAsyncCheckProjectPermissions(
|
||||
const { can: canSendRecovery } = useAsyncCheckPermissions(
|
||||
PermissionAction.AUTH_EXECUTE,
|
||||
'send_recovery'
|
||||
)
|
||||
const { can: canSendOtp } = useAsyncCheckProjectPermissions(
|
||||
PermissionAction.AUTH_EXECUTE,
|
||||
'send_otp'
|
||||
)
|
||||
const { can: canRemoveUser } = useAsyncCheckProjectPermissions(
|
||||
const { can: canSendOtp } = useAsyncCheckPermissions(PermissionAction.AUTH_EXECUTE, 'send_otp')
|
||||
const { can: canRemoveUser } = useAsyncCheckPermissions(
|
||||
PermissionAction.TENANT_SQL_DELETE,
|
||||
'auth.users'
|
||||
)
|
||||
const { can: canRemoveMFAFactors } = useAsyncCheckProjectPermissions(
|
||||
const { can: canRemoveMFAFactors } = useAsyncCheckPermissions(
|
||||
PermissionAction.TENANT_SQL_DELETE,
|
||||
'auth.mfa_factors'
|
||||
)
|
||||
|
||||
@@ -29,7 +29,7 @@ import { THRESHOLD_COUNT } from 'data/table-rows/table-rows-count-query'
|
||||
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
|
||||
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { isAtBottom } from 'lib/helpers'
|
||||
import { cleanPointerEventsNoneOnBody, isAtBottom } from 'lib/helpers'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
@@ -684,10 +684,14 @@ export const UsersV2 = ({ showChart = true }: { showChart?: boolean }) => {
|
||||
<DeleteUserModal
|
||||
visible={!!selectedUserToDelete}
|
||||
selectedUser={selectedUserToDelete}
|
||||
onClose={() => setSelectedUserToDelete(undefined)}
|
||||
onClose={() => {
|
||||
setSelectedUserToDelete(undefined)
|
||||
cleanPointerEventsNoneOnBody()
|
||||
}}
|
||||
onDeleteSuccess={() => {
|
||||
if (selectedUserToDelete?.id === selectedUser) setSelectedUser(undefined)
|
||||
setSelectedUserToDelete(undefined)
|
||||
cleanPointerEventsNoneOnBody(500)
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ import { useParams } from 'common'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import { useOrganizationPaymentMethodsQuery } from 'data/organizations/organization-payment-methods-query'
|
||||
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { Button } from 'ui'
|
||||
import CreditCard from './CreditCard'
|
||||
|
||||
@@ -31,7 +31,7 @@ const CurrentPaymentMethod = () => {
|
||||
|
||||
const defaultPaymentMethod = paymentMethods?.data.find((pm) => pm.is_default)
|
||||
|
||||
const { can: canReadPaymentMethods } = useAsyncCheckProjectPermissions(
|
||||
const { can: canReadPaymentMethods } = useAsyncCheckPermissions(
|
||||
PermissionAction.BILLING_READ,
|
||||
'stripe.payment_methods'
|
||||
)
|
||||
|
||||
Loaded 100 of 449 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user