mirror of
https://github.com/supabase/supabase.git
synced 2026-10-07 02:15:05 +03:00
89 files changed
+4387
-870
No files matched your search
@@ -553,8 +553,10 @@ export const functions = {
|
||||
url: '/guides/functions/examples/connect-to-postgres',
|
||||
items: [],
|
||||
},
|
||||
{ name: 'Discord Bot', url: '/guides/functions/examples/discord-bot', items: [] },
|
||||
{ name: 'GitHub Actions', url: '/guides/functions/examples/github-actions', items: [] },
|
||||
{ name: 'OG Image', url: '/guides/functions/examples/og-image', items: [] },
|
||||
{ name: 'OpenAI', url: '/guides/functions/examples/openai', items: [] },
|
||||
{ name: 'Storage Caching', url: '/guides/functions/examples/storage-caching', items: [] },
|
||||
{ name: 'Stripe Webhooks', url: '/guides/functions/examples/stripe-webhooks', items: [] },
|
||||
{ name: 'Telegram Bot', url: '/guides/functions/examples/telegram-bot', items: [] },
|
||||
@@ -821,6 +823,7 @@ export const integrations = {
|
||||
url: undefined,
|
||||
items: [
|
||||
{ name: 'Estuary', url: '/guides/integrations/estuary', items: [] },
|
||||
{ name: 'OpenAI', url: '/guides/functions/examples/openai', items: [] },
|
||||
{ name: 'pgMustard', url: '/guides/integrations/pgmustard', items: [] },
|
||||
{ name: 'Prisma', url: '/guides/integrations/prisma', items: [] },
|
||||
{ name: 'Sequin', url: '/guides/integrations/sequin', items: [] },
|
||||
|
||||
@@ -38,7 +38,7 @@ const NavigationMenuGuideList: React.FC<Props> = ({ id, active }) => {
|
||||
|
||||
// disabled
|
||||
// level !== 'home' && level !== id ? '-ml-8' : '',
|
||||
!active ? 'opacity-0 invisible absolute' : '',
|
||||
!active ? 'opacity-0 invisible absolute h-0 overflow-hidden' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
<NavigationMenuGuideListItems id={id} />
|
||||
|
||||
@@ -21,7 +21,7 @@ const Layout: FC<Props> = (props: Props) => {
|
||||
<Head>
|
||||
<title>{props.meta?.title} | Supabase Docs</title>
|
||||
<meta name="description" content={props.meta?.description} />
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/docs/favicon.ico" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content={props.meta?.title} />
|
||||
|
||||
@@ -50,7 +50,7 @@ const Layout: FC<Props> = (props: Props) => {
|
||||
<Head>
|
||||
<title>{props.meta?.title} | Supabase</title>
|
||||
<meta name="description" content={props.meta?.description} />
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/docs/favicon.ico" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content={props.meta?.title} />
|
||||
|
||||
@@ -3,7 +3,9 @@ import { Html, Head, Main, NextScript } from 'next/document'
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html lang="en" className="dark">
|
||||
<Head></Head>
|
||||
<Head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"></meta>
|
||||
</Head>
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
|
||||
@@ -137,7 +137,8 @@ In order to get the most out of TypeScript and it´s intellisense, you should im
|
||||
// and what to do when importing types
|
||||
declare namespace App {
|
||||
interface Supabase {
|
||||
Database: import('./DatabaseDefinitions').Database
|
||||
// Use the path to where you generated the types using the Supbase CLI.
|
||||
Database: import('../types/supabase').Database;
|
||||
SchemaName: 'public'
|
||||
}
|
||||
|
||||
|
||||
@@ -43,12 +43,12 @@ export const examples = [
|
||||
{
|
||||
name: 'With supabase-js',
|
||||
description: 'Use the Supabase client inside your Edge Function.',
|
||||
href: 'https://github.com/supabase/supabase/blob/master/examples/edge-functions/supabase/functions/select-from-table-with-auth-rls/index.ts',
|
||||
href: '/guides/functions/auth',
|
||||
},
|
||||
{
|
||||
name: 'With CORS headers',
|
||||
description: 'Send CORS headers for invoking from the browser.',
|
||||
href: 'https://github.com/supabase/supabase/blob/master/examples/edge-functions/supabase/functions/browser-with-cors/index.ts',
|
||||
href: '/guides/functions/cors',
|
||||
},
|
||||
{
|
||||
name: 'React Native with Stripe',
|
||||
@@ -86,6 +86,71 @@ export const examples = [
|
||||
description: `Get user location data from user's IP address.`,
|
||||
href: 'https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/location',
|
||||
},
|
||||
{
|
||||
name: 'Cloudflare Turnstile',
|
||||
description: `Protecting Forms with Cloudflare Turnstile.`,
|
||||
href: '/guides/functions/examples/cloudflare-turnstile',
|
||||
},
|
||||
{
|
||||
name: 'Connect to Postgres',
|
||||
description: `Connecting to Postgres from Edge Functions.`,
|
||||
href: '/guides/functions/examples/connect-to-postgres',
|
||||
},
|
||||
{
|
||||
name: 'Github Actions',
|
||||
description: `Deploying Edge Functions with GitHub Actions.`,
|
||||
href: '/guides/functions/examples/github-actions',
|
||||
},
|
||||
{
|
||||
name: 'Oak Server Middleware',
|
||||
description: `Request Routing with Oak server middleware.`,
|
||||
href: 'https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/oak-server',
|
||||
},
|
||||
{
|
||||
name: 'OpenAI',
|
||||
description: `Using OpenAI in Edge Functions.`,
|
||||
href: '/guides/functions/examples/openai',
|
||||
},
|
||||
{
|
||||
name: 'Stripe Webhooks',
|
||||
description: `Handling signed Stripe Webhooks with Edge Functions.`,
|
||||
href: '/guides/functions/examples/stripe-webhooks',
|
||||
},
|
||||
{
|
||||
name: 'Send emails',
|
||||
description: `Send emails in Edge Functions.`,
|
||||
href: 'https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/send-email-smtp',
|
||||
},
|
||||
{
|
||||
name: 'Web Stream',
|
||||
description: `Server-Sent Events in Edge Functions.`,
|
||||
href: 'https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/streams',
|
||||
},
|
||||
{
|
||||
name: 'Puppeteer',
|
||||
description: `Generate screenshots with Puppeteer.`,
|
||||
href: 'https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/puppeteer',
|
||||
},
|
||||
{
|
||||
name: 'Discord Bot',
|
||||
description: `Building a Slash Command Discord Bot with Edge Functions.`,
|
||||
href: '/guides/functions/examples/discord-bot',
|
||||
},
|
||||
{
|
||||
name: 'Telegram Bot',
|
||||
description: `Building a Telegram Bot with Edge Functions.`,
|
||||
href: '/guides/functions/examples/telegram-bot',
|
||||
},
|
||||
{
|
||||
name: 'Upload File',
|
||||
description: `Process multipart/form-data.`,
|
||||
href: 'https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/file-upload-storage',
|
||||
},
|
||||
{
|
||||
name: 'Upstash Redis',
|
||||
description: `Build an Edge Functions Counter with Upstash Redis.`,
|
||||
href: '/guides/functions/examples/upstash-redis',
|
||||
},
|
||||
]
|
||||
|
||||
export const Page = ({ children }) => <Layout meta={meta} children={children} hideToc={true} />
|
||||
|
||||
@@ -20,7 +20,7 @@ By creating a supabase client with the auth context from the function, you can d
|
||||
2. Run queries in the context of the user with [Row Level Security (RLS)](/docs/guides/auth/row-level-security) policies enforced.
|
||||
|
||||
```js lines=14,17-19,22-23 title=supabase/functions/select-from-table-with-auth-rls/index.ts
|
||||
import { serve } from 'https://deno.land/std@0.131.0/http/server.ts'
|
||||
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||
|
||||
serve(async (req: Request) => {
|
||||
|
||||
@@ -24,7 +24,7 @@ export const corsHeaders = {
|
||||
You can then import and use the CORS headers within your functions:
|
||||
|
||||
```ts index.ts
|
||||
import { serve } from 'https://deno.land/std@0.131.0/http/server.ts'
|
||||
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'
|
||||
import { corsHeaders } from '../_shared/cors.ts'
|
||||
|
||||
console.log(`Function "browser-with-cors" up and running!`)
|
||||
|
||||
@@ -34,7 +34,7 @@ supabase functions new cloudflare-turnstile
|
||||
And add the code to the `index.ts` file:
|
||||
|
||||
```ts index.ts
|
||||
import { serve } from 'https://deno.land/std@0.131.0/http/server.ts'
|
||||
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'
|
||||
import { corsHeaders } from '../_shared/cors.ts'
|
||||
|
||||
console.log('Hello from Cloudflare Trunstile!')
|
||||
|
||||
@@ -4,6 +4,7 @@ export const meta = {
|
||||
id: 'examples-postgres-on-the-edge',
|
||||
title: 'Connect to Postgres',
|
||||
description: 'Connecting to Postgres from Edge Functions.',
|
||||
video: 'https://www.youtube.com/v/cl7EuF1-RsY',
|
||||
}
|
||||
|
||||
<div class="video-container">
|
||||
@@ -19,7 +20,7 @@ Supabase Edge Functions allow you to go beyond HTTP and can connect to your Post
|
||||
|
||||
```ts index.ts
|
||||
import * as postgres from 'https://deno.land/x/postgres@v0.14.2/mod.ts'
|
||||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
|
||||
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'
|
||||
|
||||
// Get the connection string from the environment variable "DATABASE_URL"
|
||||
const databaseUrl = Deno.env.get('DATABASE_URL')!
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import Layout from '~/layouts/DefaultGuideLayout'
|
||||
|
||||
export const meta = {
|
||||
id: 'examples-discord-bot',
|
||||
title: 'Discord Bot',
|
||||
description: 'Building a Slash Command Discord Bot with Edge Functions.',
|
||||
video: 'https://www.youtube.com/v/J24Bvo_m7DM',
|
||||
}
|
||||
|
||||
<div class="video-container">
|
||||
<iframe
|
||||
src="https://www.youtube-nocookie.com/embed/J24Bvo_m7DM"
|
||||
frameBorder="1"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
## Create an application on Discord Developer Portal
|
||||
|
||||
1. Go to [https://discord.com/developers/applications](https://discord.com/developers/applications) (login using your discord account if required).
|
||||
2. Click on **New Application** button available at left side of your profile picture.
|
||||
3. Name your application and click on **Create**.
|
||||
4. Go to **Bot** section, click on **Add Bot**, and finally on **Yes, do it!** to confirm.
|
||||
|
||||
That's it. A new application is created which will hold our Slash Command. Don't close the tab as we need information from this application page throughout our development.
|
||||
|
||||
Before we can write some code, we need to curl a discord endpoint to register a Slash Command in our app.
|
||||
|
||||
Fill `DISCORD_BOT_TOKEN` with the token available in the **Bot** section and `CLIENT_ID` with the ID available on the **General Information** section of the page and run the command on your terminal.
|
||||
|
||||
```bash
|
||||
BOT_TOKEN='replace_me_with_bot_token'
|
||||
CLIENT_ID='replace_me_with_client_id'
|
||||
curl -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H "Authorization: Bot $BOT_TOKEN" \
|
||||
-d '{"name":"hello","description":"Greet a person","options":[{"name":"name","description":"The name of the person","type":3,"required":true}]}' \
|
||||
"https://discord.com/api/v8/applications/$CLIENT_ID/commands"
|
||||
```
|
||||
|
||||
This will register a Slash Command named `hello` that accepts a parameter named `name` of type string.
|
||||
|
||||
## Code
|
||||
|
||||
```ts index.ts
|
||||
// Sift is a small routing library that abstracts away details like starting a
|
||||
// listener on a port, and provides a simple function (serve) that has an API
|
||||
// to invoke a function for a specific path.
|
||||
import { json, serve, validateRequest } from 'sift'
|
||||
// TweetNaCl is a cryptography library that we use to verify requests
|
||||
// from Discord.
|
||||
import nacl from 'nacl'
|
||||
|
||||
enum DiscordCommandType {
|
||||
Ping = 1,
|
||||
ApplicationCommand = 2,
|
||||
}
|
||||
|
||||
// For all requests to "/" endpoint, we want to invoke home() handler.
|
||||
serve({
|
||||
'/discord-bot': home,
|
||||
})
|
||||
|
||||
// The main logic of the Discord Slash Command is defined in this function.
|
||||
async function home(request: Request) {
|
||||
// validateRequest() ensures that a request is of POST method and
|
||||
// has the following headers.
|
||||
const { error } = await validateRequest(request, {
|
||||
POST: {
|
||||
headers: ['X-Signature-Ed25519', 'X-Signature-Timestamp'],
|
||||
},
|
||||
})
|
||||
if (error) {
|
||||
return json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
|
||||
// verifySignature() verifies if the request is coming from Discord.
|
||||
// When the request's signature is not valid, we return a 401 and this is
|
||||
// important as Discord sends invalid requests to test our verification.
|
||||
const { valid, body } = await verifySignature(request)
|
||||
if (!valid) {
|
||||
return json(
|
||||
{ error: 'Invalid request' },
|
||||
{
|
||||
status: 401,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const { type = 0, data = { options: [] } } = JSON.parse(body)
|
||||
// Discord performs Ping interactions to test our application.
|
||||
// Type 1 in a request implies a Ping interaction.
|
||||
if (type === DiscordCommandType.Ping) {
|
||||
return json({
|
||||
type: 1, // Type 1 in a response is a Pong interaction response type.
|
||||
})
|
||||
}
|
||||
|
||||
// Type 2 in a request is an ApplicationCommand interaction.
|
||||
// It implies that a user has issued a command.
|
||||
if (type === DiscordCommandType.ApplicationCommand) {
|
||||
const { value } = data.options.find(
|
||||
(option: { name: string; value: string }) => option.name === 'name'
|
||||
)
|
||||
return json({
|
||||
// Type 4 responds with the below message retaining the user's
|
||||
// input at the top.
|
||||
type: 4,
|
||||
data: {
|
||||
content: `Hello, ${value}!`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// We will return a bad request error as a valid Discord request
|
||||
// shouldn't reach here.
|
||||
return json({ error: 'bad request' }, { status: 400 })
|
||||
}
|
||||
|
||||
/** Verify whether the request is coming from Discord. */
|
||||
async function verifySignature(request: Request): Promise<{ valid: boolean; body: string }> {
|
||||
const PUBLIC_KEY = Deno.env.get('DISCORD_PUBLIC_KEY')!
|
||||
// Discord sends these headers with every request.
|
||||
const signature = request.headers.get('X-Signature-Ed25519')!
|
||||
const timestamp = request.headers.get('X-Signature-Timestamp')!
|
||||
const body = await request.text()
|
||||
const valid = nacl.sign.detached.verify(
|
||||
new TextEncoder().encode(timestamp + body),
|
||||
hexToUint8Array(signature),
|
||||
hexToUint8Array(PUBLIC_KEY)
|
||||
)
|
||||
|
||||
return { valid, body }
|
||||
}
|
||||
|
||||
/** Converts a hexadecimal string to Uint8Array. */
|
||||
function hexToUint8Array(hex: string) {
|
||||
return new Uint8Array(hex.match(/.{1,2}/g)!.map((val) => parseInt(val, 16)))
|
||||
}
|
||||
```
|
||||
|
||||
## Deploy the Slash Command Handler
|
||||
|
||||
```bash
|
||||
supabase functions deploy discord-bot --no-verify-jwt
|
||||
supabase secrets set DISCORD_PUBLIC_KEY=your_public_key
|
||||
```
|
||||
|
||||
Navigate to your Function details in the Supabase Dashboard to get your Endpoint URL.
|
||||
|
||||
### Configure Discord application to use our URL as interactions endpoint URL
|
||||
|
||||
1. Go back to your application (Greeter) page on Discord Developer Portal
|
||||
2. Fill **INTERACTIONS ENDPOINT URL** field with the URL and click on **Save Changes**.
|
||||
|
||||
The application is now ready. Let's proceed to the next section to install it.
|
||||
|
||||
## Install the Slash Command on your Discord server
|
||||
|
||||
So to use the `hello` Slash Command, we need to install our Greeter application on our Discord server. Here are the steps:
|
||||
|
||||
1. Go to **OAuth2** section of the Discord application page on Discord Developer Portal
|
||||
2. Select `applications.commands` scope and click on the **Copy** button below.
|
||||
3. Now paste and visit the URL on your browser. Select your server and click on **Authorize**.
|
||||
|
||||
Open Discord, type `/Promise` and press **Enter**.
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
supabase functions serve discord-bot --no-verify-jwt --env-file ./supabase/.env.local
|
||||
ngrok http 54321
|
||||
```
|
||||
|
||||
export const Page = ({ children }) => <Layout meta={meta} children={children} />
|
||||
|
||||
export default Page
|
||||
@@ -50,7 +50,7 @@ export default function handler(req: Request) {
|
||||
Create an `index.ts` file to execute the handler on incoming requests:
|
||||
|
||||
```ts index.ts
|
||||
import { serve } from 'https://deno.land/std@0.131.0/http/server.ts'
|
||||
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'
|
||||
import handler from './handler.tsx'
|
||||
|
||||
console.log('Hello from og-image Function!')
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import Layout from '~/layouts/DefaultGuideLayout'
|
||||
|
||||
export const meta = {
|
||||
id: 'examples-openai',
|
||||
title: 'OpenAI',
|
||||
description: 'Using OpenAI in Edge Functions.',
|
||||
video: 'https://www.youtube.com/v/29p8kIqyU_Y',
|
||||
}
|
||||
|
||||
<div class="video-container">
|
||||
<iframe
|
||||
src="https://www.youtube-nocookie.com/embed/29p8kIqyU_Y"
|
||||
frameBorder="1"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
Use the [OpenAI completions API](https://platform.openai.com/docs/api-reference/completions) in Supabase Edge Functions.
|
||||
|
||||
```ts index.ts
|
||||
import 'xhr_polyfill'
|
||||
import { serve } from 'std/server'
|
||||
import { CreateCompletionRequest } from 'openai'
|
||||
|
||||
serve(async (req) => {
|
||||
const { query } = await req.json()
|
||||
|
||||
const completionConfig: CreateCompletionRequest = {
|
||||
model: 'text-davinci-003',
|
||||
prompt: query,
|
||||
max_tokens: 256,
|
||||
temperature: 0,
|
||||
stream: true,
|
||||
}
|
||||
|
||||
return fetch('https://api.openai.com/v1/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${Deno.env.get('OPENAI_API_KEY')}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(completionConfig),
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
supabase functions serve --env-file ./supabase/.env.local --no-verify-jwt
|
||||
```
|
||||
|
||||
Use cURL or Postman to make a POST request to http://localhost:54321/functions/v1/openai.
|
||||
|
||||
```bash
|
||||
curl -i --location --request POST http://localhost:54321/functions/v1/openai \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{"query":"What is Supabase?"}'
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
```bash
|
||||
supabase functions deploy --no-verify-jwt openai
|
||||
supabase secrets set --env-file ./supabase/.env.local
|
||||
```
|
||||
|
||||
## Go deeper
|
||||
|
||||
If you're interesting in learning how to use this to build your own ChatGPT, read [the blog post](/blog/chatgpt-supabase-docs) and check out the video:
|
||||
|
||||
<div class="video-container">
|
||||
<iframe
|
||||
src="https://www.youtube-nocookie.com/embed/Yhtjd7yGGGA"
|
||||
frameBorder="1"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
export const Page = ({ children }) => <Layout meta={meta} children={children} />
|
||||
|
||||
export default Page
|
||||
@@ -126,7 +126,7 @@ The fastest way to get started with supabase and Nuxt.js is to use the supabase-
|
||||
<StepHikeCompact.Step step={5}>
|
||||
<StepHikeCompact.Details title="Create client in app">
|
||||
|
||||
Next, in your Next.js app, create a file called supabase-client.js and add the following code to initialize the Supabase client and set your project's credentials:
|
||||
Next, in your Nuxt.js app, create a file called supabase-client.js and add the following code to initialize the Supabase client and set your project's credentials:
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
|
||||
@@ -50,9 +50,9 @@ These variables will be exposed on the browser, and that's completely fine since
|
||||
|
||||
```js title=src/lib/supabaseClient.ts
|
||||
import { createClient } from '@supabase/auth-helpers-sveltekit'
|
||||
import { env } from '$env/dynamic/public'
|
||||
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'
|
||||
|
||||
export const supabase = createClient(env.PUBLIC_SUPABASE_URL, env.PUBLIC_SUPABASE_ANON_KEY)
|
||||
export const supabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY)
|
||||
```
|
||||
|
||||
Optionally, update `src/routes/styles.css` with the [CSS from the example](https://raw.githubusercontent.com/supabase/supabase/master/examples/user-management/svelte-user-management/src/app.css).
|
||||
|
||||
@@ -99,7 +99,7 @@ const handleLogin = async () => {
|
||||
<h1 class="header">Supabase + Vue 3</h1>
|
||||
<p class="description">Sign in via magic link with your email below</p>
|
||||
<div>
|
||||
<input class="inputField" type="email" placeholder="Your email" v-model="email" />
|
||||
<input class="inputField" required type="email" placeholder="Your email" v-model="email" />
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
|
||||
@@ -8,7 +8,7 @@ export const meta = {
|
||||
video: 'https://www.youtube.com/v/mw0DLwItue4',
|
||||
}
|
||||
|
||||
[OneSignal](https://onesignal.com/) is a tool that allows you to send messages across different channels such as the following to keep your users engaged.
|
||||
[OneSignal](https://onesignal.com/) is a tool that allows you to send messages across different channels such as the following to keep your users engaged.
|
||||
|
||||
- Push notifications
|
||||
- SMS
|
||||
@@ -36,7 +36,6 @@ You can find the complete example app along with the edge functions code to send
|
||||
|
||||

|
||||
|
||||
|
||||
## Step 1: Getting started
|
||||
|
||||
Before we dive into the code, this guide assumes that you have the following ready
|
||||
@@ -80,8 +79,7 @@ const supabase = createClient(supabaseUrl, supabaseAnonKey)
|
||||
const Home: NextPage = () => {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
|
||||
const [oneSignalInitialized, setOneSignalInitialized] =
|
||||
useState<boolean>(false)
|
||||
const [oneSignalInitialized, setOneSignalInitialized] = useState<boolean>(false)
|
||||
|
||||
/**
|
||||
* Initializes OneSignal SDK for a given Supabase User ID
|
||||
@@ -123,9 +121,7 @@ const Home: NextPage = () => {
|
||||
const { price } = Object.fromEntries(new FormData(event.currentTarget))
|
||||
if (typeof price !== 'string') return
|
||||
|
||||
const { error } = await supabase
|
||||
.from('orders')
|
||||
.insert({ price: Number(price) })
|
||||
const { error } = await supabase.from('orders').insert({ price: Number(price) })
|
||||
if (error) {
|
||||
alert(error.message)
|
||||
}
|
||||
@@ -142,15 +138,13 @@ const Home: NextPage = () => {
|
||||
|
||||
initialize()
|
||||
|
||||
const authListener = supabase.auth.onAuthStateChange(
|
||||
async (event, session) => {
|
||||
const user = session?.user ?? null
|
||||
setUser(user)
|
||||
if (user) {
|
||||
initializeOneSignal(user.id)
|
||||
}
|
||||
const authListener = supabase.auth.onAuthStateChange(async (event, session) => {
|
||||
const user = session?.user ?? null
|
||||
setUser(user)
|
||||
if (user) {
|
||||
initializeOneSignal(user.id)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return () => {
|
||||
authListener.data.subscription.unsubscribe()
|
||||
@@ -175,10 +169,7 @@ const Home: NextPage = () => {
|
||||
<option value="200">$200</option>
|
||||
<option value="300">$300</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="py-1 px-4 text-lg bg-green-400 rounded"
|
||||
>
|
||||
<button type="submit" className="py-1 px-4 text-lg bg-green-400 rounded">
|
||||
Place an Order
|
||||
</button>
|
||||
</form>
|
||||
@@ -190,10 +181,7 @@ const Home: NextPage = () => {
|
||||
name="email"
|
||||
placeholder="Email"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="py-1 px-4 text-lg bg-green-400 rounded"
|
||||
>
|
||||
<button type="submit" className="py-1 px-4 text-lg bg-green-400 rounded">
|
||||
Send Magic Link
|
||||
</button>
|
||||
</form>
|
||||
@@ -211,7 +199,7 @@ There is quite a bit of stuff going on here, but basically, it’s creating a si
|
||||
Notice that inside the `initializeOneSignal()` function, we are setting the Supabase user ID as an [external user ID of OneSignal](https://documentation.onesignal.com/docs/external-user-ids). This allows us to later send push notifications to the user using their Supabase user ID from the backend, which is very handy.
|
||||
|
||||
```tsx
|
||||
await OneSignal.setExternalUserId(uid);
|
||||
await OneSignal.setExternalUserId(uid)
|
||||
```
|
||||
|
||||
The front-end side of things is done here. Let’s get into the backend.
|
||||
@@ -237,7 +225,7 @@ supabase functions new notify
|
||||
Replace the contents of `supabase/functions/notify/index.ts` with the following
|
||||
|
||||
```tsx
|
||||
import { serve } from 'https://deno.land/std@0.131.0/http/server.ts'
|
||||
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'
|
||||
import * as OneSignal from 'https://esm.sh/@onesignal/node-onesignal@1.0.0-beta7'
|
||||
|
||||
const _OnesignalAppId_ = Deno.env.get('ONESIGNAL_APP_ID')!
|
||||
@@ -263,12 +251,9 @@ serve(async (req) => {
|
||||
}
|
||||
const onesignalApiRes = await onesignal.createNotification(notification)
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ onesignalResponse: onesignalApiRes }),
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
return new Response(JSON.stringify({ onesignalResponse: onesignalApiRes }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to create OneSignal notification', err)
|
||||
return new Response('Server error.', {
|
||||
@@ -295,7 +280,6 @@ ONESIGNAL_REST_API_KEY=YOUR_ONESIGNAL_REST_API_KEY
|
||||
|
||||

|
||||
|
||||
|
||||
Once your environment variables are filled in, you can run the following command to set the environment variable.
|
||||
|
||||
```bash
|
||||
@@ -313,35 +297,35 @@ supabase functions deploy notify --no-verify-jwt
|
||||
Finally, we get to set up the database! Run the following SQL to set up the `orders` table.
|
||||
|
||||
```sql
|
||||
create table if not exists public.orders (
|
||||
id uuid not null primary key default uuid_generate_v4(),
|
||||
created_at timestamptz not null default now(),
|
||||
user_id uuid not null default auth.uid(),
|
||||
create table
|
||||
if not exists public.orders (
|
||||
id uuid not null primary key default uuid_generate_v4 (),
|
||||
created_at timestamptz not null default now (),
|
||||
user_id uuid not null default auth.uid (),
|
||||
price int8 not null
|
||||
);
|
||||
);
|
||||
```
|
||||
|
||||
As you can see, the `orders` table has 4 columns and 3 of them have default values. That means all we need to send from the front-end app is the price. That is why our insert statement looked very simple.
|
||||
|
||||
```tsx
|
||||
const { error } = await supabase.from('orders').insert({
|
||||
price: 100,
|
||||
})
|
||||
price: 100,
|
||||
})
|
||||
```
|
||||
|
||||
Let’s also set up the webhook so that whenever a new row is inserted in the `orders` table, it calls the edge function. Go to `Database > Webhooks` and create a new Database Webhook. The table should be set to `orders` and Events should be inserted. The type should be HTTP Request, the HTTP method should be POST, and the URL should be the URL of your edge function. Hit confirm to save the webhook configuration.
|
||||
|
||||

|
||||
|
||||
At this point, the app should be complete! Run your app locally with `npm run dev`, or deploy your app to a hosting service and see how you receive a push notification when you place an order!
|
||||
At this point, the app should be complete! Run your app locally with `npm run dev`, or deploy your app to a hosting service and see how you receive a push notification when you place an order!
|
||||
Remember that if you decide to deploy your app to a hosting service, you would need to create another OneSignal app configured for your local address.
|
||||
|
||||

|
||||
|
||||
|
||||
## Resources
|
||||
|
||||
This particular example was using Next.js, but you can apply the same principles to implement send push notification, SMS, Emails, and in-app-notifications on other platforms as well.
|
||||
This particular example was using Next.js, but you can apply the same principles to implement send push notification, SMS, Emails, and in-app-notifications on other platforms as well.
|
||||
|
||||
- [OneSignal + Flutter + Supabase example](https://github.com/OneSignalDevelopers/onesignal-supabase-sample-integration-supabase)
|
||||
- [OneSignal Mobile Quickstart](https://documentation.onesignal.com/docs/mobile-sdk-setup)
|
||||
|
||||
@@ -23,27 +23,56 @@ This value is also reported in the [database settings page](https://app.supabase
|
||||
## Database storage management
|
||||
|
||||
Supabase uses network-attached storage to balance performance with scalability.
|
||||
For Pro and Enterprise projects, disk size expands ~1.5x automatically (e.g., 8GB -> 12GB) when you reach 90% of the disk size.
|
||||
Disk size expansion can only occur once every six hours.
|
||||
Pro projects can store up to 1024TB.
|
||||
|
||||
All projects enter read-only mode when you reach 95% of the disk size. In read-only mode, clients will encounter errors such as `cannot execute INSERT in a read-only transaction`.
|
||||
Regular operation (read-write mode) is automatically re-enabled once usage is below 95% of the disk size.
|
||||
- For **Free tier projects**, the database can enter ['read only' mode when you exceed the 500mb limit](#free-tier-project-read-only-mode).
|
||||
- For **Pro and Enterprise tier projects**, your [disk size expands automatically](#paid-tier-disk-auto-scaling).
|
||||
|
||||
If you need more than 1024TB of disk size or require multiple storage expansions in a short period of time, [contact us](https://app.supabase.com/support/new) to learn more about the Enterprise plan.
|
||||
### Free tier project 'read-only' mode
|
||||
|
||||
### Increasing available disk size
|
||||
Free tier projects can enter read-only mode when exceeding the 500mb database size limit.
|
||||
|
||||
1. [Upgrade to the Pro or Enterprise plan](https://app.supabase.com/project/_/settings/billing/subscription) to increase your quota and expand your disk size automatically.
|
||||
In read-only mode, clients will encounter errors such as `cannot execute INSERT in a read-only transaction`.
|
||||
Regular operation (read-write mode) is automatically re-enabled once database storage is below the 500mb database size limit.
|
||||
|
||||
2. Delete data from your project's database to lower its disk usage. If your database is already in read-only mode, run the following command to change the transaction mode to read-write for your session:
|
||||
For more database storage and avoid your database entering read-only mode when exceeding the limit, you can [upgrade to the Pro or Enterprise plan](https://app.supabase.com/project/_/settings/billing/subscription).
|
||||
|
||||
```sql
|
||||
SET
|
||||
default_transaction_read_only = 'off';
|
||||
```
|
||||
### Paid tier disk auto-scaling
|
||||
|
||||
This allows you to delete data from within the session.
|
||||
Pro and Enterprise have disk auto-scaling. Database storage expands automatically when the database reaches 90% of the disk size. The disk is expanded to be 50% larger (e.g., 8GB -> 12GB).
|
||||
Auto-scaling can only take place once every 6 hours.
|
||||
|
||||
<Admonition type="caution">
|
||||
|
||||
If you intend to import a lot of data into your database that would require multiple storage expansions then [reach out to our team](https://app.supabase.com/support/new), otherwise your project might enter a ['read-only' mode](#paid-tier-project-read-only-mode).
|
||||
|
||||
</Admonition>
|
||||
|
||||
You may require multiple storage expansions in a short period of time. For example, you need to upload more than 1.5x the current size of your database storage. This will lead to your database entering a [read-only mode](#paid-tier-project-read-only-mode). You can avoid this by [contacting the support team](https://app.supabase.com/support/new) so that your database can be resized to a sufficient amount before you start uploading to it.
|
||||
|
||||
If you need more than 1024TB of disk size, you can [contact us](https://app.supabase.com/support/new) to learn more about the Enterprise plan.
|
||||
|
||||
### Paid tier project 'read-only' mode
|
||||
|
||||
Database storage expands, at most, once every 6 hours.
|
||||
If within those 6 hours you reach 95% of the disk space, your project will enter read-only mode.
|
||||
|
||||
In read-only mode, clients will encounter errors such as `cannot execute INSERT in a read-only transaction`.
|
||||
Regular operation (read-write mode) is automatically re-enabled once usage is below 95% of the disk size, or when the database automatically resizes after the 6 hour period.
|
||||
|
||||
### Increasing available database space
|
||||
|
||||
If your project is on the free tier, you can [Upgrade to the Pro or Enterprise tier](https://app.supabase.com/project/_/settings/billing/subscription) so that you can start to use more than 500mb.
|
||||
|
||||
For Pro and Enterprise tier projects that are [nearing their disk size limit](#paid-tier-project-read-only-mode), you can increase available disk size by deleting data from your project's database to lower its disk usage.
|
||||
|
||||
If your project database is already in read-only mode, run the following command to change the transaction mode to read-write for your session:
|
||||
|
||||
```sql
|
||||
SET
|
||||
default_transaction_read_only = 'off';
|
||||
```
|
||||
|
||||
This allows you to delete data from within the session.
|
||||
|
||||
### Preoccupied Space
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ Replication for Realtime is disabled for all tables in your new project. On the
|
||||
|
||||
### Migrate Storage objects
|
||||
|
||||
The new project has the old project's Storage buckets, but the Storage objects need to be migrated manually. Use this script to move storage objects from one project to another. If you have more than 10k objects, we can move the objects for you. Just contact us at [support@supabase.com](mailto:support@supabase.com).
|
||||
The new project has the old project's Storage buckets, but the Storage objects need to be migrated manually. Use this script to move storage objects from one project to another.
|
||||
|
||||
```js
|
||||
// npm install @supabase/supabase-js@1
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
@@ -38,7 +38,7 @@ export default function MyApp({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
</Head>
|
||||
<Meta />
|
||||
<DefaultSeo
|
||||
|
||||
@@ -966,6 +966,7 @@ const Beta = (props: Props) => {
|
||||
<Layout hideHeader={true}>
|
||||
<Head>
|
||||
<title>{site_title}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"></meta>
|
||||
<meta name="og:title" property="og:title" content={site_title} />
|
||||
<meta name="twitter:site" content={site_title} />
|
||||
<meta name="twitter:text:title" content={site_title} />
|
||||
|
||||
@@ -83,34 +83,6 @@ export default function IndexPage() {
|
||||
additional: '',
|
||||
cta: 'Get Started',
|
||||
},
|
||||
// {
|
||||
// name: 'Team',
|
||||
// nameBadge: 'New',
|
||||
// costUnit: 'per month per org',
|
||||
// href: 'https://app.supabase.com/new/new-project',
|
||||
// from: true,
|
||||
// priceLabel: 'Starting from',
|
||||
// priceMonthly: 599,
|
||||
// description: 'For scaling teams with permissions & access controls',
|
||||
// warning: '+ any additional usage',
|
||||
// features: [
|
||||
// 'Usage-based pricing',
|
||||
// 'Organization member roles (ABAC)',
|
||||
// 'SOC2',
|
||||
// 'Priority email support & SLAs',
|
||||
// '14 day backups',
|
||||
// '100,000 monthly active users included',
|
||||
// 'Standardized Security Questionnaire',
|
||||
// 'SSO for Supabase Dashboard',
|
||||
// '1 XS compute instance',
|
||||
// '28 day log retention',
|
||||
// ],
|
||||
// scale: '',
|
||||
// shutdown: '',
|
||||
// preface: 'The following features will apply to all projects within the organization:',
|
||||
// additional: '',
|
||||
// cta: 'Get Started',
|
||||
// },
|
||||
{
|
||||
name: 'Enterprise',
|
||||
href: 'https://forms.supabase.com/enterprise',
|
||||
@@ -127,8 +99,8 @@ export default function IndexPage() {
|
||||
`Private Slack channel`,
|
||||
`Uptime SLA`,
|
||||
],
|
||||
priceLabel: 'Custom quotas',
|
||||
priceMonthly: 'Exclusive pricing',
|
||||
priceLabel: '',
|
||||
priceMonthly: 'Contact us',
|
||||
preface: 'These apply to all projects within the organization:',
|
||||
scale: '',
|
||||
shutdown: '',
|
||||
@@ -164,7 +136,7 @@ export default function IndexPage() {
|
||||
name: 'Point in Time Recovery',
|
||||
heroImg: 'addons-pitr-hero',
|
||||
icon: 'pitr-upgrade',
|
||||
price: 'Starts from $5',
|
||||
price: 'Starts from $100',
|
||||
description: 'Roll back to any specific point in time and ensure that data is not lost.',
|
||||
leftCtaText: 'Documentation',
|
||||
leftCtaLink: 'https://supabase.com/docs/guides/platform/backups',
|
||||
@@ -319,7 +291,7 @@ export default function IndexPage() {
|
||||
</p>
|
||||
<p
|
||||
className={`mt-2 gradient-text-scale-500 dark:gradient-text-scale-100 pb-1 ${
|
||||
tier.name !== 'Enterprise' ? 'text-5xl' : 'text-4xl max-w-[75%]'
|
||||
tier.name !== 'Enterprise' ? 'text-5xl' : 'text-4xl'
|
||||
}`}
|
||||
>
|
||||
{tier.name !== 'Enterprise' && '$'}
|
||||
@@ -750,13 +722,15 @@ export default function IndexPage() {
|
||||
<>
|
||||
<span
|
||||
className={`text-scale-1200 ${
|
||||
tier.name !== 'Enterprise' ? 'text-5xl' : 'text-4xl max-w-[75%]'
|
||||
tier.name !== 'Enterprise' ? 'text-5xl' : 'text-4xl'
|
||||
}`}
|
||||
>
|
||||
{tier.name !== 'Enterprise' && '$'}
|
||||
{tier.priceMonthly}
|
||||
</span>
|
||||
<p className="p text-xs mt-1">per project per month</p>
|
||||
{tier.name !== 'Enterprise' && (
|
||||
<p className="p text-xs mt-1">per project per month</p>
|
||||
)}
|
||||
</>
|
||||
|
||||
{tier.warning && (
|
||||
@@ -861,66 +835,12 @@ export default function IndexPage() {
|
||||
<div className="mx-auto max-w-5xl gap-y-10 gap-x-10 lg:grid-cols-2">
|
||||
<div className="sm:py-18 mx-auto px-6 py-16 md:py-24 lg:px-16 lg:py-24 xl:px-20">
|
||||
<h2 className="h3 text-center">Frequently asked questions</h2>
|
||||
<p className="p text-center">
|
||||
Can't find the answer to your question, ask someone in the community either on
|
||||
our Discord or GitHub.
|
||||
</p>
|
||||
<div className="p text-center mt-6">
|
||||
<Link href="https://discord.supabase.com">
|
||||
<a>
|
||||
<Button
|
||||
type="default"
|
||||
className="mr-2"
|
||||
size="small"
|
||||
icon={
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 71 55"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M60.1045 4.8978C55.5792 2.8214 50.7265 1.2916 45.6527 0.41542C45.5603 0.39851 45.468 0.440769 45.4204 0.525289C44.7963 1.6353 44.105 3.0834 43.6209 4.2216C38.1637 3.4046 32.7345 3.4046 27.3892 4.2216C26.905 3.0581 26.1886 1.6353 25.5617 0.525289C25.5141 0.443589 25.4218 0.40133 25.3294 0.41542C20.2584 1.2888 15.4057 2.8186 10.8776 4.8978C10.8384 4.9147 10.8048 4.9429 10.7825 4.9795C1.57795 18.7309 -0.943561 32.1443 0.293408 45.3914C0.299005 45.4562 0.335386 45.5182 0.385761 45.5576C6.45866 50.0174 12.3413 52.7249 18.1147 54.5195C18.2071 54.5477 18.305 54.5139 18.3638 54.4378C19.7295 52.5728 20.9469 50.6063 21.9907 48.5383C22.0523 48.4172 21.9935 48.2735 21.8676 48.2256C19.9366 47.4931 18.0979 46.6 16.3292 45.5858C16.1893 45.5041 16.1781 45.304 16.3068 45.2082C16.679 44.9293 17.0513 44.6391 17.4067 44.3461C17.471 44.2926 17.5606 44.2813 17.6362 44.3151C29.2558 49.6202 41.8354 49.6202 53.3179 44.3151C53.3935 44.2785 53.4831 44.2898 53.5502 44.3433C53.9057 44.6363 54.2779 44.9293 54.6529 45.2082C54.7816 45.304 54.7732 45.5041 54.6333 45.5858C52.8646 46.6197 51.0259 47.4931 49.0921 48.2228C48.9662 48.2707 48.9102 48.4172 48.9718 48.5383C50.038 50.6034 51.2554 52.5699 52.5959 54.435C52.6519 54.5139 52.7526 54.5477 52.845 54.5195C58.6464 52.7249 64.529 50.0174 70.6019 45.5576C70.6551 45.5182 70.6887 45.459 70.6943 45.3942C72.1747 30.0791 68.2147 16.7757 60.1968 4.9823C60.1772 4.9429 60.1437 4.9147 60.1045 4.8978ZM23.7259 37.3253C20.2276 37.3253 17.3451 34.1136 17.3451 30.1693C17.3451 26.225 20.1717 23.0133 23.7259 23.0133C27.308 23.0133 30.1626 26.2532 30.1066 30.1693C30.1066 34.1136 27.28 37.3253 23.7259 37.3253ZM47.3178 37.3253C43.8196 37.3253 40.9371 34.1136 40.9371 30.1693C40.9371 26.225 43.7636 23.0133 47.3178 23.0133C50.9 23.0133 53.7545 26.2532 53.6986 30.1693C53.6986 34.1136 50.9 37.3253 47.3178 37.3253Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Discord
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
<Link href="https://github.com/supabase/supabase/discussions">
|
||||
<a>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
icon={
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
GitHub
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-16">
|
||||
{/* @ts-ignore */}
|
||||
<div className="my-16">
|
||||
<Accordion
|
||||
type="default"
|
||||
openBehaviour="multiple"
|
||||
chevronAlign="right"
|
||||
justified
|
||||
size="medium"
|
||||
className="text-scale-900 dark:text-white"
|
||||
>
|
||||
@@ -940,6 +860,28 @@ export default function IndexPage() {
|
||||
})}
|
||||
</Accordion>
|
||||
</div>
|
||||
<p className="p text-center">
|
||||
Can't find the answer to your question, you can{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://app.supabase.com/support/new"
|
||||
className="transition text-brand-900 hover:text-brand-1000"
|
||||
>
|
||||
open a support ticket
|
||||
</a>{' '}
|
||||
and our team of experts will be able to help.
|
||||
</p>
|
||||
<p className="p text-center">
|
||||
For enterprise enquries,{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://app.supabase.com/support/new"
|
||||
className="transition text-brand-900 hover:text-brand-1000"
|
||||
>
|
||||
you can contact the team here
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-prefetch>
|
||||
|
||||
Generated
+994
-3
File diff suppressed because it is too large.
Load diff
@@ -37,14 +37,13 @@ interface AccordionProps {
|
||||
defaultActiveId?: (string | number)[]
|
||||
icon?: React.ReactNode
|
||||
iconPosition?: Align
|
||||
bordered: boolean
|
||||
onChange?: (item: string | string[]) => void
|
||||
openBehaviour: 'single' | 'multiple'
|
||||
type: Type
|
||||
size: Size
|
||||
defaultValue?: string | string[] | undefined
|
||||
justified: Boolean
|
||||
chevronAlign: Align
|
||||
justified?: Boolean
|
||||
chevronAlign?: Align
|
||||
}
|
||||
|
||||
function Accordion({
|
||||
@@ -58,8 +57,8 @@ function Accordion({
|
||||
type = 'default',
|
||||
// size, // TO DO
|
||||
defaultValue = undefined,
|
||||
justified = true,
|
||||
chevronAlign,
|
||||
justified = false,
|
||||
chevronAlign = 'left',
|
||||
}: AccordionProps) {
|
||||
// const [currentItems, setCurrentItems] = useState(defaultValue || [])
|
||||
|
||||
|
||||
+348
-6
@@ -281,18 +281,360 @@ functions:
|
||||
name: Getting your data
|
||||
code: |
|
||||
```
|
||||
r = supabase
|
||||
.from('countries')
|
||||
.select("*").execute()
|
||||
response = supabase.table('countries').select("*").execute()
|
||||
```
|
||||
data:
|
||||
sql: |
|
||||
```sql
|
||||
create table
|
||||
countries (id int8 primary key, name text);
|
||||
|
||||
insert into
|
||||
countries (id, name)
|
||||
values
|
||||
(1, 'Afghanistan'),
|
||||
(2, 'Albania'),
|
||||
(3, 'Algeria');
|
||||
```
|
||||
response: |
|
||||
```
|
||||
APIResponse(data=[{'id': 1, 'name': 'Afghanistan'},
|
||||
{'id': 2, 'name': 'Albania'},
|
||||
{'id': 3, 'name': 'Algeria'}],
|
||||
count=None)
|
||||
```
|
||||
- id: selecting-specific-columns
|
||||
name: Selecting specific columns
|
||||
code: |
|
||||
```
|
||||
r = await supabase
|
||||
.from('countries')
|
||||
.select('name').execute()
|
||||
response = supabase.table('countries').select('name').execute()
|
||||
```
|
||||
data:
|
||||
sql: |
|
||||
```sql
|
||||
create table
|
||||
countries (id int8 primary key, name text);
|
||||
|
||||
insert into
|
||||
countries (id, name)
|
||||
values
|
||||
(1, 'Afghanistan'),
|
||||
(2, 'Albania'),
|
||||
(3, 'Algeria');
|
||||
```
|
||||
response: |
|
||||
```
|
||||
APIResponse(data=[{'name': 'Afghanistan'},
|
||||
{'name': 'Albania'},
|
||||
{'name': 'Algeria'}],
|
||||
count=None)
|
||||
```
|
||||
- id: query-foreign-tables
|
||||
name: Query foreign tables
|
||||
description: |
|
||||
If your database has foreign key relationships, you can query related tables too.
|
||||
code: |
|
||||
```
|
||||
response = supabase.table('countries').select('name, cities(name)').execute()
|
||||
```
|
||||
data:
|
||||
sql: |
|
||||
```sql
|
||||
create table
|
||||
countries (id int8 primary key, name text);
|
||||
create table
|
||||
cities (
|
||||
id int8 primary key,
|
||||
country_id int8 not null references countries,
|
||||
name text
|
||||
);
|
||||
|
||||
insert into
|
||||
countries (id, name)
|
||||
values
|
||||
(1, 'Germany'),
|
||||
(2, 'Indonesia');
|
||||
insert into
|
||||
cities (id, country_id, name)
|
||||
values
|
||||
(1, 2, 'Bali'),
|
||||
(2, 1, 'Munich');
|
||||
```
|
||||
response: |
|
||||
```
|
||||
APIResponse(data=[{'name': 'Germany', 'cities': [{'name': 'Munich'}]},
|
||||
{'name': 'Indonesia', 'cities': [{'name': 'Bali'}]}],
|
||||
count=None)
|
||||
```
|
||||
- id: query-foreign-tables-through-a-join-table
|
||||
name: Query foreign tables through a join table
|
||||
code: |
|
||||
```
|
||||
response = supabase.table('users').select('name, teams(name)').execute()
|
||||
```
|
||||
data:
|
||||
sql: |
|
||||
```sql
|
||||
create table
|
||||
users (
|
||||
id int8 primary key,
|
||||
name text
|
||||
);
|
||||
create table
|
||||
teams (
|
||||
id int8 primary key,
|
||||
name text
|
||||
);
|
||||
-- join table
|
||||
create table
|
||||
users_teams (
|
||||
user_id int8 not null references users,
|
||||
team_id int8 not null references teams,
|
||||
-- both foreign keys must be part of a composite primary key
|
||||
primary key (user_id, team_id)
|
||||
);
|
||||
|
||||
insert into
|
||||
users (id, name)
|
||||
values
|
||||
(1, 'Kiran'),
|
||||
(2, 'Evan');
|
||||
insert into
|
||||
teams (id, name)
|
||||
values
|
||||
(1, 'Green'),
|
||||
(2, 'Blue');
|
||||
insert into
|
||||
users_teams (user_id, team_id)
|
||||
values
|
||||
(1, 1),
|
||||
(1, 2),
|
||||
(2, 2);
|
||||
```
|
||||
response: |
|
||||
```
|
||||
APIResponse(data=[
|
||||
{'name': 'Kiran', 'teams': [{'name': 'Green'}, {'name': 'Blue'}]},
|
||||
{'name': 'Evan', 'teams': [{'name': 'Blue'}]}
|
||||
],
|
||||
count=None)
|
||||
```
|
||||
description: |
|
||||
If you're in a situation where your tables are **NOT** directly
|
||||
related, but instead are joined by a _join table_, you can still use
|
||||
the `select()` method to query the related data. The join table needs
|
||||
to have the foreign keys as part of its composite primary key.
|
||||
hideCodeBlock: true
|
||||
- id: query-the-same-foreign-table-multiple-times
|
||||
name: Query the same foreign table multiple times
|
||||
code: |
|
||||
```
|
||||
response = supabase.table('messages').select('content,from:sender_id(name),to:receiver_id(name)').execute()
|
||||
```
|
||||
data:
|
||||
sql: |
|
||||
```sql
|
||||
create table
|
||||
users (id int8 primary key, name text);
|
||||
|
||||
create table
|
||||
messages (
|
||||
sender_id int8 not null references users,
|
||||
receiver_id int8 not null references users,
|
||||
content text
|
||||
);
|
||||
|
||||
insert into
|
||||
users (id, name)
|
||||
values
|
||||
(1, 'Kiran'),
|
||||
(2, 'Evan');
|
||||
|
||||
insert into
|
||||
messages (sender_id, receiver_id, content)
|
||||
values
|
||||
(1, 2, '👋');
|
||||
```
|
||||
response: |
|
||||
```
|
||||
APIResponse(data=[{'content': '👋',
|
||||
'from': {'name': 'Kiran'},
|
||||
'to': {'name': 'Evan'}}
|
||||
],
|
||||
count=None)
|
||||
```
|
||||
description: |
|
||||
If you need to query the same foreign table twice, use the name of the
|
||||
joined column to identify which join to use. You can also give each
|
||||
column an alias.
|
||||
hideCodeBlock: true
|
||||
- id: filtering-through-foreign-tables
|
||||
name: Filtering through foreign tables
|
||||
code: |
|
||||
```
|
||||
response = supabase.table('cities').select('name, countries(*)').eq('countries.name', 'Estonia').execute()
|
||||
```
|
||||
data:
|
||||
sql: |
|
||||
```sql
|
||||
create table
|
||||
countries (id int8 primary key, name text);
|
||||
create table
|
||||
cities (
|
||||
id int8 primary key,
|
||||
country_id int8 not null references countries,
|
||||
name text
|
||||
);
|
||||
|
||||
insert into
|
||||
countries (id, name)
|
||||
values
|
||||
(1, 'Germany'),
|
||||
(2, 'Indonesia');
|
||||
insert into
|
||||
cities (id, country_id, name)
|
||||
values
|
||||
(1, 2, 'Bali'),
|
||||
(2, 1, 'Munich');
|
||||
```
|
||||
response: |
|
||||
```
|
||||
APIResponse(data=[{'name': 'Bali', 'countries': None},
|
||||
{'name': 'Munich', 'countries': None}],
|
||||
count=None)
|
||||
```
|
||||
description: |
|
||||
NOT WORKING CURRENTLY
|
||||
If the filter on a foreign table's column is not satisfied, the foreign
|
||||
table returns `[]` or `null` but the parent table is not filtered out.
|
||||
If you want to filter out the parent table rows, use the `!inner` hint
|
||||
hideCodeBlock: true
|
||||
- id: querying-foreign-table-with-count
|
||||
name: Querying foreign table with count
|
||||
code: |
|
||||
```
|
||||
response = supabase.table('countries').select('*, cities(count)').execute()
|
||||
```
|
||||
data:
|
||||
sql: |
|
||||
```sql
|
||||
create table countries (
|
||||
"id" "uuid" primary key default "extensions"."uuid_generate_v4"() not null,
|
||||
"name" text
|
||||
);
|
||||
|
||||
create table cities (
|
||||
"id" "uuid" primary key default "extensions"."uuid_generate_v4"() not null,
|
||||
"name" text,
|
||||
"country_id" "uuid" references public.countries on delete cascade
|
||||
);
|
||||
|
||||
with country as (
|
||||
insert into countries (name)
|
||||
values ('united kingdom') returning id
|
||||
)
|
||||
insert into cities (name, country_id) values
|
||||
('London', (select id from country)),
|
||||
('Manchester', (select id from country)),
|
||||
('Liverpool', (select id from country)),
|
||||
('Bristol', (select id from country));
|
||||
```
|
||||
response: |
|
||||
```
|
||||
APIResponse(data=[{'id': 1, 'name': 'Germany', 'cities': [{'count': 1}]},
|
||||
{'id': 2, 'name': 'Indonesia', 'cities': [{'count': 1}]}],
|
||||
count=None)
|
||||
```
|
||||
description: |
|
||||
You can get the number of rows in a related table by using the
|
||||
**count** property.
|
||||
hideCodeBlock: true
|
||||
- id: querying-with-count-option
|
||||
name: Querying with count option
|
||||
code: |
|
||||
```
|
||||
response = supabase.table('countries').select('*', count='exact').execute()
|
||||
```
|
||||
data:
|
||||
sql: |
|
||||
```sql
|
||||
create table
|
||||
countries (id int8 primary key, name text);
|
||||
|
||||
insert into
|
||||
countries (id, name)
|
||||
values
|
||||
(1, 'Afghanistan'),
|
||||
(2, 'Albania'),
|
||||
(3, 'Algeria');
|
||||
```
|
||||
response: |
|
||||
```
|
||||
APIResponse(data=[{'id': 1, 'name': 'Germany'},
|
||||
{'id': 2, 'name': 'Indonesia'}],
|
||||
count=2)
|
||||
```
|
||||
description: |
|
||||
You can get the number of rows by using the
|
||||
*count* parameter in the select query.
|
||||
hideCodeBlock: true
|
||||
- id: querying-json-data
|
||||
name: Querying JSON data
|
||||
code: |
|
||||
```
|
||||
response = supabase.table('users').select('id, name, address->city').execute()
|
||||
```
|
||||
data:
|
||||
sql: |
|
||||
```sql
|
||||
create table
|
||||
users (
|
||||
id int8 primary key,
|
||||
name text,
|
||||
address jsonb
|
||||
);
|
||||
|
||||
insert into
|
||||
users (id, name, address)
|
||||
values
|
||||
(1, 'Avdotya', '{"city":"Saint Petersburg"}');
|
||||
```
|
||||
response: |
|
||||
```
|
||||
APIResponse(data=[{'id': 1, 'name': 'Avdotya', 'city': 'Saint Petersburg'}], count=None)
|
||||
```
|
||||
description: |
|
||||
You can select and filter data inside of
|
||||
[JSON](/docs/guides/database/json) columns. Postgres offers some
|
||||
[operators](/docs/guides/database/json#query-the-jsonb-data) for
|
||||
querying JSON data.
|
||||
hideCodeBlock: true
|
||||
|
||||
- id: insert
|
||||
title: 'Create data: insert()'
|
||||
$ref: '@supabase/postgrest-js.PostgrestQueryBuilder.insert'
|
||||
examples:
|
||||
- id: create-a-record
|
||||
name: Create a record
|
||||
code: |
|
||||
```
|
||||
data, count = supabase
|
||||
.table('countries')
|
||||
.insert({"id": 1, "name": "Denmark"})
|
||||
.execute()
|
||||
```
|
||||
data:
|
||||
sql: |
|
||||
```sql
|
||||
create table
|
||||
countries (id int8 primary key, name text);
|
||||
```
|
||||
response: |
|
||||
```
|
||||
APIResponse(data=[{'id': 1, 'name': 'Denmark'}], count=None)
|
||||
```
|
||||
hideCodeBlock: true
|
||||
isSpotlight: true
|
||||
|
||||
- id: invoke
|
||||
title: 'invoke()'
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { FC, useState, ReactNode } from 'react'
|
||||
import {
|
||||
Button,
|
||||
IconDownload,
|
||||
IconPlus,
|
||||
IconX,
|
||||
IconTrash,
|
||||
Dropdown,
|
||||
IconColumns,
|
||||
IconChevronDown,
|
||||
} from 'ui'
|
||||
import { saveAs } from 'file-saver'
|
||||
import { FC, useState, ReactNode } from 'react'
|
||||
import { Button, IconDownload, IconX, IconTrash, Dropdown, IconChevronDown } from 'ui'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
import { useStore } from 'hooks'
|
||||
import { checkPermissions, useStore } from 'hooks'
|
||||
import FilterDropdown from './filter'
|
||||
import SortPopover from './sort'
|
||||
import RefreshButton from './RefreshButton'
|
||||
@@ -80,6 +72,9 @@ const DefaultHeader: FC<DefaultHeaderProps> = ({
|
||||
}) => {
|
||||
const canAddNew = onAddRow !== undefined || onAddColumn !== undefined
|
||||
|
||||
// [Joshen] Using this logic to block both column and row creation/update/delete
|
||||
const canCreateColumns = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns')
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -91,71 +86,73 @@ const DefaultHeader: FC<DefaultHeaderProps> = ({
|
||||
<>
|
||||
<div className="h-[20px] w-px border-r border-scale-600"></div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Dropdown
|
||||
side="bottom"
|
||||
align="start"
|
||||
size="medium"
|
||||
overlay={[
|
||||
...(onAddRow !== undefined
|
||||
? [
|
||||
<Dropdown.Item
|
||||
key="add-row"
|
||||
className="group"
|
||||
onClick={onAddRow}
|
||||
disabled={onAddRow === undefined}
|
||||
icon={
|
||||
<div className="-mt-2 pr-1.5">
|
||||
<div className="border border-scale-1000 w-[15px] h-[4px]" />
|
||||
<div className="border border-scale-1000 w-[15px] h-[4px] my-[2px]" />
|
||||
<div
|
||||
className={[
|
||||
'border border-scale-1100 w-[15px] h-[4px] translate-x-0.5',
|
||||
'transition duration-200 group-hover:border-brand-900 group-hover:translate-x-0',
|
||||
].join(' ')}
|
||||
/>
|
||||
{canCreateColumns && (
|
||||
<Dropdown
|
||||
side="bottom"
|
||||
align="start"
|
||||
size="medium"
|
||||
overlay={[
|
||||
...(onAddRow !== undefined
|
||||
? [
|
||||
<Dropdown.Item
|
||||
key="add-row"
|
||||
className="group"
|
||||
onClick={onAddRow}
|
||||
disabled={onAddRow === undefined}
|
||||
icon={
|
||||
<div className="-mt-2 pr-1.5">
|
||||
<div className="border border-scale-1000 w-[15px] h-[4px]" />
|
||||
<div className="border border-scale-1000 w-[15px] h-[4px] my-[2px]" />
|
||||
<div
|
||||
className={[
|
||||
'border border-scale-1100 w-[15px] h-[4px] translate-x-0.5',
|
||||
'transition duration-200 group-hover:border-brand-900 group-hover:translate-x-0',
|
||||
].join(' ')}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="">
|
||||
<p>Insert row</p>
|
||||
<p className="text-scale-1000">Insert a new row into {table.name}</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="">
|
||||
<p>Insert row</p>
|
||||
<p className="text-scale-1000">Insert a new row into {table.name}</p>
|
||||
</div>
|
||||
</Dropdown.Item>,
|
||||
]
|
||||
: []),
|
||||
...(onAddColumn !== undefined
|
||||
? [
|
||||
<Dropdown.Item
|
||||
key="add-column"
|
||||
className="group"
|
||||
onClick={onAddColumn}
|
||||
disabled={onAddColumn === undefined}
|
||||
icon={
|
||||
<div className="flex -mt-2 pr-1.5">
|
||||
<div className="border border-scale-1000 w-[4px] h-[15px]" />
|
||||
<div className="border border-scale-1000 w-[4px] h-[15px] mx-[2px]" />
|
||||
<div
|
||||
className={[
|
||||
'border border-scale-1100 w-[4px] h-[15px] -translate-y-0.5',
|
||||
'transition duration-200 group-hover:border-brand-900 group-hover:translate-y-0',
|
||||
].join(' ')}
|
||||
/>
|
||||
</Dropdown.Item>,
|
||||
]
|
||||
: []),
|
||||
...(onAddColumn !== undefined
|
||||
? [
|
||||
<Dropdown.Item
|
||||
key="add-column"
|
||||
className="group"
|
||||
onClick={onAddColumn}
|
||||
disabled={onAddColumn === undefined}
|
||||
icon={
|
||||
<div className="flex -mt-2 pr-1.5">
|
||||
<div className="border border-scale-1000 w-[4px] h-[15px]" />
|
||||
<div className="border border-scale-1000 w-[4px] h-[15px] mx-[2px]" />
|
||||
<div
|
||||
className={[
|
||||
'border border-scale-1100 w-[4px] h-[15px] -translate-y-0.5',
|
||||
'transition duration-200 group-hover:border-brand-900 group-hover:translate-y-0',
|
||||
].join(' ')}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="">
|
||||
<p>Insert column</p>
|
||||
<p className="text-scale-1000">Insert a new column into {table.name}</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="">
|
||||
<p>Insert column</p>
|
||||
<p className="text-scale-1000">Insert a new column into {table.name}</p>
|
||||
</div>
|
||||
</Dropdown.Item>,
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
>
|
||||
<Button size="tiny" icon={<IconChevronDown size={14} strokeWidth={1.5} />}>
|
||||
Insert
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Dropdown.Item>,
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
>
|
||||
<Button size="tiny" icon={<IconChevronDown size={14} strokeWidth={1.5} />}>
|
||||
Insert
|
||||
</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
IconLock,
|
||||
IconUnlock,
|
||||
} from 'ui'
|
||||
import * as React from 'react'
|
||||
import { useDispatch, useTrackedState } from '../../store'
|
||||
|
||||
type ColumnMenuProps = {
|
||||
|
||||
@@ -8,19 +8,18 @@ import TemplateEditor from './TemplateEditor'
|
||||
const EmailTemplates = observer(() => {
|
||||
return (
|
||||
<div>
|
||||
<div className='flex justify-between items-center'>
|
||||
<div className="flex justify-between items-center">
|
||||
<FormHeader
|
||||
title="Email Templates"
|
||||
description="Customize the emails that will be sent out to your users."
|
||||
/>
|
||||
<Button type="link" icon={<IconExternalLink size={14} strokeWidth={1.5} />}>
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://supabase.com/docs/guides/auth/auth-email-templates"
|
||||
>
|
||||
Email Templates Documentation
|
||||
</a>
|
||||
</Button>
|
||||
<div className="mb-6">
|
||||
<Button type="default" icon={<IconExternalLink size={14} strokeWidth={1.5} />}>
|
||||
<a target="_blank" href="https://supabase.com/docs/guides/auth/auth-email-templates">
|
||||
Documentation
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<FormPanel>
|
||||
<Tabs
|
||||
|
||||
@@ -35,7 +35,7 @@ const BackupsList: FC<Props> = ({}) => {
|
||||
icon={<IconClock size="large" />}
|
||||
primaryText="Free Plan does not include project backups."
|
||||
projectRef={projectRef}
|
||||
secondaryText="Please upgrade to the Pro plan for up to 7 days of scheduled backups."
|
||||
secondaryText="Upgrade to the Pro plan for up to 7 days of scheduled backups."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import Link from 'next/link'
|
||||
import { Button, IconCalendar } from 'ui'
|
||||
import { FormPanel } from 'components/ui/Forms'
|
||||
import { useParams } from 'hooks'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { checkPermissions, useParams } from 'hooks'
|
||||
import { useProjectSubscriptionQuery } from 'data/subscriptions/project-subscription-query'
|
||||
import { getPITRRetentionDuration } from './PITR.utils'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
const PITRNotice = ({}) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: subscription } = useProjectSubscriptionQuery({ projectRef })
|
||||
const retentionPeriod = getPITRRetentionDuration(subscription?.addons ?? [])
|
||||
|
||||
const canUpdateSubscription = checkPermissions(
|
||||
PermissionAction.BILLING_WRITE,
|
||||
'stripe.subscriptions'
|
||||
)
|
||||
|
||||
return (
|
||||
<FormPanel
|
||||
disabled={true}
|
||||
@@ -18,13 +25,30 @@ const PITRNotice = ({}) => {
|
||||
<span className="text-sm text-scale-1000">
|
||||
You can also increase your recovery retention period updating your PITR add-on
|
||||
</span>
|
||||
<Link href={`/project/${projectRef}/settings/billing/update/pro`}>
|
||||
<a>
|
||||
<Button as="span" type="default">
|
||||
Increase retention period
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Button disabled={canUpdateSubscription} as="span" type="default">
|
||||
<Link href={`/project/${projectRef}/settings/billing/update/pro`}>
|
||||
<a>Increase retention period</a>
|
||||
</Link>
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</Tooltip.Trigger>
|
||||
{!canUpdateSubscription && (
|
||||
<Tooltip.Content side="left">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
You need additional permissions to amend subscriptions
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -2,11 +2,13 @@ import dayjs from 'dayjs'
|
||||
import { FC } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { Button, IconAlertCircle } from 'ui'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
|
||||
import { useStore } from 'hooks'
|
||||
import { checkPermissions, useStore } from 'hooks'
|
||||
import { Timezone } from './PITR.types'
|
||||
import { FormPanel } from 'components/ui/Forms'
|
||||
import TimezoneSelection from './TimezoneSelection'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
interface Props {
|
||||
selectedTimezone: Timezone
|
||||
@@ -29,6 +31,11 @@ const PITRStatus: FC<Props> = ({ selectedTimezone, onUpdateTimezone, onSetConfig
|
||||
.tz(selectedTimezone?.utc[0])
|
||||
.format('DD MMM YYYY, HH:mm:ss')
|
||||
|
||||
const canTriggerPhysicalBackup = checkPermissions(
|
||||
PermissionAction.INFRA_EXECUTE,
|
||||
'queue_job.walg.prepare_restore'
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormPanel
|
||||
@@ -41,7 +48,28 @@ const PITRStatus: FC<Props> = ({ selectedTimezone, onUpdateTimezone, onSetConfig
|
||||
You'll be able to pick the right date and time when you begin
|
||||
</span>
|
||||
</div>
|
||||
<Button onClick={() => onSetConfiguration()}>Start a restore</Button>
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Button disabled={!canTriggerPhysicalBackup} onClick={() => onSetConfiguration()}>
|
||||
Start a restore
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
{!canTriggerPhysicalBackup && (
|
||||
<Tooltip.Content side="left">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
You need additional permissions to trigger a PITR recovery
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -60,6 +60,8 @@ const FunctionsList: FC<Props> = ({
|
||||
title="Functions"
|
||||
ctaButtonLabel="Create a new function"
|
||||
onClickCta={() => createFunction()}
|
||||
disabled={!canCreateFunctions}
|
||||
disabledMessage="You need additional permissions to create functions"
|
||||
>
|
||||
<AlphaPreview />
|
||||
<p className="text-sm text-scale-1100">
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { partition } from 'lodash'
|
||||
import { FC, useState, useEffect } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { PostgresRole } from '@supabase/postgres-meta'
|
||||
import { Button, Input, IconPlus, IconSearch, IconX, Badge } from 'ui'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
import { useStore } from 'hooks'
|
||||
import { checkPermissions, useStore } from 'hooks'
|
||||
import SparkBar from 'components/ui/SparkBar'
|
||||
import { FormHeader } from 'components/ui/Forms'
|
||||
import RoleRow from './RoleRow'
|
||||
import DeleteRoleModal from './DeleteRoleModal'
|
||||
import { SUPABASE_ROLES } from './Roles.constants'
|
||||
import { partition } from 'lodash'
|
||||
import CreateRolePanel from './CreateRolePanel'
|
||||
import DeleteRoleModal from './DeleteRoleModal'
|
||||
|
||||
interface Props {
|
||||
onSelectRole: (role: any) => void
|
||||
@@ -26,6 +27,8 @@ const RolesList: FC<Props> = ({}) => {
|
||||
const [isCreatingRole, setIsCreatingRole] = useState(false)
|
||||
const [selectedRoleToDelete, setSelectedRoleToDelete] = useState<any>()
|
||||
|
||||
const canUpdateRoles = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'roles')
|
||||
|
||||
useEffect(() => {
|
||||
const getMaxConnectionLimit = async () => {
|
||||
const res = await meta.query('show max_connections')
|
||||
@@ -149,13 +152,31 @@ const RolesList: FC<Props> = ({}) => {
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<IconPlus size="tiny" />}
|
||||
onClick={() => setIsCreatingRole(true)}
|
||||
>
|
||||
Add role
|
||||
</Button>
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!canUpdateRoles}
|
||||
icon={<IconPlus size="tiny" />}
|
||||
onClick={() => setIsCreatingRole(true)}
|
||||
>
|
||||
Add role
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
{!canUpdateRoles && (
|
||||
<Tooltip.Content align="start" side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200 text-xs',
|
||||
].join(' ')}
|
||||
>
|
||||
You need additional permissions to add a new role
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -184,7 +205,12 @@ const RolesList: FC<Props> = ({}) => {
|
||||
</div>
|
||||
)}
|
||||
{otherRoles.map((role: PostgresRole, i: number) => (
|
||||
<RoleRow key={role.id} role={role} onSelectDelete={setSelectedRoleToDelete} />
|
||||
<RoleRow
|
||||
key={role.id}
|
||||
disabled={!canUpdateRoles}
|
||||
role={role}
|
||||
onSelectDelete={setSelectedRoleToDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { FC, useState } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { Input, Button, IconSearch, IconPlus, IconChevronLeft, IconEdit3, IconTrash } from 'ui'
|
||||
import type { PostgresTable } from '@supabase/postgres-meta'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
import { useStore } from 'hooks'
|
||||
import { useStore, checkPermissions } from 'hooks'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import NoSearchResults from 'components/to-be-cleaned/NoSearchResults'
|
||||
import type { PostgresTable } from '@supabase/postgres-meta'
|
||||
|
||||
interface Props {
|
||||
selectedTable: PostgresTable
|
||||
@@ -30,6 +32,7 @@ const ColumnList: FC<Props> = ({
|
||||
: selectedTable.columns?.filter((column: any) => column.name.includes(filterString))) ?? []
|
||||
|
||||
const isLocked = meta.excludedSchemas.includes(selectedTable.schema ?? '')
|
||||
const canUpdateColumns = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns')
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -58,9 +61,32 @@ const ColumnList: FC<Props> = ({
|
||||
</div>
|
||||
{!isLocked && (
|
||||
<div>
|
||||
<Button icon={<IconPlus />} onClick={() => onAddColumn()}>
|
||||
New column
|
||||
</Button>
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
disabled={!canUpdateColumns}
|
||||
icon={<IconPlus />}
|
||||
onClick={() => onAddColumn()}
|
||||
>
|
||||
New column
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
{!canUpdateColumns && (
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
You need additional permissions to create columns
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -103,20 +129,59 @@ const ColumnList: FC<Props> = ({
|
||||
</Table.td>
|
||||
<Table.td className="px-4 py-3 pr-2">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
onClick={() => onEditColumn(x)}
|
||||
icon={<IconEdit3 />}
|
||||
style={{ padding: 5 }}
|
||||
type="text"
|
||||
disabled={isLocked}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => onDeleteColumn(x)}
|
||||
icon={<IconTrash />}
|
||||
style={{ padding: 5 }}
|
||||
type="text"
|
||||
disabled={isLocked}
|
||||
/>
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
onClick={() => onEditColumn(x)}
|
||||
icon={<IconEdit3 />}
|
||||
style={{ padding: 5 }}
|
||||
type="text"
|
||||
disabled={!canUpdateColumns || isLocked}
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
{!canUpdateColumns && (
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
You need additional permissions to edit columns
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
onClick={() => onDeleteColumn(x)}
|
||||
icon={<IconTrash />}
|
||||
style={{ padding: 5 }}
|
||||
type="text"
|
||||
disabled={!canUpdateColumns || isLocked}
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
{!canUpdateColumns && (
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
You need additional permissions to delete columns
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
|
||||
@@ -12,10 +12,11 @@ import {
|
||||
IconLock,
|
||||
IconCheck,
|
||||
} from 'ui'
|
||||
|
||||
import { partition } from 'lodash'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
import { useStore } from 'hooks'
|
||||
import { useStore, checkPermissions } from 'hooks'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import NoSearchResults from 'components/to-be-cleaned/NoSearchResults'
|
||||
import type { PostgresTable, PostgresSchema } from '@supabase/postgres-meta'
|
||||
@@ -38,8 +39,8 @@ const TableList: FC<Props> = ({
|
||||
onOpenTable = () => {},
|
||||
}) => {
|
||||
const { meta } = useStore()
|
||||
|
||||
const [filterString, setFilterString] = useState<string>('')
|
||||
const canUpdateTables = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables')
|
||||
|
||||
const schemas: PostgresSchema[] = meta.schemas.list()
|
||||
const [protectedSchemas, openSchemas] = partition(schemas, (schema) =>
|
||||
@@ -120,9 +121,32 @@ const TableList: FC<Props> = ({
|
||||
</div>
|
||||
{!isLocked && (
|
||||
<div>
|
||||
<Button icon={<IconPlus />} onClick={() => onAddTable()}>
|
||||
New table
|
||||
</Button>
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
disabled={!canUpdateTables}
|
||||
icon={<IconPlus />}
|
||||
onClick={() => onAddTable()}
|
||||
>
|
||||
New table
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
{!canUpdateTables && (
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
You need additional permissions to create tables
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -183,7 +207,62 @@ const TableList: FC<Props> = ({
|
||||
>
|
||||
{x.columns.length} columns
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<IconEdit3 />}
|
||||
style={{ padding: 5 }}
|
||||
disabled={!canUpdateTables || isLocked}
|
||||
onClick={() => onEditTable(x)}
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
{!canUpdateTables && (
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
You need additional permissions to edit tables
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<IconTrash />}
|
||||
style={{ padding: 5 }}
|
||||
disabled={!canUpdateTables || isLocked}
|
||||
onClick={() => onDeleteTable(x)}
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
{!canUpdateTables && (
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
You need additional permissions to delete tables
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
|
||||
{/* <Button
|
||||
type="text"
|
||||
icon={<IconEdit3 />}
|
||||
style={{ padding: 5 }}
|
||||
@@ -196,7 +275,7 @@ const TableList: FC<Props> = ({
|
||||
style={{ padding: 5 }}
|
||||
disabled={isLocked}
|
||||
onClick={() => onDeleteTable(x)}
|
||||
/>
|
||||
/> */}
|
||||
</div>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
|
||||
@@ -36,7 +36,7 @@ const CreateWrapper = () => {
|
||||
const [selectedTableToEdit, setSelectedTableToEdit] = useState()
|
||||
const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({})
|
||||
|
||||
const canCreateWrapper = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'extensions')
|
||||
const canCreateWrapper = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers')
|
||||
|
||||
const wrapperMeta = WRAPPERS.find((wrapper) => wrapper.name === type)
|
||||
const initialValues =
|
||||
@@ -164,6 +164,7 @@ const CreateWrapper = () => {
|
||||
isSubmitting={isSubmitting}
|
||||
hasChanges={hasChanges}
|
||||
handleReset={handleReset}
|
||||
disabled={!canCreateWrapper}
|
||||
helper={
|
||||
!canCreateWrapper
|
||||
? 'You need additional permissions to create a foreign data wrapper'
|
||||
|
||||
@@ -65,7 +65,7 @@ const EditWrapper = () => {
|
||||
const [selectedTableToEdit, setSelectedTableToEdit] = useState()
|
||||
const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({})
|
||||
|
||||
const canCreateWrapper = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'extensions')
|
||||
const canUpdateWrapper = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers')
|
||||
|
||||
const initialValues =
|
||||
wrapperMeta !== undefined
|
||||
@@ -248,7 +248,7 @@ const EditWrapper = () => {
|
||||
|
||||
return (
|
||||
<FormPanel
|
||||
disabled={!canCreateWrapper}
|
||||
disabled={!canUpdateWrapper}
|
||||
footer={
|
||||
<div className="flex px-8 py-4">
|
||||
<FormActions
|
||||
@@ -259,9 +259,10 @@ const EditWrapper = () => {
|
||||
handleReset()
|
||||
setWrapperTables(initialTables)
|
||||
}}
|
||||
disabled={!canUpdateWrapper}
|
||||
helper={
|
||||
!canCreateWrapper
|
||||
? 'You need additional permissions to create a foreign data wrapper'
|
||||
!canUpdateWrapper
|
||||
? 'You need additional permissions to edit a foreign data wrapper'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { FC } from 'react'
|
||||
import { Collapsible, IconChevronUp, Button, IconExternalLink, IconTrash, IconEdit } from 'ui'
|
||||
import { partition } from 'lodash'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { Collapsible, IconChevronUp, Button, IconExternalLink, IconTrash, IconEdit } from 'ui'
|
||||
|
||||
import { useParams, useStore } from 'hooks'
|
||||
import { useParams, useStore, checkPermissions } from 'hooks'
|
||||
import { WrapperMeta } from './Wrappers.types'
|
||||
import { FDW } from 'data/fdw/fdws-query'
|
||||
import { useFDWDeleteMutation } from 'data/fdw/fdw-delete-mutation'
|
||||
@@ -24,6 +26,8 @@ const WrapperRow: FC<Props> = ({ wrappers = [], wrapperMeta, isOpen, onOpen }) =
|
||||
const { project } = useProjectContext()
|
||||
const { mutateAsync: deleteFDW } = useFDWDeleteMutation()
|
||||
|
||||
const canManageWrappers = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers')
|
||||
|
||||
const onDeleteWrapper = (wrapper: any) => {
|
||||
confirmAlert({
|
||||
title: `Confirm to disable ${wrapper.name}`,
|
||||
@@ -156,21 +160,69 @@ const WrapperRow: FC<Props> = ({ wrappers = [], wrapperMeta, isOpen, onOpen }) =
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Link href={`/project/${ref}/database/wrappers/${wrapper.id}`}>
|
||||
<a>
|
||||
{canManageWrappers ? (
|
||||
<Link href={`/project/${ref}/database/wrappers/${wrapper.id}`}>
|
||||
<a>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<IconEdit strokeWidth={1.5} />}
|
||||
className="py-2"
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
type="default"
|
||||
disabled
|
||||
icon={<IconEdit strokeWidth={1.5} />}
|
||||
className="py-2"
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
{!canManageWrappers && (
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
You need additional permissions to edit wrappers
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<IconEdit strokeWidth={1.5} />}
|
||||
disabled={!canManageWrappers}
|
||||
icon={<IconTrash strokeWidth={1.5} />}
|
||||
className="py-2"
|
||||
onClick={() => onDeleteWrapper(wrapper)}
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<IconTrash strokeWidth={1.5} />}
|
||||
className="py-2"
|
||||
onClick={() => onDeleteWrapper(wrapper)}
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
{!canManageWrappers && (
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
You need additional permissions to add wrappers
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from 'next/link'
|
||||
import { groupBy } from 'lodash'
|
||||
import { useState } from 'react'
|
||||
import { Button, IconExternalLink } from 'ui'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
@@ -11,7 +12,6 @@ import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import WrappersDropdown from './WrappersDropdown'
|
||||
import WrappersDisabledState from './WrappersDisabledState'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { groupBy } from 'lodash'
|
||||
|
||||
const Wrappers = () => {
|
||||
const { meta } = useStore()
|
||||
|
||||
@@ -3,8 +3,10 @@ import Image from 'next/image'
|
||||
import { FC, Fragment } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { Button, Dropdown, IconPlus } from 'ui'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
import { useParams } from 'hooks'
|
||||
import { useParams, checkPermissions } from 'hooks'
|
||||
import { WRAPPERS } from './Wrappers.constants'
|
||||
|
||||
interface Props {
|
||||
@@ -14,6 +16,32 @@ interface Props {
|
||||
|
||||
const WrapperDropdown: FC<Props> = ({ buttonText = 'Add wrapper', align = 'end' }) => {
|
||||
const { ref } = useParams()
|
||||
const canManageWrappers = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers')
|
||||
|
||||
if (!canManageWrappers) {
|
||||
return (
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Button disabled type="primary" icon={<IconPlus strokeWidth={1.5} />}>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
You need additional permissions to add wrappers
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { useRouter } from 'next/router'
|
||||
import { FC, useState, useEffect } from 'react'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import {
|
||||
Alert,
|
||||
IconGlobe,
|
||||
IconTerminal,
|
||||
IconMinimize2,
|
||||
IconMaximize2,
|
||||
IconCheck,
|
||||
IconClipboard,
|
||||
Button,
|
||||
Modal,
|
||||
} from 'ui'
|
||||
|
||||
import { useStore, useParams, checkPermissions } from 'hooks'
|
||||
import Panel from 'components/ui/Panel'
|
||||
import CommandRender from './CommandRender'
|
||||
import { useProjectApiQuery } from 'data/config/project-api-query'
|
||||
import { useEdgeFunctionDeleteMutation } from 'data/edge-functions/edge-functions-delete-mutation'
|
||||
|
||||
interface Props {}
|
||||
|
||||
// [Joshen] Next - additional configs: Verify jwt + import maps
|
||||
|
||||
const EdgeFunctionDetails: FC<Props> = () => {
|
||||
const router = useRouter()
|
||||
const { functions, ui } = useStore()
|
||||
const { ref: projectRef, id } = useParams()
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
const [showInstructions, setShowInstructions] = useState(false)
|
||||
const [selectedFunction, setSelectedFunction] = useState<any>(null)
|
||||
|
||||
const canDeleteEdgeFunction = checkPermissions(PermissionAction.FUNCTIONS_WRITE, '*')
|
||||
const { mutateAsync: deleteEdgeFunction, isLoading: isDeleting } = useEdgeFunctionDeleteMutation()
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedFunction(functions.byId(id))
|
||||
}, [functions.isLoaded, ui.selectedProject])
|
||||
|
||||
const { data: settings } = useProjectApiQuery({ projectRef })
|
||||
|
||||
// Get the API service
|
||||
const apiService = settings?.autoApiService
|
||||
const anonKey = apiService?.service_api_keys.find((x) => x.name === 'anon key')
|
||||
? apiService.defaultApiKey
|
||||
: undefined
|
||||
|
||||
const endpoint = apiService?.app_config.endpoint ?? ''
|
||||
const endpointSections = endpoint.split('.')
|
||||
const functionsEndpoint = [
|
||||
...endpointSections.slice(0, 1),
|
||||
'functions',
|
||||
...endpointSections.slice(1),
|
||||
].join('.')
|
||||
const functionUrl = `${apiService?.protocol}://${functionsEndpoint}/${selectedFunction?.slug}`
|
||||
|
||||
const onConfirmDelete = async () => {
|
||||
if (!projectRef) return console.error('Project ref is required')
|
||||
if (selectedFunction === undefined) return console.error('No edge function selected')
|
||||
|
||||
try {
|
||||
await deleteEdgeFunction({ projectRef, slug: selectedFunction.slug })
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
message: `Successfully deleted "${selectedFunction.name}"`,
|
||||
})
|
||||
router.push(`/project/${projectRef}/functions`)
|
||||
} catch (error: any) {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: `Failed to delete function: ${error.message}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const managementCommands: any = [
|
||||
{
|
||||
command: `supabase functions deploy ${selectedFunction?.slug}`,
|
||||
description: 'This will overwrite the deployed function with your new function',
|
||||
jsx: () => {
|
||||
return (
|
||||
<>
|
||||
<span className="text-brand-1100">supabase</span> functions deploy{' '}
|
||||
{selectedFunction?.slug}
|
||||
</>
|
||||
)
|
||||
},
|
||||
comment: 'Deploy a new version',
|
||||
},
|
||||
{
|
||||
command: `supabase functions delete ${selectedFunction?.slug}`,
|
||||
description: 'This will remove the function and all the logs associated with it',
|
||||
jsx: () => {
|
||||
return (
|
||||
<>
|
||||
<span className="text-brand-1100">supabase</span> functions delete{' '}
|
||||
{selectedFunction?.slug}
|
||||
</>
|
||||
)
|
||||
},
|
||||
comment: 'Delete the function',
|
||||
},
|
||||
]
|
||||
|
||||
const secretCommands: any = [
|
||||
{
|
||||
command: `supabase secrets list`,
|
||||
description: 'This will list all the secrets for your project',
|
||||
jsx: () => {
|
||||
return (
|
||||
<>
|
||||
<span className="text-brand-1100">supabase</span> secrets list
|
||||
</>
|
||||
)
|
||||
},
|
||||
comment: 'View all secrets',
|
||||
},
|
||||
{
|
||||
command: `supabase secrets set NAME1=VALUE1 NAME2=VALUE2`,
|
||||
description: 'This will set secrets for your project',
|
||||
jsx: () => {
|
||||
return (
|
||||
<>
|
||||
<span className="text-brand-1100">supabase</span> secrets set NAME1=VALUE1 NAME2=VALUE2
|
||||
</>
|
||||
)
|
||||
},
|
||||
comment: 'Set secrets for your project',
|
||||
},
|
||||
{
|
||||
command: `supabase secrets unset NAME1 NAME2 `,
|
||||
description: 'This will delete secrets for your project',
|
||||
jsx: () => {
|
||||
return (
|
||||
<>
|
||||
<span className="text-brand-1100">supabase</span> secrets unset NAME1 NAME2
|
||||
</>
|
||||
)
|
||||
},
|
||||
comment: 'Unset secrets for your project',
|
||||
},
|
||||
]
|
||||
|
||||
const invokeCommands: any = [
|
||||
{
|
||||
command: `curl -L -X POST '${functionUrl}' -H 'Authorization: Bearer ${
|
||||
anonKey ?? '[YOUR ANON KEY]'
|
||||
}' --data '{"name":"Functions"}'`,
|
||||
description: 'Invokes the hello function',
|
||||
jsx: () => {
|
||||
return (
|
||||
<>
|
||||
<span className="text-brand-1100">curl</span> -L -X POST 'https://{functionsEndpoint}/
|
||||
{selectedFunction?.slug}' -H 'Authorization: Bearer [YOUR ANON KEY]'{' '}
|
||||
{`--data '{"name":"Functions"}'`}
|
||||
</>
|
||||
)
|
||||
},
|
||||
comment: 'Invoke your function',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-8 pb-16">
|
||||
<div className="space-y-6 rounded border bg-scale-100 px-10 py-8 drop-shadow-sm dark:bg-scale-300">
|
||||
<div className="flex items-center">
|
||||
<p className="text-sm text-scale-1000 w-[130px]">Function Name</p>
|
||||
<p className="text-sm text-scale-1200">{selectedFunction?.name}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<p className="text-sm text-scale-1000 w-[130px]">Endpoint URL</p>
|
||||
<p className="text-sm text-scale-1200">{functionUrl}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="text-scale-900 hover:text-scale-1200 transition ml-2"
|
||||
onClick={(event: any) => {
|
||||
function onCopy(value: any) {
|
||||
setIsCopied(true)
|
||||
navigator.clipboard.writeText(value).then()
|
||||
setTimeout(function () {
|
||||
setIsCopied(false)
|
||||
}, 3000)
|
||||
}
|
||||
event.stopPropagation()
|
||||
onCopy(functionUrl)
|
||||
}}
|
||||
>
|
||||
{isCopied ? (
|
||||
<div className="text-brand-900">
|
||||
<IconCheck size={14} strokeWidth={3} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<div className="block">
|
||||
<IconClipboard size={14} strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<p className="text-sm text-scale-1000 w-[130px]">Created At</p>
|
||||
<p className="text-sm text-scale-1200">
|
||||
{selectedFunction?.created_at &&
|
||||
dayjs(selectedFunction.created_at).format('dddd, MMMM D, YYYY h:mm A')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<p className="text-sm text-scale-1000 w-[130px]">Last Updated At</p>
|
||||
<p className="text-sm text-scale-1200">
|
||||
{selectedFunction?.updated_at &&
|
||||
dayjs(selectedFunction.updated_at).format('dddd, MMMM D, YYYY h:mm A')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm text-scale-1000 w-[130px]">Deployments</span>
|
||||
<div className="text-sm text-scale-1200">{selectedFunction?.version}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start">
|
||||
<span className="text-sm text-scale-1000 w-[130px]">Regions</span>
|
||||
<div className="col-span-2 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2 text-sm text-scale-1200">
|
||||
<IconGlobe size={16} strokeWidth={1.5} />
|
||||
<span>Earth</span>
|
||||
</div>
|
||||
<span className="text-sm text-scale-1000">All functions are deployed globally</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="space-y-6 rounded border bg-scale-100 px-8 py-6 drop-shadow-sm dark:bg-scale-300 transition-all overflow-hidden"
|
||||
style={{ maxHeight: showInstructions ? 800 : 80 }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded border bg-scale-1200 p-2 text-scale-100 dark:bg-scale-100 dark:text-scale-1200">
|
||||
<IconTerminal strokeWidth={2} />
|
||||
</div>
|
||||
<h4>Command line access</h4>
|
||||
</div>
|
||||
<div className="cursor-pointer" onClick={() => setShowInstructions(!showInstructions)}>
|
||||
{showInstructions ? (
|
||||
<IconMinimize2 size={14} strokeWidth={1.5} />
|
||||
) : (
|
||||
<IconMaximize2 size={14} strokeWidth={1.5} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 className="text-base">Deployment management</h5>
|
||||
<CommandRender commands={managementCommands} />
|
||||
<h5 className="text-base">Invoke </h5>
|
||||
<CommandRender commands={invokeCommands} />
|
||||
<h5 className="text-base">Secrets management</h5>
|
||||
<CommandRender commands={secretCommands} />
|
||||
</div>
|
||||
|
||||
<Panel title={<p>Delete Edge Function</p>}>
|
||||
<Panel.Content>
|
||||
<Alert
|
||||
withIcon
|
||||
variant="danger"
|
||||
title="Once your function is deleted, it can no longer be restored"
|
||||
>
|
||||
<p className="mb-3">
|
||||
Make sure you have made a backup if you want to restore your edge function
|
||||
</p>
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
type="danger"
|
||||
disabled={!canDeleteEdgeFunction}
|
||||
loading={selectedFunction?.id === undefined}
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
>
|
||||
Delete edge function
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
{!canDeleteEdgeFunction && (
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
You need additional permissions to delete an edge function
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</Alert>
|
||||
</Panel.Content>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
size="small"
|
||||
alignFooter="right"
|
||||
header={<h3>Confirm to delete {selectedFunction?.name}</h3>}
|
||||
visible={showDeleteModal}
|
||||
loading={isDeleting}
|
||||
onCancel={() => setShowDeleteModal(false)}
|
||||
onConfirm={onConfirmDelete}
|
||||
>
|
||||
<div className="py-6">
|
||||
<Modal.Content>
|
||||
<Alert withIcon variant="warning" title="This action cannot be undone">
|
||||
Ensure that you have made a backup if you want to restore your edge function
|
||||
</Alert>
|
||||
</Modal.Content>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default EdgeFunctionDetails
|
||||
@@ -0,0 +1,103 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { FC, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { IconCheck, IconClipboard } from 'ui'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
|
||||
import { useParams, useStore } from 'hooks'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import { EdgeFunctionsResponse } from 'data/edge-functions/edge-functions-query'
|
||||
|
||||
interface Props {
|
||||
function: EdgeFunctionsResponse
|
||||
}
|
||||
|
||||
const EdgeFunctionsListItem: FC<Props> = ({ function: item }) => {
|
||||
const router = useRouter()
|
||||
const { ui } = useStore()
|
||||
const { ref } = useParams()
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
|
||||
// get the .co or .net TLD from the restUrl
|
||||
const restUrl = ui.selectedProject?.restUrl
|
||||
const restUrlTld = new URL(restUrl as string).hostname.split('.').pop()
|
||||
const functionUrl = `https://${ref}.functions.supabase.${restUrlTld}/${item.slug}`
|
||||
|
||||
return (
|
||||
<Table.tr
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
router.push(`/project/${ref}/functions/${item.id}/details`)
|
||||
}}
|
||||
>
|
||||
<Table.td className="">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm text-scale-1200">{item.name}</p>
|
||||
</div>
|
||||
</Table.td>
|
||||
<Table.td className="">
|
||||
<div className="text-xs text-scale-1100 flex gap-2 items-center truncate">
|
||||
<p className="font-mono truncate hidden md:inline">{functionUrl}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="text-scale-900 hover:text-scale-1200 transition"
|
||||
onClick={(event: any) => {
|
||||
function onCopy(value: any) {
|
||||
setIsCopied(true)
|
||||
navigator.clipboard.writeText(value).then()
|
||||
setTimeout(function () {
|
||||
setIsCopied(false)
|
||||
}, 3000)
|
||||
}
|
||||
event.stopPropagation()
|
||||
onCopy(functionUrl)
|
||||
}}
|
||||
>
|
||||
{isCopied ? (
|
||||
<div className="text-brand-900">
|
||||
<IconCheck size={14} strokeWidth={3} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<div className="block">
|
||||
<IconClipboard size={14} strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</Table.td>
|
||||
<Table.td className="hidden 2xl:table-cell">
|
||||
<p className="text-scale-1100">{dayjs(item.created_at).format('DD MMM, YYYY HH:mm')}</p>
|
||||
</Table.td>
|
||||
<Table.td className="lg:table-cell">
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-sm text-scale-1000">{dayjs(item.updated_at).fromNow()}</p>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
Last updated on {dayjs(item.updated_at).format('DD MMM, YYYY HH:mm')}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Table.td>
|
||||
<Table.td className="lg:table-cell">
|
||||
<p className="text-scale-1100">{item.version}</p>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(EdgeFunctionsListItem)
|
||||
@@ -1,62 +0,0 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { FC } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
|
||||
import { useStore } from 'hooks'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
|
||||
interface Props {
|
||||
function: any
|
||||
}
|
||||
|
||||
const FunctionsListItem: FC<Props> = ({ function: item }) => {
|
||||
const { ui } = useStore()
|
||||
const is_functionConfirmed = item.email_confirmed_at || item.phone_confirmed_at
|
||||
|
||||
const router = useRouter()
|
||||
const ref = ui?.selectedProject?.ref
|
||||
|
||||
// get the .co or .net TLD from the restUrl
|
||||
const restUrl = ui.selectedProject?.restUrl
|
||||
const restUrlTld = new URL(restUrl as string).hostname.split('.').pop()
|
||||
|
||||
return (
|
||||
<Table.tr
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
router.push(`/project/${ref}/functions/${item.id}`)
|
||||
}}
|
||||
>
|
||||
<Table.td className="">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-scale-1200">{item.name}</span>
|
||||
</div>
|
||||
</Table.td>
|
||||
<Table.td className="">
|
||||
<div className="text-xs text-scale-900 flex gap-2 items-center truncate">
|
||||
<span className="font-mono truncate hidden md:inline">{`https://${ref}.functions.supabase.${restUrlTld}/${item.slug}`}</span>
|
||||
<span className="font-mono truncate md:hidden">{`/${item.name}`}</span>
|
||||
</div>
|
||||
</Table.td>
|
||||
<Table.td className="hidden 2xl:table-cell">
|
||||
<span className="text-scale-1100">
|
||||
{dayjs(item.created_at).format('DD MMM, YYYY HH:mm')}
|
||||
</span>
|
||||
</Table.td>
|
||||
<Table.td className="hidden lg:table-cell">
|
||||
<span className="text-scale-1100">
|
||||
{dayjs(item.updated_at).format('DD MMM, YYYY HH:mm')}
|
||||
</span>
|
||||
</Table.td>
|
||||
<Table.td className="hidden 2xl:table-cell">
|
||||
<span className="text-scale-1100">v{item.version}</span>
|
||||
</Table.td>
|
||||
<Table.td className="2xl:table-cell text-right">
|
||||
<span className="text-scale-1100">{item.status}</span>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(FunctionsListItem)
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Tabs } from 'ui'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
// @ts-ignore
|
||||
const FunctionsNav = ({ item }) => {
|
||||
const FunctionsNav = ({ item }: any) => {
|
||||
const router = useRouter()
|
||||
const activeRoute = router.pathname.split('/')[5]
|
||||
const { ref } = router.query
|
||||
@@ -17,9 +16,8 @@ const FunctionsNav = ({ item }) => {
|
||||
router.push(`/project/${ref}/functions/${item.id}/${e === 'metrics' ? '' : e}`)
|
||||
}
|
||||
>
|
||||
<Tabs.Panel id="metrics" label="Metrics" />
|
||||
<Tabs.Panel id="details" label="Details" />
|
||||
{/* <Tabs.Panel id="triggers" label="Triggers" /> */}
|
||||
<Tabs.Panel id="metrics" label="Metrics" />
|
||||
<Tabs.Panel id="invocations" label="Invocations" />
|
||||
<Tabs.Panel id="logs" label="Logs" />
|
||||
</Tabs>
|
||||
|
||||
@@ -10,19 +10,16 @@ import { Commands } from './Functions.types'
|
||||
|
||||
interface Props {
|
||||
closable?: boolean
|
||||
removeBorder?: boolean
|
||||
}
|
||||
|
||||
const TerminalInstructions: FC<Props> = ({ closable = false }) => {
|
||||
const TerminalInstructions: FC<Props> = ({ closable = false, removeBorder = false }) => {
|
||||
const router = useRouter()
|
||||
const { ref: projectRef } = useParams()
|
||||
|
||||
const [showInstructions, setShowInstructions] = useState(!closable)
|
||||
|
||||
const { data: tokens } = useAccessTokensQuery()
|
||||
const { data: settings } = useProjectApiQuery({ projectRef })
|
||||
|
||||
const { data: settings } = useProjectApiQuery({
|
||||
projectRef,
|
||||
})
|
||||
const apiService = settings?.autoApiService
|
||||
const anonKey = apiService?.service_api_keys.find((x) => x.name === 'anon key')
|
||||
? apiService.defaultApiKey
|
||||
@@ -86,7 +83,9 @@ const TerminalInstructions: FC<Props> = ({ closable = false }) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="col-span-7 overflow-hidden transition-all border rounded shadow bg-scale-100 dark:bg-scale-300"
|
||||
className={`col-span-7 overflow-hidden transition-all rounded bg-scale-100 dark:bg-scale-300 ${
|
||||
removeBorder ? '' : 'border shadow'
|
||||
}`}
|
||||
style={{ maxHeight: showInstructions ? 500 : 80 }}
|
||||
>
|
||||
<div className="px-8 py-6 space-y-6">
|
||||
@@ -107,7 +106,7 @@ const TerminalInstructions: FC<Props> = ({ closable = false }) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<CommandRender commands={commands} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import FunctionsListItem from './FunctionsListItem'
|
||||
import EdgeFunctionDetails from './EdgeFunctionDetails'
|
||||
import EdgeFunctionsListItem from './EdgeFunctionsListItem'
|
||||
import FunctionsEmptyState from './FunctionsEmptyState'
|
||||
import TerminalInstructions from './TerminalInstructions'
|
||||
|
||||
export { FunctionsListItem, FunctionsEmptyState, TerminalInstructions }
|
||||
export { EdgeFunctionDetails, EdgeFunctionsListItem, FunctionsEmptyState, TerminalInstructions }
|
||||
@@ -0,0 +1,583 @@
|
||||
/* Based on https://github.com/graphql/graphiql/blob/main/packages/graphiql/src/components/GraphiQL.tsx */
|
||||
|
||||
import {
|
||||
Button,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
CopyIcon,
|
||||
Dialog,
|
||||
ExecuteButton,
|
||||
GraphiQLProvider,
|
||||
HeaderEditor,
|
||||
KeyboardShortcutIcon,
|
||||
MergeIcon,
|
||||
PlusIcon,
|
||||
PrettifyIcon,
|
||||
QueryEditor,
|
||||
ReloadIcon,
|
||||
ResponseEditor,
|
||||
SettingsIcon,
|
||||
Spinner,
|
||||
Tab,
|
||||
Tabs,
|
||||
ToolbarButton,
|
||||
Tooltip,
|
||||
UnStyledButton,
|
||||
useCopyQuery,
|
||||
useDragResize,
|
||||
useEditorContext,
|
||||
useExecutionContext,
|
||||
useMergeQuery,
|
||||
usePluginContext,
|
||||
usePrettifyEditors,
|
||||
useSchemaContext,
|
||||
useStorageContext,
|
||||
useTheme,
|
||||
VariableEditor,
|
||||
} from '@graphiql/react'
|
||||
import { Fetcher } from '@graphiql/toolkit'
|
||||
import clsx from 'clsx'
|
||||
import 'graphiql/graphiql.css'
|
||||
import { PropsWithChildren, useEffect, useState } from 'react'
|
||||
import styles from './graphiql.module.css'
|
||||
|
||||
export type GraphiQLProps = {
|
||||
fetcher: Fetcher
|
||||
theme?: 'dark' | 'light'
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
const GraphiQL = ({ fetcher, theme = 'dark', apiKey }: GraphiQLProps) => {
|
||||
// Ensure props are correct
|
||||
if (typeof fetcher !== 'function') {
|
||||
throw new TypeError(
|
||||
'The `GraphiQL` component requires a `fetcher` function to be passed as prop.'
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<GraphiQLProvider fetcher={fetcher} defaultHeaders={JSON.stringify({ apiKey }, null, 2)}>
|
||||
<GraphiQLInterface theme={theme} />
|
||||
</GraphiQLProvider>
|
||||
)
|
||||
}
|
||||
|
||||
type GraphiQLInterfaceProps = {
|
||||
theme: 'dark' | 'light'
|
||||
}
|
||||
|
||||
export const GraphiQLInterface = ({ theme }: GraphiQLInterfaceProps) => {
|
||||
const editorContext = useEditorContext({ nonNull: true })
|
||||
const executionContext = useExecutionContext({ nonNull: true })
|
||||
const schemaContext = useSchemaContext({ nonNull: true })
|
||||
const storageContext = useStorageContext()
|
||||
const pluginContext = usePluginContext()
|
||||
|
||||
const copy = useCopyQuery()
|
||||
const merge = useMergeQuery()
|
||||
const prettify = usePrettifyEditors()
|
||||
|
||||
const { setTheme } = useTheme()
|
||||
useEffect(() => {
|
||||
setTheme(theme)
|
||||
}, [theme])
|
||||
|
||||
const PluginContent = pluginContext?.visiblePlugin?.content
|
||||
|
||||
const pluginResize = useDragResize({
|
||||
defaultSizeRelation: 1 / 3,
|
||||
direction: 'horizontal',
|
||||
initiallyHidden: pluginContext?.visiblePlugin ? undefined : 'second',
|
||||
onHiddenElementChange: (resizableElement) => {
|
||||
if (resizableElement === 'first') {
|
||||
pluginContext?.setVisiblePlugin(null)
|
||||
}
|
||||
},
|
||||
sizeThresholdSecond: 200,
|
||||
storageKey: 'docExplorerFlex',
|
||||
})
|
||||
const editorResize = useDragResize({
|
||||
direction: 'horizontal',
|
||||
storageKey: 'editorFlex',
|
||||
})
|
||||
const editorToolsResize = useDragResize({
|
||||
defaultSizeRelation: 3,
|
||||
direction: 'vertical',
|
||||
initiallyHidden: (() => {
|
||||
return editorContext.initialVariables || editorContext.initialHeaders ? undefined : 'second'
|
||||
})(),
|
||||
sizeThresholdSecond: 60,
|
||||
storageKey: 'secondaryEditorFlex',
|
||||
})
|
||||
|
||||
const [activeSecondaryEditor, setActiveSecondaryEditor] = useState<'variables' | 'headers'>(
|
||||
() => {
|
||||
return !editorContext.initialVariables && editorContext.initialHeaders
|
||||
? 'headers'
|
||||
: 'variables'
|
||||
}
|
||||
)
|
||||
const [showDialog, setShowDialog] = useState<'settings' | 'short-keys' | null>(null)
|
||||
const [clearStorageStatus, setClearStorageStatus] = useState<'success' | 'error' | null>(null)
|
||||
|
||||
const logo = <GraphiQLLogo />
|
||||
|
||||
const toolbar = (
|
||||
<>
|
||||
<ToolbarButton onClick={() => prettify()} label="Prettify query (Shift-Ctrl-P)">
|
||||
<PrettifyIcon className="graphiql-toolbar-icon" aria-hidden="true" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={() => merge()} label="Merge fragments into query (Shift-Ctrl-M)">
|
||||
<MergeIcon className="graphiql-toolbar-icon" aria-hidden="true" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={() => copy()} label="Copy query (Shift-Ctrl-C)">
|
||||
<CopyIcon className="graphiql-toolbar-icon" aria-hidden="true" />
|
||||
</ToolbarButton>
|
||||
</>
|
||||
)
|
||||
|
||||
const onClickReference = () => {
|
||||
if (pluginResize.hiddenElement === 'second') {
|
||||
pluginResize.setHiddenElement(null)
|
||||
}
|
||||
}
|
||||
|
||||
const modifier =
|
||||
window.navigator.platform.toLowerCase().indexOf('mac') === 0 ? (
|
||||
<code className="graphiql-key">Cmd</code>
|
||||
) : (
|
||||
<code className="graphiql-key">Ctrl</code>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={clsx('graphiql-container', styles.graphiqlContainer)}>
|
||||
<div className="graphiql-main">
|
||||
<div ref={pluginResize.firstRef} style={{ minWidth: 0 }}>
|
||||
<div className={clsx('graphiql-sessions', styles.graphiqlSessions)}>
|
||||
<div className="graphiql-session-header">
|
||||
<Tabs aria-label="Select active operation">
|
||||
{editorContext.tabs.length > 1 ? (
|
||||
<>
|
||||
{editorContext.tabs.map((tab, index) => (
|
||||
<Tab key={tab.id} isActive={index === editorContext.activeTabIndex}>
|
||||
<Tab.Button
|
||||
aria-controls="graphiql-session"
|
||||
id={`graphiql-session-tab-${index}`}
|
||||
onClick={() => {
|
||||
executionContext.stop()
|
||||
editorContext.changeTab(index)
|
||||
}}
|
||||
>
|
||||
{tab.title}
|
||||
</Tab.Button>
|
||||
<Tab.Close
|
||||
onClick={() => {
|
||||
if (editorContext.activeTabIndex === index) {
|
||||
executionContext.stop()
|
||||
}
|
||||
editorContext.closeTab(index)
|
||||
}}
|
||||
/>
|
||||
</Tab>
|
||||
))}
|
||||
<div>
|
||||
<Tooltip label="Add tab">
|
||||
<UnStyledButton
|
||||
type="button"
|
||||
className="graphiql-tab-add"
|
||||
onClick={() => editorContext.addTab()}
|
||||
aria-label="Add tab"
|
||||
>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
</UnStyledButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</Tabs>
|
||||
<div className="graphiql-session-header-right">
|
||||
{editorContext.tabs.length === 1 ? (
|
||||
<div className="graphiql-add-tab-wrapper">
|
||||
<Tooltip label="Add tab">
|
||||
<UnStyledButton
|
||||
type="button"
|
||||
className="graphiql-tab-add"
|
||||
onClick={() => editorContext.addTab()}
|
||||
aria-label="Add tab"
|
||||
>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
</UnStyledButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
{logo}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
role="tabpanel"
|
||||
id="graphiql-session"
|
||||
className={clsx('graphiql-session', styles.graphiqlSession)}
|
||||
aria-labelledby={`graphiql-session-tab-${editorContext.activeTabIndex}`}
|
||||
>
|
||||
<div ref={editorResize.firstRef}>
|
||||
<div
|
||||
className={`graphiql-editors ${styles.graphiqlEditors}${
|
||||
editorContext.tabs.length === 1 ? ' full-height' : ''
|
||||
}`}
|
||||
>
|
||||
<div ref={editorToolsResize.firstRef}>
|
||||
<section className="graphiql-query-editor" aria-label="Query Editor">
|
||||
<div className="graphiql-query-editor-wrapper">
|
||||
<QueryEditor />
|
||||
</div>
|
||||
<div className="graphiql-toolbar" role="toolbar" aria-label="Editor Commands">
|
||||
<ExecuteButton />
|
||||
{toolbar}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div ref={editorToolsResize.dragBarRef}>
|
||||
<div className="graphiql-editor-tools">
|
||||
<div className="graphiql-editor-tools-tabs">
|
||||
<UnStyledButton
|
||||
type="button"
|
||||
className={
|
||||
activeSecondaryEditor === 'variables' &&
|
||||
editorToolsResize.hiddenElement !== 'second'
|
||||
? 'active'
|
||||
: ''
|
||||
}
|
||||
onClick={() => {
|
||||
if (editorToolsResize.hiddenElement === 'second') {
|
||||
editorToolsResize.setHiddenElement(null)
|
||||
}
|
||||
setActiveSecondaryEditor('variables')
|
||||
}}
|
||||
>
|
||||
Variables
|
||||
</UnStyledButton>
|
||||
|
||||
<UnStyledButton
|
||||
type="button"
|
||||
className={
|
||||
activeSecondaryEditor === 'headers' &&
|
||||
editorToolsResize.hiddenElement !== 'second'
|
||||
? 'active'
|
||||
: ''
|
||||
}
|
||||
onClick={() => {
|
||||
if (editorToolsResize.hiddenElement === 'second') {
|
||||
editorToolsResize.setHiddenElement(null)
|
||||
}
|
||||
setActiveSecondaryEditor('headers')
|
||||
}}
|
||||
>
|
||||
Headers
|
||||
</UnStyledButton>
|
||||
</div>
|
||||
<Tooltip
|
||||
label={
|
||||
editorToolsResize.hiddenElement === 'second'
|
||||
? 'Show editor tools'
|
||||
: 'Hide editor tools'
|
||||
}
|
||||
>
|
||||
<UnStyledButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
editorToolsResize.setHiddenElement(
|
||||
editorToolsResize.hiddenElement === 'second' ? null : 'second'
|
||||
)
|
||||
}}
|
||||
aria-label={
|
||||
editorToolsResize.hiddenElement === 'second'
|
||||
? 'Show editor tools'
|
||||
: 'Hide editor tools'
|
||||
}
|
||||
>
|
||||
{editorToolsResize.hiddenElement === 'second' ? (
|
||||
<ChevronUpIcon className="graphiql-chevron-icon" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronDownIcon className="graphiql-chevron-icon" aria-hidden="true" />
|
||||
)}
|
||||
</UnStyledButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={editorToolsResize.secondRef}>
|
||||
<section
|
||||
className="graphiql-editor-tool"
|
||||
aria-label={activeSecondaryEditor === 'variables' ? 'Variables' : 'Headers'}
|
||||
>
|
||||
<VariableEditor
|
||||
isHidden={activeSecondaryEditor !== 'variables'}
|
||||
onClickReference={onClickReference}
|
||||
/>
|
||||
|
||||
<HeaderEditor isHidden={activeSecondaryEditor !== 'headers'} />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={editorResize.dragBarRef}>
|
||||
<div className="graphiql-horizontal-drag-bar" />
|
||||
</div>
|
||||
<div ref={editorResize.secondRef}>
|
||||
<div className="graphiql-response">
|
||||
{executionContext.isFetching ? <Spinner /> : null}
|
||||
<ResponseEditor />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={pluginResize.dragBarRef}>
|
||||
{pluginContext?.visiblePlugin ? <div className="graphiql-horizontal-drag-bar" /> : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={pluginResize.secondRef}
|
||||
style={{
|
||||
// Make sure the container shrinks when containing long
|
||||
// non-breaking texts
|
||||
minWidth: '200px',
|
||||
}}
|
||||
>
|
||||
<div className="graphiql-plugin">{PluginContent ? <PluginContent /> : null}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="graphiql-sidebar">
|
||||
<div className="graphiql-sidebar-section">
|
||||
{pluginContext?.plugins.map((plugin) => {
|
||||
const isVisible = plugin === pluginContext.visiblePlugin
|
||||
const label = `${isVisible ? 'Hide' : 'Show'} ${plugin.title}`
|
||||
const Icon = plugin.icon
|
||||
return (
|
||||
<Tooltip key={plugin.title} label={label}>
|
||||
<UnStyledButton
|
||||
type="button"
|
||||
className={isVisible ? 'active' : ''}
|
||||
onClick={() => {
|
||||
if (isVisible) {
|
||||
pluginContext.setVisiblePlugin(null)
|
||||
pluginResize.setHiddenElement('second')
|
||||
} else {
|
||||
pluginContext.setVisiblePlugin(plugin)
|
||||
pluginResize.setHiddenElement(null)
|
||||
}
|
||||
}}
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon aria-hidden="true" />
|
||||
</UnStyledButton>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="graphiql-sidebar-section">
|
||||
<Tooltip label="Re-fetch GraphQL schema">
|
||||
<UnStyledButton
|
||||
type="button"
|
||||
disabled={schemaContext.isFetching}
|
||||
onClick={() => schemaContext.introspect()}
|
||||
aria-label="Re-fetch GraphQL schema"
|
||||
>
|
||||
<ReloadIcon
|
||||
className={schemaContext.isFetching ? 'graphiql-spin' : ''}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</UnStyledButton>
|
||||
</Tooltip>
|
||||
<Tooltip label="Open short keys dialog">
|
||||
<UnStyledButton
|
||||
type="button"
|
||||
onClick={() => setShowDialog('short-keys')}
|
||||
aria-label="Open short keys dialog"
|
||||
>
|
||||
<KeyboardShortcutIcon aria-hidden="true" />
|
||||
</UnStyledButton>
|
||||
</Tooltip>
|
||||
<Tooltip label="Open settings dialog">
|
||||
<UnStyledButton
|
||||
type="button"
|
||||
onClick={() => setShowDialog('settings')}
|
||||
aria-label="Open settings dialog"
|
||||
>
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
</UnStyledButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog isOpen={showDialog === 'short-keys'} onDismiss={() => setShowDialog(null)}>
|
||||
<div className="graphiql-dialog-header">
|
||||
<div className="graphiql-dialog-title">Short Keys</div>
|
||||
<Dialog.Close onClick={() => setShowDialog(null)} />
|
||||
</div>
|
||||
<div className="graphiql-dialog-section">
|
||||
<div>
|
||||
<table className="graphiql-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Short key</th>
|
||||
<th>Function</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
{modifier}
|
||||
{' + '}
|
||||
<code className="graphiql-key">F</code>
|
||||
</td>
|
||||
<td>Search in editor</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
{modifier}
|
||||
{' + '}
|
||||
<code className="graphiql-key">K</code>
|
||||
</td>
|
||||
<td>Search in documentation</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
{modifier}
|
||||
{' + '}
|
||||
<code className="graphiql-key">Enter</code>
|
||||
</td>
|
||||
<td>Execute query</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code className="graphiql-key">Ctrl</code>
|
||||
{' + '}
|
||||
<code className="graphiql-key">Shift</code>
|
||||
{' + '}
|
||||
<code className="graphiql-key">P</code>
|
||||
</td>
|
||||
<td>Prettify editors</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code className="graphiql-key">Ctrl</code>
|
||||
{' + '}
|
||||
<code className="graphiql-key">Shift</code>
|
||||
{' + '}
|
||||
<code className="graphiql-key">M</code>
|
||||
</td>
|
||||
<td>Merge fragments definitions into operation definition</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code className="graphiql-key">Ctrl</code>
|
||||
{' + '}
|
||||
<code className="graphiql-key">Shift</code>
|
||||
{' + '}
|
||||
<code className="graphiql-key">C</code>
|
||||
</td>
|
||||
<td>Copy query</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code className="graphiql-key">Ctrl</code>
|
||||
{' + '}
|
||||
<code className="graphiql-key">Shift</code>
|
||||
{' + '}
|
||||
<code className="graphiql-key">R</code>
|
||||
</td>
|
||||
<td>Re-fetch schema using introspection</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>
|
||||
The editors use{' '}
|
||||
<a
|
||||
href="https://codemirror.net/5/doc/manual.html#keymaps"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
CodeMirror Key Maps
|
||||
</a>{' '}
|
||||
that add more short keys. This instance of Graph<em>i</em>QL uses <code>sublime</code>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
<Dialog
|
||||
isOpen={showDialog === 'settings'}
|
||||
onDismiss={() => {
|
||||
setShowDialog(null)
|
||||
setClearStorageStatus(null)
|
||||
}}
|
||||
>
|
||||
<div className="graphiql-dialog-header">
|
||||
<div className="graphiql-dialog-title">Settings</div>
|
||||
<Dialog.Close
|
||||
onClick={() => {
|
||||
setShowDialog(null)
|
||||
setClearStorageStatus(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{storageContext ? (
|
||||
<div className="graphiql-dialog-section">
|
||||
<div>
|
||||
<div className="graphiql-dialog-section-title">Clear storage</div>
|
||||
<div className="graphiql-dialog-section-caption">
|
||||
Remove all locally stored data and start fresh.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
state={clearStorageStatus || undefined}
|
||||
disabled={clearStorageStatus === 'success'}
|
||||
onClick={() => {
|
||||
try {
|
||||
storageContext?.clear()
|
||||
setClearStorageStatus('success')
|
||||
} catch {
|
||||
setClearStorageStatus('error')
|
||||
}
|
||||
}}
|
||||
>
|
||||
{clearStorageStatus === 'success'
|
||||
? 'Cleared data'
|
||||
: clearStorageStatus === 'error'
|
||||
? 'Failed'
|
||||
: 'Clear data'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Configure the UI by providing this Component as a child of GraphiQL.
|
||||
function GraphiQLLogo<TProps>(props: PropsWithChildren<TProps>) {
|
||||
return (
|
||||
<div className="graphiql-logo">
|
||||
{props.children || (
|
||||
<a
|
||||
className="graphiql-logo-link"
|
||||
href="https://github.com/graphql/graphiql"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Graph
|
||||
<em>i</em>
|
||||
QL
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
GraphiQLLogo.displayName = 'GraphiQLLogo'
|
||||
|
||||
export default GraphiQL
|
||||
@@ -0,0 +1,27 @@
|
||||
.graphiqlContainer .graphiqlSessions {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.graphiqlContainer .graphiqlSession {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.graphiqlContainer .graphiqlEditors {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.graphiqlContainer .graphiqlEditors:global(.full-height) {
|
||||
margin-top: calc(0px - var(--session-header-height));
|
||||
}
|
||||
|
||||
/* Colors */
|
||||
:global(body.graphiql-dark) .graphiqlContainer {
|
||||
--color-base: 0, 0%, 11%;
|
||||
--color-primary: 153, 50%, 50%;
|
||||
}
|
||||
|
||||
:global(body.graphiql-light) .graphiqlContainer {
|
||||
--color-base: 210, 17%, 98%;
|
||||
--color-primary: 153, 50%, 50%;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FC, useState, useEffect, Fragment } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { useStore } from 'hooks'
|
||||
import { checkPermissions, useStore } from 'hooks'
|
||||
import { pluckObjectFields } from 'lib/helpers'
|
||||
import Loading from 'components/ui/Loading'
|
||||
import Panel from 'components/ui/Panel'
|
||||
@@ -12,6 +12,7 @@ import ToggleField from 'components/to-be-cleaned/forms/ToggleField'
|
||||
import SchemaFormPanel from 'components/to-be-cleaned/forms/SchemaFormPanel'
|
||||
import { Input } from 'ui'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
interface Props {}
|
||||
|
||||
@@ -127,6 +128,11 @@ export const PgbouncerConfig: FC<ConfigProps> = observer(
|
||||
({ projectRef, bouncerInfo, connectionInfo }) => {
|
||||
const { ui } = useStore()
|
||||
|
||||
const canUpdateConnectionPoolingConfiguration = checkPermissions(
|
||||
PermissionAction.UPDATE,
|
||||
'projects'
|
||||
)
|
||||
|
||||
const [updates, setUpdates] = useState<any>({
|
||||
pgbouncer_enabled: bouncerInfo.pgbouncer_enabled,
|
||||
pool_mode: bouncerInfo.pool_mode || 'transaction',
|
||||
@@ -198,6 +204,8 @@ export const PgbouncerConfig: FC<ConfigProps> = observer(
|
||||
onChangeModel={(model: any) => setUpdates(model)}
|
||||
onSubmit={(model: any) => updateConfig(model)}
|
||||
onReset={() => setUpdates(bouncerInfo)}
|
||||
disabled={!canUpdateConnectionPoolingConfiguration}
|
||||
disabledMessage="You need additional permissions to update connection pooling settings"
|
||||
>
|
||||
<div className="space-y-6 py-4">
|
||||
<ToggleField name="pgbouncer_enabled" />
|
||||
|
||||
+111
-21
@@ -1,8 +1,10 @@
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { FC, useState } from 'react'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { Button, IconAlertCircle, IconExternalLink, IconGlobe, IconLock } from 'ui'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
import { useParams } from 'hooks'
|
||||
import { checkPermissions, useParams } from 'hooks'
|
||||
import Panel from 'components/ui/Panel'
|
||||
import { FormPanel, FormHeader } from 'components/ui/Forms'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
@@ -12,6 +14,62 @@ import DisallowAllModal from './DisallowAllModal'
|
||||
import AllowAllModal from './AllowAllModal'
|
||||
import { useNetworkRestrictionsQuery } from 'data/network-restrictions/network-restrictions-query'
|
||||
|
||||
const AllowAllAccessButton: FC<{ disabled: boolean; onClick: (value: boolean) => void }> = ({
|
||||
disabled,
|
||||
onClick,
|
||||
}) => (
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Button type="default" disabled={disabled} onClick={() => onClick(true)}>
|
||||
Allow all access
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
{disabled && (
|
||||
<Tooltip.Content align="center" side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200 w-[250px]',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
You need additional permissions to update network restrictions
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
)
|
||||
|
||||
const DisallowAllAccessButton: FC<{ disabled: boolean; onClick: (value: boolean) => void }> = ({
|
||||
disabled,
|
||||
onClick,
|
||||
}) => (
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Button type="default" disabled={disabled} onClick={() => onClick(true)}>
|
||||
Restrict all access
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
{disabled && (
|
||||
<Tooltip.Content align="center" side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200 w-[250px]',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
You need additional permissions to update network restrictions
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
)
|
||||
|
||||
const NetworkRestrictions = ({}) => {
|
||||
const { ref } = useParams()
|
||||
|
||||
@@ -21,6 +79,8 @@ const NetworkRestrictions = ({}) => {
|
||||
const [selectedRestrictionToRemove, setSelectedRestrictionToRemove] = useState<string>()
|
||||
const { data, isLoading } = useNetworkRestrictionsQuery({ projectRef: ref })
|
||||
|
||||
const canUpdateNetworkRestrictions = checkPermissions(PermissionAction.UPDATE, 'projects')
|
||||
|
||||
const hasAccessToRestrictions = data?.entitlement === 'allowed'
|
||||
const restrictedIps = data?.config.dbAllowedCidrs ?? []
|
||||
const restrictionStatus = data?.status ?? ''
|
||||
@@ -48,7 +108,31 @@ const NetworkRestrictions = ({}) => {
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
<Button onClick={() => setIsAddingAddress(true)}>Add restriction</Button>
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
disabled={!canUpdateNetworkRestrictions}
|
||||
onClick={() => setIsAddingAddress(true)}
|
||||
>
|
||||
Add restriction
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
{!canUpdateNetworkRestrictions && (
|
||||
<Tooltip.Content align="center" side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200 w-[250px]',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
You need additional permissions to update network restrictions
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
@@ -75,12 +159,14 @@ const NetworkRestrictions = ({}) => {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button type="default" onClick={() => setIsAllowingAll(true)}>
|
||||
Allow all access
|
||||
</Button>
|
||||
<Button type="default" onClick={() => setIsDisallowingAll(true)}>
|
||||
Restrict all access
|
||||
</Button>
|
||||
<AllowAllAccessButton
|
||||
disabled={!canUpdateNetworkRestrictions}
|
||||
onClick={setIsAllowingAll}
|
||||
/>
|
||||
<DisallowAllAccessButton
|
||||
disabled={!canUpdateNetworkRestrictions}
|
||||
onClick={setIsDisallowingAll}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Panel.Content>
|
||||
@@ -101,9 +187,10 @@ const NetworkRestrictions = ({}) => {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Button type="default" onClick={() => setIsDisallowingAll(true)}>
|
||||
Restrict all access
|
||||
</Button>
|
||||
<DisallowAllAccessButton
|
||||
disabled={!canUpdateNetworkRestrictions}
|
||||
onClick={setIsDisallowingAll}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : isDisallowedAll ? (
|
||||
@@ -125,9 +212,10 @@ const NetworkRestrictions = ({}) => {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Button type="default" onClick={() => setIsAllowingAll(true)}>
|
||||
Allow all access
|
||||
</Button>
|
||||
<AllowAllAccessButton
|
||||
disabled={!canUpdateNetworkRestrictions}
|
||||
onClick={setIsAllowingAll}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -146,12 +234,14 @@ const NetworkRestrictions = ({}) => {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button type="default" onClick={() => setIsAllowingAll(true)}>
|
||||
Allow all access
|
||||
</Button>
|
||||
<Button type="default" onClick={() => setIsDisallowingAll(true)}>
|
||||
Restrict all access
|
||||
</Button>
|
||||
<AllowAllAccessButton
|
||||
disabled={!canUpdateNetworkRestrictions}
|
||||
onClick={setIsAllowingAll}
|
||||
/>
|
||||
<DisallowAllAccessButton
|
||||
disabled={!canUpdateNetworkRestrictions}
|
||||
onClick={setIsDisallowingAll}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{restrictedIps.map((ip) => {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import Link from 'next/link'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useStore, useFlag } from 'hooks'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { Button, IconDownload, Toggle, IconLoader, Alert } from 'ui'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
import { checkPermissions, useParams, useStore, useFlag } from 'hooks'
|
||||
import {
|
||||
FormHeader,
|
||||
FormPanel,
|
||||
@@ -27,6 +29,8 @@ const SSLConfiguration = () => {
|
||||
})
|
||||
const { mutateAsync: updateSSLEnforcement } = useSSLEnforcementUpdateMutation()
|
||||
|
||||
const canUpdateSSLEnforcement = checkPermissions(PermissionAction.UPDATE, 'projects')
|
||||
|
||||
const hasAccessToSSLEnforcement = !sslEnforcementConfiguration?.isNotAllowed
|
||||
const env = process.env.NEXT_PUBLIC_ENVIRONMENT === 'prod' ? 'prod' : 'staging'
|
||||
const hasSSLCertificate =
|
||||
|
||||
-1
@@ -19,7 +19,6 @@ const ResourcesExceededErrorRenderer: React.FC<ErrorRendererProps> = ({ error, i
|
||||
type="default"
|
||||
chevronAlign="left"
|
||||
size="small"
|
||||
bordered={false}
|
||||
iconPosition="left"
|
||||
>
|
||||
<Accordion.Item id="1" header="Full error message">
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
import { FC, useEffect } from 'react'
|
||||
import { Badge, Button, IconAlertCircle, IconInfo, Loading, IconExternalLink } from 'ui'
|
||||
import Link from 'next/link'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
IconAlertCircle,
|
||||
IconInfo,
|
||||
Loading,
|
||||
IconExternalLink,
|
||||
Alert,
|
||||
IconBookOpen,
|
||||
} from 'ui'
|
||||
|
||||
import { useStore } from 'hooks'
|
||||
import { checkPermissions, useStore } from 'hooks'
|
||||
import { formatBytes } from 'lib/helpers'
|
||||
import { PRICING_TIER_PRODUCT_IDS, USAGE_APPROACHING_THRESHOLD } from 'lib/constants'
|
||||
import SparkBar from 'components/ui/SparkBar'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import InformationBox from 'components/ui/InformationBox'
|
||||
import { USAGE_BASED_PRODUCTS } from 'components/interfaces/Billing/Billing.constants'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { ProjectUsageResponseUsageKeys, useProjectUsageQuery } from 'data/usage/project-usage-query'
|
||||
import { useRouter } from 'next/router'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
|
||||
interface Props {
|
||||
projectRef?: string
|
||||
@@ -21,8 +34,14 @@ const ProjectUsage: FC<Props> = ({ projectRef }) => {
|
||||
const { data: usage, error, isLoading } = useProjectUsageQuery({ projectRef })
|
||||
const router = useRouter()
|
||||
|
||||
const subscriptionTier = ui.selectedProject?.subscription_tier
|
||||
const { project } = useProjectContext()
|
||||
const [isReadOnlyMode, setIsReadOnlyMode] = useState(false)
|
||||
const canUpdateSubscription = checkPermissions(
|
||||
PermissionAction.BILLING_WRITE,
|
||||
'stripe.subscriptions'
|
||||
)
|
||||
|
||||
const subscriptionTier = ui.selectedProject?.subscription_tier
|
||||
const projectHasNoLimits =
|
||||
subscriptionTier === PRICING_TIER_PRODUCT_IDS.PAYG ||
|
||||
subscriptionTier === PRICING_TIER_PRODUCT_IDS.TEAM ||
|
||||
@@ -59,11 +78,25 @@ const ProjectUsage: FC<Props> = ({ projectRef }) => {
|
||||
/>
|
||||
)
|
||||
}
|
||||
// [Terry]
|
||||
// temporary solution to check if project is in read only mode
|
||||
// until we get an api endpoint for this
|
||||
|
||||
async function checkForReadOnlyMode() {
|
||||
const sql = `show default_transaction_read_only;`
|
||||
const connectionString = project?.connectionString
|
||||
const { result: readOnlyStatus } = await executeSql({ projectRef, connectionString, sql })
|
||||
if (readOnlyStatus[0]?.default_transaction_read_only === 'on') setIsReadOnlyMode(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (projectRef) checkForReadOnlyMode()
|
||||
}, [projectRef])
|
||||
|
||||
const isPaidTier = subscriptionTier !== PRICING_TIER_PRODUCT_IDS.FREE
|
||||
|
||||
const featureFootnotes: Record<string, JSX.Element> = {
|
||||
db_size: (
|
||||
db_size: isPaidTier ? (
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex flex-row space-x-4 text-scale-1000">
|
||||
{usage?.disk_volume_size_gb && <span>Disk Size: {usage.disk_volume_size_gb} GB</span>}
|
||||
@@ -77,6 +110,8 @@ const ProjectUsage: FC<Props> = ({ projectRef }) => {
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -127,7 +162,6 @@ const ProjectUsage: FC<Props> = ({ projectRef }) => {
|
||||
<th className="p-3 text-xs font-medium leading-4 text-left text-gray-400" />
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
{/* Line items */}
|
||||
{usage === undefined ? (
|
||||
<div className="px-4 pt-1 pb-4 w-96">
|
||||
@@ -148,14 +182,16 @@ const ProjectUsage: FC<Props> = ({ projectRef }) => {
|
||||
usageElement = (
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Not included in {planName} tier</span>
|
||||
<Button
|
||||
size="tiny"
|
||||
onClick={() =>
|
||||
router.push(`/project/${projectRef}/settings/billing/update`)
|
||||
}
|
||||
>
|
||||
Upgrade to Pro
|
||||
</Button>
|
||||
{canUpdateSubscription && (
|
||||
<Button
|
||||
size="tiny"
|
||||
onClick={() =>
|
||||
router.push(`/project/${projectRef}/settings/billing/update`)
|
||||
}
|
||||
>
|
||||
Upgrade to Pro
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
} else if (showUsageExceedMessage) {
|
||||
@@ -253,6 +289,121 @@ const ProjectUsage: FC<Props> = ({ projectRef }) => {
|
||||
</tbody>
|
||||
)}
|
||||
</table>
|
||||
|
||||
{!isReadOnlyMode && product.title === 'Database' && (
|
||||
<div className="p-6">
|
||||
<Alert title={'Database is in read-only mode'} variant="danger" withIcon>
|
||||
<p>
|
||||
{isPaidTier ? (
|
||||
<>
|
||||
Your disk has reached 95% capacity and has entered{' '}
|
||||
<a
|
||||
href={`https://supabase.com/docs/guides/platform/database-usage#paid-tier-disk-auto-scaling`}
|
||||
className="underline transition hover:text-scale-1200"
|
||||
>
|
||||
read-only mode
|
||||
</a>
|
||||
.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
You have exceeded the 500mb Database size limit and your project is now
|
||||
in{' '}
|
||||
<a
|
||||
href={`https://supabase.com/docs/guides/platform/database-usage#free-tier-project-read-only-mode`}
|
||||
className="underline transition hover:text-scale-1200"
|
||||
>
|
||||
read-only mode
|
||||
</a>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{isPaidTier ? (
|
||||
<>
|
||||
<p>
|
||||
For Pro and Enterprise projects,{' '}
|
||||
<a
|
||||
href="https://supabase.com/docs/guides/platform/database-usage#paid-tier-disk-auto-scaling"
|
||||
className="underline transition hover:text-scale-1200"
|
||||
>
|
||||
Disk Size expands automatically
|
||||
</a>{' '}
|
||||
when it reaches 90% capacity, but can only occur once every six hours.
|
||||
If the disk size has already expanded and then reaches 95% capacity
|
||||
within 6 hours, then{' '}
|
||||
<a
|
||||
href="https://supabase.com/docs/guides/platform/database-usage#paid-tier-project-read-only-mode"
|
||||
className="underline transition hover:text-scale-1200"
|
||||
>
|
||||
your disk will enter read-only mode
|
||||
</a>{' '}
|
||||
until it can resize again after 6 hours.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
If you require help or need your disk changed to read/write mode so you
|
||||
can delete data,{' '}
|
||||
<Link
|
||||
href={`/support/new?ref=${
|
||||
projectRef ? projectRef : ''
|
||||
}&category=Database_unresponsive&Subject=Read%20only%20mode%20issue`}
|
||||
>
|
||||
<a className="underline transition hover:text-scale-1200">
|
||||
you can contact the support team
|
||||
</a>
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
You can either{' '}
|
||||
<a
|
||||
href="https://supabase.com/docs/guides/platform/database-usage#increasing-available-disk-size"
|
||||
className="underline transition hover:text-scale-1200"
|
||||
>
|
||||
reduce your disk usage below 500mb
|
||||
</a>{' '}
|
||||
or{' '}
|
||||
<Link
|
||||
href={`/project/${
|
||||
projectRef ? projectRef : ''
|
||||
}/settings/billing/subscription`}
|
||||
>
|
||||
<a className="underline transition hover:text-scale-1200">
|
||||
upgrade your project
|
||||
</a>
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isPaidTier ? (
|
||||
<Button type="danger" className="mt-3">
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://supabase.com/docs/guides/platform/database-usage#database-storage-management"
|
||||
>
|
||||
Upgrade this project
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="danger"
|
||||
icon={<IconBookOpen size={14} strokeWidth={1.5} />}
|
||||
className="mt-3"
|
||||
>
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://supabase.com/docs/guides/platform/database-usage#database-storage-management"
|
||||
>
|
||||
Database storage management docs
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import dayjs from 'dayjs'
|
||||
import Link from 'next/link'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { FC, Fragment, useEffect, useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
@@ -11,13 +12,13 @@ import {
|
||||
Modal,
|
||||
Form,
|
||||
IconTrash,
|
||||
Badge,
|
||||
IconKey,
|
||||
IconLoader,
|
||||
IconX,
|
||||
IconExternalLink,
|
||||
} from 'ui'
|
||||
import { useParams, useStore } from 'hooks'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useParams, useStore, checkPermissions } from 'hooks'
|
||||
import Divider from 'components/ui/Divider'
|
||||
|
||||
const DEFAULT_KEY_NAME = 'No description provided'
|
||||
@@ -34,6 +35,8 @@ const EncryptionKeysManagement: FC<Props> = ({}) => {
|
||||
const [selectedKeyToRemove, setSelectedKeyToRemove] = useState<any>()
|
||||
const [isDeletingKey, setIsDeletingKey] = useState(false)
|
||||
|
||||
const canManageKeys = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables')
|
||||
|
||||
useEffect(() => {
|
||||
if (id !== undefined) setSearchValue(id)
|
||||
}, [id])
|
||||
@@ -143,9 +146,32 @@ const EncryptionKeysManagement: FC<Props> = ({}) => {
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
<Button type="primary" onClick={() => setShowAddKeyModal(true)}>
|
||||
Add new key
|
||||
</Button>
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!canManageKeys}
|
||||
onClick={() => setShowAddKeyModal(true)}
|
||||
>
|
||||
Add new key
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
{!canManageKeys && (
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
You need additional permissions to add keys
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -178,12 +204,32 @@ const EncryptionKeysManagement: FC<Props> = ({}) => {
|
||||
<p className="text-sm text-scale-1100">
|
||||
Added on {dayjs(key.created).format('MMM D, YYYY')}
|
||||
</p>
|
||||
<Button
|
||||
type="default"
|
||||
className="py-2"
|
||||
icon={<IconTrash />}
|
||||
onClick={() => setSelectedKeyToRemove(key)}
|
||||
/>
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
type="default"
|
||||
className="py-2"
|
||||
icon={<IconTrash />}
|
||||
disabled={!canManageKeys}
|
||||
onClick={() => setSelectedKeyToRemove(key)}
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
{!canManageKeys && (
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
You need additional permissions to delete keys
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
{idx !== keys.length - 1 && <Divider light />}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Link from 'next/link'
|
||||
import dayjs from 'dayjs'
|
||||
import { FC, useState } from 'react'
|
||||
import {
|
||||
@@ -12,9 +13,10 @@ import {
|
||||
IconMoreVertical,
|
||||
IconLoader,
|
||||
} from 'ui'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { VaultSecret } from 'types'
|
||||
import { useParams, useStore } from 'hooks'
|
||||
import Link from 'next/link'
|
||||
import { useParams, useStore, checkPermissions } from 'hooks'
|
||||
|
||||
interface Props {
|
||||
secret: VaultSecret
|
||||
@@ -30,6 +32,8 @@ const SecretRow: FC<Props> = ({ secret, onSelectEdit, onSelectRemove }) => {
|
||||
const [revealedValue, setRevealedValue] = useState<string>()
|
||||
const name = secret?.name ?? 'No name provided'
|
||||
|
||||
const canManageSecrets = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables')
|
||||
|
||||
const revealSecret = async () => {
|
||||
setIsLoading(true)
|
||||
if (revealedValue === undefined) {
|
||||
@@ -106,15 +110,58 @@ const SecretRow: FC<Props> = ({ secret, onSelectEdit, onSelectRemove }) => {
|
||||
className="w-[120px]"
|
||||
overlay={
|
||||
<>
|
||||
<Dropdown.Item icon={<IconEdit3 size="tiny" />} onClick={() => onSelectEdit(secret)}>
|
||||
Edit
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
icon={<IconTrash stroke="red" size="tiny" />}
|
||||
onClick={() => onSelectRemove(secret)}
|
||||
>
|
||||
Delete
|
||||
</Dropdown.Item>
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Dropdown.Item
|
||||
icon={<IconEdit3 size="tiny" />}
|
||||
disabled={!canManageSecrets}
|
||||
onClick={() => onSelectEdit(secret)}
|
||||
>
|
||||
Edit
|
||||
</Dropdown.Item>
|
||||
</Tooltip.Trigger>
|
||||
{!canManageSecrets && (
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
You need additional permissions to edit secrets
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Dropdown.Item
|
||||
disabled={!canManageSecrets}
|
||||
icon={<IconTrash stroke="red" size="tiny" />}
|
||||
onClick={() => onSelectRemove(secret)}
|
||||
>
|
||||
Delete
|
||||
</Dropdown.Item>
|
||||
</Tooltip.Trigger>
|
||||
{!canManageSecrets && (
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
You need additional permissions to delete secrets
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import Link from 'next/link'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { FC, Fragment, useState, useEffect } from 'react'
|
||||
import { IconSearch, Input, Button, Listbox, IconLoader, IconExternalLink, IconX } from 'ui'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
import { useStore, useParams } from 'hooks'
|
||||
import { useStore, useParams, checkPermissions } from 'hooks'
|
||||
import SecretRow from './SecretRow'
|
||||
import EditSecretModal from './EditSecretModal'
|
||||
import DeleteSecretModal from './DeleteSecretModal'
|
||||
@@ -22,6 +24,8 @@ const SecretsManagement: FC<Props> = ({}) => {
|
||||
const [selectedSecretToEdit, setSelectedSecretToEdit] = useState<any>()
|
||||
const [selectedSecretToRemove, setSelectedSecretToRemove] = useState<any>()
|
||||
|
||||
const canManageSecrets = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables')
|
||||
|
||||
useEffect(() => {
|
||||
if (search !== undefined) setSearchValue(search)
|
||||
}, [search])
|
||||
@@ -105,9 +109,32 @@ const SecretsManagement: FC<Props> = ({}) => {
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
<Button type="primary" onClick={() => setShowAddSecretModal(true)}>
|
||||
Add new secret
|
||||
</Button>
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!canManageSecrets}
|
||||
onClick={() => setShowAddSecretModal(true)}
|
||||
>
|
||||
Add new secret
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
{!canManageSecrets && (
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
You need additional permissions to add secrets
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { FC } from 'react'
|
||||
import Link from 'next/link'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { Button, IconAlertCircle, IconLock } from 'ui'
|
||||
import type { PostgresPolicy, PostgresTable } from '@supabase/postgres-meta'
|
||||
|
||||
import { useStore } from 'hooks'
|
||||
import { useStore, checkPermissions } from 'hooks'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
interface Props {
|
||||
table: PostgresTable
|
||||
@@ -14,8 +16,34 @@ const GridHeaderActions: FC<Props> = ({ table }) => {
|
||||
const projectRef = ui.selectedProject?.ref
|
||||
const policies = meta.policies.list((policy: PostgresPolicy) => policy.table_id === table.id)
|
||||
|
||||
const isReadOnly =
|
||||
!checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') &&
|
||||
!checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns')
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-3">
|
||||
{isReadOnly && (
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger className="w-full">
|
||||
<div className="border border-scale-700 rounded bg-scale-500 px-3 py-1 text-xs">
|
||||
Viewing as read-only
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
You need additional permissions to manage your project's data
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
<Link href={`/project/${projectRef}/auth/policies#${table.id}`}>
|
||||
<a>
|
||||
<Button
|
||||
|
||||
@@ -67,6 +67,10 @@ const TableGridEditor: FC<Props> = ({
|
||||
const isVaultEnabled = useFlag('vaultExtension')
|
||||
const [encryptedColumns, setEncryptedColumns] = useState([])
|
||||
|
||||
const isReadOnly =
|
||||
!checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') &&
|
||||
!checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns')
|
||||
|
||||
const getEncryptedColumns = async (table: any) => {
|
||||
const columns = await vault.listEncryptedColumns(table.schema, table.name)
|
||||
setEncryptedColumns(columns)
|
||||
@@ -170,7 +174,7 @@ const TableGridEditor: FC<Props> = ({
|
||||
theme={theme}
|
||||
gridProps={{ height: '100%' }}
|
||||
storageRef={projectRef}
|
||||
editable={canUpdateTables && canEditViaTableEditor}
|
||||
editable={!isReadOnly && canUpdateTables && canEditViaTableEditor}
|
||||
schema={selectedTable.schema}
|
||||
table={gridTable}
|
||||
headerActions={
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ReactElement, useEffect } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
import { useStore, withAuth } from 'hooks'
|
||||
import { useFlag, useStore, withAuth } from 'hooks'
|
||||
import Error from 'components/ui/Error'
|
||||
import ProductMenu from 'components/ui/ProductMenu'
|
||||
import ProjectLayout from 'components/layouts/ProjectLayout/ProjectLayout'
|
||||
@@ -13,7 +13,11 @@ function DocsLayout({ title, children }: { title: string; children: ReactElement
|
||||
const { meta, ui } = useStore()
|
||||
const { data, isLoading, error } = meta.openApi
|
||||
|
||||
const graphiql = useFlag('graphiql')
|
||||
|
||||
const getPage = () => {
|
||||
if (router.pathname.endsWith('graphiql')) return 'graphiql'
|
||||
|
||||
const { page, resource } = router.query
|
||||
if (!page && !resource) return 'introduction'
|
||||
return (page || resource || '') as string
|
||||
@@ -45,7 +49,7 @@ function DocsLayout({ title, children }: { title: string; children: ReactElement
|
||||
productMenu={
|
||||
<ProductMenu
|
||||
page={getPage()}
|
||||
menu={generateDocsMenu(projectRef, tableNames, functionNames)}
|
||||
menu={generateDocsMenu(projectRef, tableNames, functionNames, graphiql)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types'
|
||||
import SVG from 'react-inlinesvg'
|
||||
import { IconBook, IconBookOpen } from 'ui'
|
||||
|
||||
export const generateDocsMenu = (
|
||||
ref: string,
|
||||
tables: string[],
|
||||
functions: string[]
|
||||
functions: string[],
|
||||
showGraphiql: boolean
|
||||
): ProductMenuGroup[] => {
|
||||
return [
|
||||
{
|
||||
@@ -58,6 +60,31 @@ export const generateDocsMenu = (
|
||||
}),
|
||||
],
|
||||
},
|
||||
...(showGraphiql
|
||||
? [
|
||||
{
|
||||
title: 'GraphQL',
|
||||
items: [
|
||||
{
|
||||
name: 'GraphiQL',
|
||||
key: 'graphiql',
|
||||
url: `/project/${ref}/api/graphiql`,
|
||||
icon: (
|
||||
<SVG
|
||||
src="/img/graphql.svg"
|
||||
style={{ width: `${16}px`, height: `${16}px` }}
|
||||
className="text-scale-1200"
|
||||
preProcessor={(code) =>
|
||||
code.replace(/svg/, 'svg class="m-auto text-color-inherit"')
|
||||
}
|
||||
/>
|
||||
),
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: 'More Resources',
|
||||
items: [
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { FC, useEffect, ReactNode } from 'react'
|
||||
import { FC, useEffect, ReactNode, useState } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { IconCode } from 'ui'
|
||||
import { Button, IconExternalLink, IconCode, Modal, IconTerminal } from 'ui'
|
||||
|
||||
import { checkPermissions, useStore, withAuth } from 'hooks'
|
||||
import { checkPermissions, useParams, useStore, withAuth } from 'hooks'
|
||||
import FunctionsNav from '../interfaces/Functions/FunctionsNav'
|
||||
import BaseLayout from 'components/layouts'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { TerminalInstructions } from 'components/interfaces/Functions'
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
@@ -16,10 +17,10 @@ interface Props {
|
||||
}
|
||||
|
||||
const FunctionsLayout: FC<Props> = ({ title, children }) => {
|
||||
const { functions, ui } = useStore()
|
||||
const router = useRouter()
|
||||
|
||||
const { id, ref } = router.query
|
||||
const { id, ref } = useParams()
|
||||
const { functions, ui } = useStore()
|
||||
const [showTerminalInstructions, setShowTerminalInstructions] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (ui.selectedProjectRef) functions.load()
|
||||
@@ -87,7 +88,7 @@ const FunctionsLayout: FC<Props> = ({ title, children }) => {
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="item-center flex flex-col justify-between gap-y-4 xl:flex-row">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<div
|
||||
className={[
|
||||
'h-6 w-6 rounded border border-brand-600 bg-brand-300',
|
||||
@@ -97,35 +98,55 @@ const FunctionsLayout: FC<Props> = ({ title, children }) => {
|
||||
<IconCode size={14} strokeWidth={3} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link href={`/project/${ref}/functions`}>
|
||||
<h1 className="cursor-pointer text-2xl text-scale-1200 transition-colors hover:text-scale-1100">
|
||||
Edge Functions
|
||||
</h1>
|
||||
</Link>
|
||||
<p className="mt-1 text-scale-1000">Beta</p>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link href={`/project/${ref}/functions`}>
|
||||
<h1 className="cursor-pointer text-2xl text-scale-1200 transition-colors hover:text-scale-1100">
|
||||
Edge Functions
|
||||
</h1>
|
||||
</Link>
|
||||
<p className="mt-1 text-scale-1000">Beta</p>
|
||||
{name && (
|
||||
<div className="mt-1.5 flex items-center space-x-4">
|
||||
<span className="text-scale-1000">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
height="16"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
shapeRendering="geometricPrecision"
|
||||
>
|
||||
<path d="M16 3.549L7.12 20.600"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<h5 className="text-lg text-scale-1200">{name}</h5>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
type="default"
|
||||
icon={<IconTerminal size={14} strokeWidth={1.5} />}
|
||||
onClick={() => setShowTerminalInstructions(true)}
|
||||
>
|
||||
Terminal Instructions
|
||||
</Button>
|
||||
<Link href="https://supabase.com/docs/guides/functions">
|
||||
<a target="_link">
|
||||
<Button
|
||||
type="default"
|
||||
icon={<IconExternalLink size={14} strokeWidth={1.5} />}
|
||||
>
|
||||
Documentation
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{name && (
|
||||
<>
|
||||
<span className="text-scale-800 dark:text-scale-700">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
height="16"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
shapeRendering="geometricPrecision"
|
||||
>
|
||||
<path d="M16 3.549L7.12 20.600"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<h5 className="text-2xl text-scale-1200">{name}</h5>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{item && <FunctionsNav item={item} />}
|
||||
@@ -140,6 +161,24 @@ const FunctionsLayout: FC<Props> = ({ title, children }) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
size="xlarge"
|
||||
visible={showTerminalInstructions}
|
||||
onCancel={() => setShowTerminalInstructions(false)}
|
||||
header={<h3>Deploying an edge function to your project</h3>}
|
||||
customFooter={
|
||||
<div className="w-full flex items-center justify-end">
|
||||
<Button type="primary" size="tiny" onClick={() => setShowTerminalInstructions(false)}>
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="py-4">
|
||||
<TerminalInstructions removeBorder />
|
||||
</div>
|
||||
</Modal>
|
||||
</BaseLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,19 +11,43 @@ import HelpPopover from './HelpPopover'
|
||||
import NotificationsPopover from './NotificationsPopover'
|
||||
import { getResourcesExceededLimits } from 'components/ui/OveragesBanner/OveragesBanner.utils'
|
||||
import { useProjectUsageQuery } from 'data/usage/project-usage-query'
|
||||
import { Badge } from 'ui'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const LayoutHeader = ({ customHeaderComponents, breadcrumbs = [], headerBorder = true }: any) => {
|
||||
const { ui } = useStore()
|
||||
const { selectedOrganization, selectedProject } = ui
|
||||
|
||||
const { ref: projectRef } = useParams()
|
||||
const { project } = useProjectContext()
|
||||
|
||||
const [isReadOnlyMode, setIsReadOnlyMode] = useState(false)
|
||||
|
||||
// [Terry]
|
||||
// temporary solution to check if project is in read only mode
|
||||
// until we get an api endpoint for this
|
||||
async function checkForReadOnlyMode() {
|
||||
const sql = `show default_transaction_read_only;`
|
||||
const connectionString = project?.connectionString
|
||||
const { result: readOnlyStatus } = await executeSql({ projectRef, connectionString, sql })
|
||||
if (readOnlyStatus[0]?.default_transaction_read_only === 'on') setIsReadOnlyMode(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (projectRef) checkForReadOnlyMode()
|
||||
}, [projectRef])
|
||||
|
||||
const { data: usage } = useProjectUsageQuery({ projectRef })
|
||||
const resourcesExceededLimits = getResourcesExceededLimits(usage)
|
||||
|
||||
const projectHasNoLimits =
|
||||
ui.selectedProject?.subscription_tier === PRICING_TIER_PRODUCT_IDS.PAYG ||
|
||||
ui.selectedProject?.subscription_tier === PRICING_TIER_PRODUCT_IDS.ENTERPRISE ||
|
||||
ui.selectedProject?.subscription_tier === PRICING_TIER_PRODUCT_IDS.TEAM
|
||||
|
||||
const showOverUsageBadge =
|
||||
selectedProject?.subscription_tier !== undefined &&
|
||||
!projectHasNoLimits &&
|
||||
@@ -63,6 +87,18 @@ const LayoutHeader = ({ customHeaderComponents, breadcrumbs = [], headerBorder =
|
||||
{/* Project Dropdown */}
|
||||
<ProjectDropdown />
|
||||
|
||||
{/* [Terry] Temporary until we figure out how we want to display this permanently */}
|
||||
{/* context: https://www.notion.so/supabase/DB-Disk-Size-Free-tier-Read-only-Critical-f2b8937c13a149e3ac769fe5888f6db0*/}
|
||||
{!isReadOnlyMode && (
|
||||
<div className="ml-2">
|
||||
<Link href={`/project/${projectRef}/settings/billing/usage`}>
|
||||
<a>
|
||||
<Badge color="red">Project is in read-only mode</Badge>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* [Joshen TODO] Temporarily hidden until usage endpoint is sorted out */}
|
||||
{/* {showOverUsageBadge && (
|
||||
<div className="ml-2">
|
||||
|
||||
@@ -40,6 +40,7 @@ const StorageLayout: FC<Props> = ({ title, children }) => {
|
||||
buckets,
|
||||
} = storageExplorerStore || {}
|
||||
|
||||
// [Joshen] At the moment, without the service key, we won't be able to pull any data from storage
|
||||
const { data: settings, isLoading } = useProjectApiQuery({ projectRef })
|
||||
const apiService = settings?.autoApiService
|
||||
const serviceKey = find(apiService?.service_api_keys ?? [], (key) => key.tags === 'service_role')
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { FC } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { find } from 'lodash'
|
||||
import { useRouter } from 'next/router'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { useParams } from 'hooks'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { Button, Menu, IconLoader, Alert, IconEdit } from 'ui'
|
||||
|
||||
import { useParams } from 'hooks'
|
||||
import BucketRow from './BucketRow'
|
||||
import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore'
|
||||
import { useProjectApiQuery } from 'data/config/project-api-query'
|
||||
|
||||
interface Props {}
|
||||
|
||||
@@ -30,22 +33,47 @@ const StorageMenu: FC<Props> = () => {
|
||||
openToggleBucketPublicModal,
|
||||
} = storageExplorerStore || {}
|
||||
|
||||
const { data: settings, isLoading } = useProjectApiQuery({ projectRef: ref })
|
||||
const apiService = settings?.autoApiService
|
||||
const serviceKey = find(apiService?.service_api_keys ?? [], (key) => key.tags === 'service_role')
|
||||
const canAccessStorage = !isLoading && apiService && serviceKey !== undefined
|
||||
|
||||
return (
|
||||
<Menu type="pills" className="my-6 flex flex-grow flex-col px-5">
|
||||
<div className="mb-6 px-2">
|
||||
<Button
|
||||
block
|
||||
type="default"
|
||||
icon={
|
||||
<div className="text-scale-900">
|
||||
<IconEdit size={14} />
|
||||
</div>
|
||||
}
|
||||
style={{ justifyContent: 'start' }}
|
||||
onClick={openCreateBucketModal}
|
||||
>
|
||||
New bucket
|
||||
</Button>
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger className="w-full">
|
||||
<Button
|
||||
block
|
||||
type="default"
|
||||
icon={
|
||||
<div className="text-scale-900">
|
||||
<IconEdit size={14} />
|
||||
</div>
|
||||
}
|
||||
disabled={!canAccessStorage}
|
||||
style={{ justifyContent: 'start' }}
|
||||
onClick={openCreateBucketModal}
|
||||
>
|
||||
New bucket
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
{!canAccessStorage && (
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
You need additional permissions to create buckets
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div className="">
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useState } from 'react'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { Button, Form, IconClock, Input, Listbox } from 'ui'
|
||||
|
||||
import { useStore } from 'hooks'
|
||||
import { checkPermissions, useStore } from 'hooks'
|
||||
import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query'
|
||||
import { useProjectStorageConfigUpdateUpdateMutation } from 'data/config/project-storage-config-update-mutation'
|
||||
import UpgradeToPro from 'components/ui/UpgradeToPro'
|
||||
import { convertFromBytes, convertToBytes } from './StorageSettings.utils'
|
||||
import { StorageSizeUnits, STORAGE_FILE_SIZE_LIMIT_MAX_BYTES } from './StorageSettings.constants'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
export type StorageSettingsProps = {
|
||||
projectRef: string | undefined
|
||||
@@ -42,6 +44,8 @@ const StorageConfig = ({ config, projectRef }: any) => {
|
||||
const [selectedUnit, setSelectedUnit] = useState(unit)
|
||||
let initialValues = { fileSizeLimit: value, unformattedFileSizeLimit: fileSizeLimit }
|
||||
|
||||
const canUpdateStorageSettings = checkPermissions(PermissionAction.UPDATE, 'projects')
|
||||
|
||||
const formattedMaxSizeBytes = `${new Intl.NumberFormat('en-US').format(
|
||||
STORAGE_FILE_SIZE_LIMIT_MAX_BYTES
|
||||
)} bytes`
|
||||
@@ -138,7 +142,7 @@ const StorageConfig = ({ config, projectRef }: any) => {
|
||||
id="fileSizeLimit"
|
||||
name="fileSizeLimit"
|
||||
type="number"
|
||||
disabled={isFreeTier}
|
||||
disabled={isFreeTier || !canUpdateStorageSettings}
|
||||
step={1}
|
||||
onKeyPress={(event) => {
|
||||
if (event.charCode < 48 || event.charCode > 57) {
|
||||
@@ -184,13 +188,20 @@ const StorageConfig = ({ config, projectRef }: any) => {
|
||||
icon={<IconClock size="large" />}
|
||||
primaryText="Free Plan has a fixed upload file size limit of 50 MB."
|
||||
projectRef={projectRef}
|
||||
secondaryText="Please upgrade to Pro plan for a configurable upload file size limit of up to 5 GB."
|
||||
secondaryText="Upgrade to the Pro plan for a configurable upload file size limit of up to 5 GB."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t border-scale-400" />
|
||||
<div className="flex justify-between px-8 py-4">
|
||||
<div className="flex items-center justify-end w-full gap-2">
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
{!canUpdateStorageSettings ? (
|
||||
<p className="text-sm text-scale-1000">
|
||||
You need additional permissions to update storage settings
|
||||
</p>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="default"
|
||||
@@ -204,7 +215,9 @@ const StorageConfig = ({ config, projectRef }: any) => {
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={isSubmitting}
|
||||
disabled={!hasChanges && hasChanges !== undefined}
|
||||
disabled={
|
||||
!canUpdateStorageSettings || (!hasChanges && hasChanges !== undefined)
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
|
||||
@@ -2,10 +2,19 @@ import { AutoForm } from 'uniforms-bootstrap4'
|
||||
import Ajv from 'ajv'
|
||||
import { JSONSchemaBridge } from 'uniforms-bridge-json-schema'
|
||||
|
||||
const SchemaForm = ({ schema, model, children, formRef, onSubmit, onChangeModel = () => {} }) => {
|
||||
const SchemaForm = ({
|
||||
schema,
|
||||
model,
|
||||
disabled = false,
|
||||
children,
|
||||
formRef,
|
||||
onSubmit,
|
||||
onChangeModel = () => {},
|
||||
}) => {
|
||||
const validatedSchema = new JSONSchemaBridge(schema, createValidator(schema))
|
||||
return (
|
||||
<AutoForm
|
||||
disabled={disabled}
|
||||
onChangeModel={onChangeModel}
|
||||
ref={formRef}
|
||||
schema={validatedSchema}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import SchemaForm from './SchemaForm'
|
||||
import React, { useState } from 'react'
|
||||
import { Button } from 'ui'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import SchemaForm from './SchemaForm'
|
||||
|
||||
export default function SchemaFormPanel({
|
||||
schema,
|
||||
@@ -13,6 +14,8 @@ export default function SchemaFormPanel({
|
||||
submitLabel = 'Save',
|
||||
cancelLabel = 'Cancel',
|
||||
message = '',
|
||||
disabled = false,
|
||||
disabledMessage = '',
|
||||
}) {
|
||||
let formRef
|
||||
const [submitButtonLoading, setSubmitButtonLoading] = useState(false)
|
||||
@@ -48,29 +51,34 @@ export default function SchemaFormPanel({
|
||||
<div className="flex-1 text-left">
|
||||
<h6>{title || ''}</h6>
|
||||
</div>
|
||||
<div
|
||||
className={`flex transition duration-150 ${
|
||||
hasChanged ? 'opacity-100' : 'cursor-default opacity-0'
|
||||
}`}
|
||||
>
|
||||
<Button onClick={onClickCancel} type="default" disabled={!hasChanged}>
|
||||
{cancelLabel || 'Cancel'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onClickSubmit}
|
||||
loading={submitButtonLoading}
|
||||
disabled={!hasChanged}
|
||||
type="primary"
|
||||
className="ml-2 hover:border-green-500"
|
||||
{disabled && disabledMessage ? (
|
||||
<p className="text-sm text-scale-1000">{disabledMessage}</p>
|
||||
) : (
|
||||
<div
|
||||
className={`flex transition duration-150 ${
|
||||
hasChanged ? 'opacity-100' : 'cursor-default opacity-0'
|
||||
}`}
|
||||
>
|
||||
{submitLabel || 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={onClickCancel} type="default" disabled={!hasChanged}>
|
||||
{cancelLabel || 'Cancel'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onClickSubmit}
|
||||
loading={submitButtonLoading}
|
||||
disabled={disabled || !hasChanged}
|
||||
type="primary"
|
||||
className="ml-2 hover:border-green-500"
|
||||
>
|
||||
{submitLabel || 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm text-scale-900">{message}</span>
|
||||
</div>
|
||||
|
||||
<div className="Form section-block--body px-6 py-3">
|
||||
<SchemaForm
|
||||
disabled={disabled}
|
||||
onChangeModel={(model) => {
|
||||
setHasChanged(true)
|
||||
if (onChangeModel) onChangeModel(model)
|
||||
|
||||
@@ -16,20 +16,12 @@ const FlagProvider: FC = ({ children }) => {
|
||||
const { Provider } = FlagContext
|
||||
const [store, setStore] = useState({})
|
||||
|
||||
useEffect(() => {
|
||||
// [Joshen] getFlags get triggered everytime the tab refocuses but this should be okay
|
||||
// as per https://configcat.com/docs/sdk-reference/js/#polling-modes:
|
||||
// The polling downloads the config.json at the set interval and are stored in the internal cache
|
||||
// which subsequently all getValueAsync() calls are served from there
|
||||
if (IS_PLATFORM) getFlags(profile)
|
||||
}, [profile])
|
||||
|
||||
const getFlags = async (user?: User) => {
|
||||
if (!client) {
|
||||
client = configcat.getClient(
|
||||
process.env.NEXT_PUBLIC_CONFIGCAT_SDK_KEY ?? '',
|
||||
configcat.PollingMode.AutoPoll,
|
||||
{ pollIntervalSeconds: 10 }
|
||||
{ pollIntervalSeconds: 600 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -44,6 +36,14 @@ const FlagProvider: FC = ({ children }) => {
|
||||
setStore(flagStore)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// [Joshen] getFlags get triggered everytime the tab refocuses but this should be okay
|
||||
// as per https://configcat.com/docs/sdk-reference/js/#polling-modes:
|
||||
// The polling downloads the config.json at the set interval and are stored in the internal cache
|
||||
// which subsequently all getValueAsync() calls are served from there
|
||||
if (IS_PLATFORM) getFlags(profile)
|
||||
}, [profile])
|
||||
|
||||
return <Provider value={store}>{children}</Provider>
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,11 @@ import { Button } from 'ui'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import Link from 'next/link'
|
||||
import { FC, ReactNode } from 'react'
|
||||
import { useStore } from 'hooks'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
|
||||
import { checkPermissions, useStore, useFlag, useParams } from 'hooks'
|
||||
import { PRICING_TIER_PRODUCT_IDS } from 'lib/constants'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
interface Props {
|
||||
icon?: ReactNode
|
||||
@@ -14,8 +17,16 @@ interface Props {
|
||||
|
||||
const UpgradeToPro: FC<Props> = ({ icon, primaryText, projectRef, secondaryText }) => {
|
||||
const { ui } = useStore()
|
||||
const { ref } = useParams()
|
||||
const tier = ui.selectedProject?.subscription_tier
|
||||
|
||||
const canUpdateSubscription = checkPermissions(
|
||||
PermissionAction.BILLING_WRITE,
|
||||
'stripe.subscriptions'
|
||||
)
|
||||
const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate')
|
||||
const isEnterprise = tier === PRICING_TIER_PRODUCT_IDS.ENTERPRISE
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
@@ -33,13 +44,52 @@ const UpgradeToPro: FC<Props> = ({ icon, primaryText, projectRef, secondaryText
|
||||
<p className="text-sm text-scale-1100">{secondaryText}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href={`/project/${projectRef}/settings/billing/update`}>
|
||||
<a>
|
||||
<Button type="primary">
|
||||
{tier === PRICING_TIER_PRODUCT_IDS.FREE ? 'Upgrade to Pro' : 'Modify subscription'}
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Button type="primary" disabled={!canUpdateSubscription || projectUpdateDisabled}>
|
||||
<Link
|
||||
href={
|
||||
isEnterprise
|
||||
? `/project/${ref}/settings/billing/update/enterprise`
|
||||
: `/project/${ref}/settings/billing/update`
|
||||
}
|
||||
>
|
||||
<a>
|
||||
{tier === PRICING_TIER_PRODUCT_IDS.FREE
|
||||
? 'Upgrade to Pro'
|
||||
: 'Modify subscription'}
|
||||
</a>
|
||||
</Link>
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</Tooltip.Trigger>
|
||||
{!canUpdateSubscription || projectUpdateDisabled ? (
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'border border-scale-200 text-center', //border
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow', // background
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
{projectUpdateDisabled ? (
|
||||
<>
|
||||
Subscription changes are currently disabled.
|
||||
<br />
|
||||
Our engineers are working on a fix.
|
||||
</>
|
||||
) : !canUpdateSubscription ? (
|
||||
'You need additional permissions to amend subscriptions'
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { delete_ } from 'lib/common/fetch'
|
||||
import { API_ADMIN_URL } from 'lib/constants'
|
||||
import { edgeFunctionsKeys } from './keys'
|
||||
|
||||
export type EdgeFunctionsDeleteVariables = {
|
||||
projectRef: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export async function deleteEdgeFunction({ projectRef, slug }: EdgeFunctionsDeleteVariables) {
|
||||
if (!projectRef) throw new Error('projectRef is required')
|
||||
|
||||
const response = await delete_(`${API_ADMIN_URL}/projects/${projectRef}/functions/${slug}`, {})
|
||||
if (response.error) {
|
||||
throw response.error
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
type EdgeFunctionsDeleteData = Awaited<ReturnType<typeof deleteEdgeFunction>>
|
||||
|
||||
export const useEdgeFunctionDeleteMutation = ({
|
||||
onSuccess,
|
||||
...options
|
||||
}: Omit<
|
||||
UseMutationOptions<EdgeFunctionsDeleteData, unknown, EdgeFunctionsDeleteVariables>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<EdgeFunctionsDeleteData, unknown, EdgeFunctionsDeleteVariables>(
|
||||
(vars) => deleteEdgeFunction(vars),
|
||||
{
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef } = variables
|
||||
await queryClient.invalidateQueries(edgeFunctionsKeys.list(projectRef), {
|
||||
refetchType: 'all',
|
||||
})
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
...options,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query'
|
||||
import { get } from 'lib/common/fetch'
|
||||
import { API_ADMIN_URL } from 'lib/constants'
|
||||
import { useCallback } from 'react'
|
||||
import { edgeFunctionsKeys } from './keys'
|
||||
|
||||
export type EdgeFunctionsVariables = { projectRef?: string }
|
||||
|
||||
export type EdgeFunctionsResponse = {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
status: string
|
||||
version: number
|
||||
created_at: number
|
||||
updated_at: number
|
||||
verify_jwt: boolean
|
||||
import_map: boolean
|
||||
}
|
||||
|
||||
export async function getEdgeFunctions(
|
||||
{ projectRef }: EdgeFunctionsVariables,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (!projectRef) throw new Error('projectRef is required')
|
||||
|
||||
const response = await get(`${API_ADMIN_URL}/projects/${projectRef}/functions`, {
|
||||
signal,
|
||||
})
|
||||
if (response.error) throw response.error
|
||||
return response as EdgeFunctionsResponse[]
|
||||
}
|
||||
|
||||
export type EdgeFunctionsData = Awaited<ReturnType<typeof getEdgeFunctions>>
|
||||
export type EdgeFunctionsError = unknown
|
||||
|
||||
export const useEdgeFunctionsQuery = <TData = EdgeFunctionsData>(
|
||||
{ projectRef }: EdgeFunctionsVariables,
|
||||
{ enabled = true, ...options }: UseQueryOptions<EdgeFunctionsData, EdgeFunctionsError, TData> = {}
|
||||
) =>
|
||||
useQuery<EdgeFunctionsData, EdgeFunctionsError, TData>(
|
||||
edgeFunctionsKeys.list(projectRef),
|
||||
({ signal }) => getEdgeFunctions({ projectRef }, signal),
|
||||
{ enabled: enabled && typeof projectRef !== 'undefined', ...options }
|
||||
)
|
||||
|
||||
export const useEdgeFunctionsPrefetch = ({ projectRef }: EdgeFunctionsVariables) => {
|
||||
const client = useQueryClient()
|
||||
|
||||
return useCallback(() => {
|
||||
if (projectRef) {
|
||||
client.prefetchQuery(edgeFunctionsKeys.list(projectRef), ({ signal }) =>
|
||||
getEdgeFunctions({ projectRef }, signal)
|
||||
)
|
||||
}
|
||||
}, [projectRef])
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const edgeFunctionsKeys = {
|
||||
list: (projectRef: string | undefined) => ['projects', projectRef, 'edge-functions'] as const,
|
||||
}
|
||||
@@ -15,6 +15,8 @@
|
||||
"prettier:write": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@graphiql/react": "^0.15.0",
|
||||
"@graphiql/toolkit": "^0.8.0",
|
||||
"@hcaptcha/react-hcaptcha": "^1.4.4",
|
||||
"@headlessui/react": "^1.4.1",
|
||||
"@monaco-editor/react": "^4.3.1",
|
||||
@@ -37,12 +39,16 @@
|
||||
"awesome-debounce-promise": "^2.1.0",
|
||||
"blueimp-md5": "^2.19.0",
|
||||
"clipboard": "^2.0.8",
|
||||
"clsx": "^1.2.1",
|
||||
"common": "*",
|
||||
"config": "*",
|
||||
"configcat-js": "^7.0.0",
|
||||
"dayjs": "^1.11.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"generate-password": "^1.7.0",
|
||||
"graphiql": "^2.2.0",
|
||||
"graphql": "^16.6.0",
|
||||
"graphql-ws": "^5.11.3",
|
||||
"html-to-image": "^1.10.8",
|
||||
"immutability-helper": "^3.1.1",
|
||||
"ip-address": "^8.1.0",
|
||||
|
||||
@@ -16,6 +16,7 @@ import dayjs from 'dayjs'
|
||||
import customParseFormat from 'dayjs/plugin/customParseFormat'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
|
||||
// @ts-ignore
|
||||
import Prism from 'prism-react-renderer/prism'
|
||||
@@ -44,6 +45,7 @@ import useAutoAuthRedirect from 'hooks/misc/useAutoAuthRedirect'
|
||||
dayjs.extend(customParseFormat)
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
dart(Prism)
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { createGraphiQLFetcher } from '@graphiql/toolkit'
|
||||
|
||||
import ExtensionCard from 'components/interfaces/Database/Extensions/ExtensionCard'
|
||||
import GraphiQL from 'components/interfaces/GraphQL/GraphiQL'
|
||||
import { DocsLayout } from 'components/layouts'
|
||||
import Connecting from 'components/ui/Loading/Loading'
|
||||
import { useParams, useStore } from 'hooks'
|
||||
import { useProjectApiQuery } from 'data/config/project-api-query'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { NextPageWithLayout } from 'types'
|
||||
import { IconAlertCircle } from 'ui'
|
||||
|
||||
const GraphiQLPage: NextPageWithLayout = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { ui, meta } = useStore()
|
||||
|
||||
const isExtensionsLoading = meta.extensions.isLoading
|
||||
const pgGraphqlExtension = meta.extensions.byId('pg_graphql')
|
||||
|
||||
useEffect(() => {
|
||||
if (ui.selectedProject?.ref) {
|
||||
// Schemas may be needed when enabling the GraphQL extension
|
||||
meta.schemas.load()
|
||||
meta.extensions.load()
|
||||
}
|
||||
}, [ui.selectedProject?.ref])
|
||||
|
||||
const {
|
||||
data: settings,
|
||||
isError: isProjectSettingsError,
|
||||
isLoading: isProjectSettingsLoading,
|
||||
} = useProjectApiQuery({
|
||||
projectRef,
|
||||
})
|
||||
|
||||
const apiService = settings?.autoApiService
|
||||
const anonKey = apiService?.service_api_keys.find((x) => x.name === 'anon key')
|
||||
? apiService.defaultApiKey
|
||||
: undefined
|
||||
const endpoint = settings?.autoApiService.app_config.endpoint ?? ''
|
||||
|
||||
const graphqlUrl = `https://${endpoint}/graphql/v1`
|
||||
|
||||
const fetcher = useMemo(() => {
|
||||
return createGraphiQLFetcher({
|
||||
url: graphqlUrl,
|
||||
fetch,
|
||||
})
|
||||
}, [graphqlUrl])
|
||||
|
||||
if (isProjectSettingsLoading || (isExtensionsLoading && !pgGraphqlExtension)) {
|
||||
return <Connecting />
|
||||
}
|
||||
|
||||
if (isProjectSettingsError) {
|
||||
return (
|
||||
<div className="py-8 flex items-center justify-center space-x-2">
|
||||
<IconAlertCircle size={16} strokeWidth={1.5} />
|
||||
<p className="text-sm text-scale-1100">Failed to retrieve API keys</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (pgGraphqlExtension?.installed_version === null) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col justify-center items-center px-4">
|
||||
<div className="max-w-md w-full">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl mt-8 mb-2">Enable the GraphQL Extension</h1>
|
||||
<h2 className="text-scale-1100 text-sm">
|
||||
Toggle the switch below to enable the GraphQL extension. You can then use the GraphQL
|
||||
API with your Supabase Database.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<ExtensionCard extension={pgGraphqlExtension} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <GraphiQL fetcher={fetcher} theme={ui.theme} apiKey={anonKey} />
|
||||
}
|
||||
|
||||
GraphiQLPage.getLayout = (page) => <DocsLayout title="GraphiQL">{page}</DocsLayout>
|
||||
|
||||
export default observer(GraphiQLPage)
|
||||
@@ -65,7 +65,7 @@ const PITR = () => {
|
||||
primaryText="Point in time recovery is a Pro plan add-on."
|
||||
secondaryText={
|
||||
tier === PRICING_TIER_PRODUCT_IDS.FREE
|
||||
? 'Please upgrade to the Pro plan with the PITR add-on selected to enable point in time recovery for your project.'
|
||||
? 'Upgrade to the Pro plan with the PITR add-on selected to enable point in time recovery for your project.'
|
||||
: 'Please enable the add-on to enable point in time recovery for your project.'
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { EditWrapper } from 'components/interfaces/Database'
|
||||
|
||||
const DatabaseWrappersNew: NextPageWithLayout = () => {
|
||||
const canReadWrappers = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'wrappers')
|
||||
|
||||
if (!canReadWrappers) {
|
||||
return <NoPermission isFullPage resourceText="view foreign data wrappers" />
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import NoPermission from 'components/ui/NoPermission'
|
||||
import { FormsContainer } from 'components/ui/Forms'
|
||||
|
||||
const DatabaseWrappers: NextPageWithLayout = () => {
|
||||
const canReadWrappers = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'wrappers')
|
||||
const canReadWrappers = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'tables')
|
||||
if (!canReadWrappers) {
|
||||
return <NoPermission isFullPage resourceText="view foreign data wrappers" />
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ import NoPermission from 'components/ui/NoPermission'
|
||||
import { CreateWrapper } from 'components/interfaces/Database'
|
||||
|
||||
const DatabaseWrappersNew: NextPageWithLayout = () => {
|
||||
const canReadWrappers = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'wrappers')
|
||||
if (!canReadWrappers) {
|
||||
return <NoPermission isFullPage resourceText="view foreign data wrappers" />
|
||||
const canCreateWrappers = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables')
|
||||
if (!canCreateWrappers) {
|
||||
return <NoPermission isFullPage resourceText="create foreign data wrappers" />
|
||||
}
|
||||
|
||||
return <CreateWrapper />
|
||||
|
||||
@@ -1,244 +1,21 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { IconGlobe, IconTerminal } from 'ui'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
import { NextPageWithLayout } from 'types'
|
||||
import { checkPermissions, useParams, useStore } from 'hooks'
|
||||
import { useProjectApiQuery } from 'data/config/project-api-query'
|
||||
import { checkPermissions, useParams } from 'hooks'
|
||||
import FunctionsLayout from 'components/layouts/FunctionsLayout'
|
||||
import CommandRender from 'components/interfaces/Functions/CommandRender'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import { EdgeFunctionDetails } from 'components/interfaces/Functions'
|
||||
|
||||
const PageLayout: NextPageWithLayout = () => {
|
||||
const { ref: projectRef, id } = useParams()
|
||||
|
||||
const { functions, ui } = useStore()
|
||||
|
||||
const [selectedFunction, setSelectedFunction] = useState<any>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedFunction(functions.byId(id))
|
||||
}, [functions.isLoaded, ui.selectedProject])
|
||||
|
||||
const { data: settings } = useProjectApiQuery({
|
||||
projectRef,
|
||||
})
|
||||
|
||||
// get the .co or .net TLD from the restUrl
|
||||
const restUrl = ui.selectedProject?.restUrl
|
||||
const restUrlTld = new URL(restUrl as string).hostname.split('.').pop()
|
||||
|
||||
const managementCommands: any = [
|
||||
{
|
||||
command: `supabase functions deploy ${selectedFunction?.slug}`,
|
||||
description: 'This will overwrite the deployed function with your new function',
|
||||
jsx: () => {
|
||||
return (
|
||||
<>
|
||||
<span className="text-brand-1100">supabase</span> functions deploy{' '}
|
||||
{selectedFunction?.slug}
|
||||
</>
|
||||
)
|
||||
},
|
||||
comment: 'Deploy a new version',
|
||||
},
|
||||
{
|
||||
command: `supabase functions delete ${selectedFunction?.slug}`,
|
||||
description: 'This will remove the function and all the logs associated with it',
|
||||
jsx: () => {
|
||||
return (
|
||||
<>
|
||||
<span className="text-brand-1100">supabase</span> functions delete{' '}
|
||||
{selectedFunction?.slug}
|
||||
</>
|
||||
)
|
||||
},
|
||||
comment: 'Delete the function',
|
||||
},
|
||||
]
|
||||
|
||||
const secretCommands: any = [
|
||||
{
|
||||
command: `supabase secrets list`,
|
||||
description: 'This will list all the secrets for your project',
|
||||
jsx: () => {
|
||||
return (
|
||||
<>
|
||||
<span className="text-brand-1100">supabase</span> secrets list
|
||||
</>
|
||||
)
|
||||
},
|
||||
comment: 'View all secrets',
|
||||
},
|
||||
{
|
||||
command: `supabase secrets set NAME1=VALUE1 NAME2=VALUE2`,
|
||||
description: 'This will set secrets for your project',
|
||||
jsx: () => {
|
||||
return (
|
||||
<>
|
||||
<span className="text-brand-1100">supabase</span> secrets set NAME1=VALUE1 NAME2=VALUE2
|
||||
</>
|
||||
)
|
||||
},
|
||||
comment: 'Set secrets for your project',
|
||||
},
|
||||
{
|
||||
command: `supabase secrets unset NAME1 NAME2 `,
|
||||
description: 'This will delete secrets for your project',
|
||||
jsx: () => {
|
||||
return (
|
||||
<>
|
||||
<span className="text-brand-1100">supabase</span> secrets unset NAME1 NAME2
|
||||
</>
|
||||
)
|
||||
},
|
||||
comment: 'Unset secrets for your project',
|
||||
},
|
||||
]
|
||||
|
||||
// Get the API service
|
||||
const apiService = settings?.autoApiService
|
||||
const anonKey = apiService?.service_api_keys.find((x) => x.name === 'anon key')
|
||||
? apiService.defaultApiKey
|
||||
: undefined
|
||||
|
||||
const endpoint = apiService?.app_config.endpoint ?? ''
|
||||
const endpointSections = endpoint.split('.')
|
||||
const functionsEndpoint = [
|
||||
...endpointSections.slice(0, 1),
|
||||
'functions',
|
||||
...endpointSections.slice(1),
|
||||
].join('.')
|
||||
|
||||
const invokeCommands: any = [
|
||||
{
|
||||
command: `curl -L -X POST 'https://${projectRef}.functions.supabase.${restUrlTld}/${
|
||||
selectedFunction?.slug
|
||||
}' -H 'Authorization: Bearer ${anonKey ?? '[YOUR ANON KEY]'}' --data '{"name":"Functions"}'`,
|
||||
description: 'Invokes the hello function',
|
||||
jsx: () => {
|
||||
return (
|
||||
<>
|
||||
<span className="text-brand-1100">curl</span> -L -X POST 'https://{functionsEndpoint}/
|
||||
{selectedFunction?.slug}' -H 'Authorization: Bearer [YOUR ANON KEY]'{' '}
|
||||
{`--data '{"name":"Functions"}'`}
|
||||
</>
|
||||
)
|
||||
},
|
||||
comment: 'Invoke your function',
|
||||
},
|
||||
]
|
||||
const { id } = useParams()
|
||||
|
||||
const canReadFunction = checkPermissions(PermissionAction.FUNCTIONS_READ, id as string)
|
||||
if (!canReadFunction) {
|
||||
return <NoPermission isFullPage resourceText="access this edge function's details" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-y-4 lg:grid-cols-2 lg:gap-x-8">
|
||||
<div>
|
||||
<div
|
||||
className="
|
||||
space-y-6 rounded border bg-scale-100 px-10 py-8 drop-shadow-sm
|
||||
dark:bg-scale-300
|
||||
"
|
||||
>
|
||||
<div className="w-full space-y-4">
|
||||
<div className="grid grid-cols-3">
|
||||
<span className="mb-1 block text-sm text-scale-1000">Function Name</span>
|
||||
<div className="text-sm text-scale-1200">{selectedFunction?.name}</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3">
|
||||
<span className="mb-1 block text-sm text-scale-1000">Status</span>
|
||||
<div className="col-span-2 flex flex-col gap-2">
|
||||
<div className="flex">
|
||||
<div
|
||||
className="
|
||||
lowercasefirst-letter flex flex-row items-center gap-3 rounded-full bg-brand-300
|
||||
px-3 py-0.5 text-base lowercase text-brand-900 dark:bg-brand-100
|
||||
"
|
||||
>
|
||||
{selectedFunction?.status}
|
||||
<div className="relative h-2 w-2">
|
||||
<span className="flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-brand-800 opacity-75"></span>
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-brand-900"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3">
|
||||
<span className="cols-span-1 mb-1 block text-sm text-scale-1000">Endpoint URL</span>
|
||||
<div className="col-span-2">
|
||||
<span className="w-full break-words text-sm text-scale-1200">{`https://${projectRef}.functions.supabase.co/${selectedFunction?.slug}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3">
|
||||
<span className="mb-1 block text-sm text-scale-1000">Created At</span>
|
||||
<div className="col-span-2 text-sm text-scale-1200">
|
||||
{selectedFunction?.created_at &&
|
||||
dayjs(selectedFunction.created_at).format('dddd, MMMM D, YYYY h:mm A')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3">
|
||||
<span className="mb-1 block text-sm text-scale-1000">Updated At</span>
|
||||
<div className="col-span-2 text-sm text-scale-1200">
|
||||
{selectedFunction?.updated_at &&
|
||||
dayjs(selectedFunction.updated_at).format('dddd, MMMM D, YYYY h:mm A')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3">
|
||||
<span className="mb-1 block text-sm text-scale-1000">Version</span>
|
||||
<div className="col-span-2 text-sm text-scale-1200">v{selectedFunction?.version}</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3">
|
||||
<span className="mb-1 block text-sm text-scale-1000">Regions</span>
|
||||
<div className="col-span-2 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2 text-sm text-scale-1200">
|
||||
<IconGlobe />
|
||||
<span>Earth</span>
|
||||
</div>
|
||||
<span className="text-sm text-scale-1000">All functions are deployed globally</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div
|
||||
className="space-y-6 rounded
|
||||
border bg-scale-100 px-10 py-8
|
||||
drop-shadow-sm
|
||||
dark:bg-scale-300"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded border bg-scale-1200 p-2 text-scale-100 dark:bg-scale-100 dark:text-scale-1200">
|
||||
<IconTerminal strokeWidth={2} />
|
||||
</div>
|
||||
<h4>Command line access</h4>
|
||||
</div>
|
||||
|
||||
<h5 className="text-base">Deployment management</h5>
|
||||
<CommandRender commands={managementCommands} />
|
||||
<h5 className="text-base">Invoke </h5>
|
||||
<CommandRender commands={invokeCommands} />
|
||||
<h5 className="text-base">Secrets management</h5>
|
||||
<CommandRender commands={secretCommands} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return <EdgeFunctionDetails />
|
||||
}
|
||||
|
||||
PageLayout.getLayout = (page) => <FunctionsLayout>{page}</FunctionsLayout>
|
||||
|
||||
@@ -1,59 +1,53 @@
|
||||
import { observer } from 'mobx-react-lite'
|
||||
|
||||
import { useParams } from 'hooks'
|
||||
import { NextPageWithLayout } from 'types'
|
||||
import { useStore } from 'hooks'
|
||||
import FunctionsLayout from 'components/layouts/FunctionsLayout'
|
||||
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import { Function } from 'components/interfaces/Functions/Functions.types'
|
||||
import {
|
||||
FunctionsListItem,
|
||||
FunctionsEmptyState,
|
||||
TerminalInstructions,
|
||||
} from 'components/interfaces/Functions'
|
||||
|
||||
const EdgeFunctionsList = ({ functions }: { functions: Function[] }) => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-3 py-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-scale-900">{`${functions.length} function${
|
||||
functions.length > 1 ? 's' : ''
|
||||
} deployed`}</span>
|
||||
</div>
|
||||
<div>
|
||||
<Table
|
||||
head={
|
||||
<>
|
||||
<Table.th>Name</Table.th>
|
||||
<Table.th>URL</Table.th>
|
||||
<Table.th className="hidden lg:table-cell">Created</Table.th>
|
||||
<Table.th className="hidden 2xl:table-cell">Last updated</Table.th>
|
||||
<Table.th className="hidden 2xl:table-cell">Version</Table.th>
|
||||
<Table.th className="text-right">Status</Table.th>
|
||||
</>
|
||||
}
|
||||
body={
|
||||
<>
|
||||
{functions.length > 0 &&
|
||||
functions.map((item: any) => <FunctionsListItem key={item.id} function={item} />)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
import FunctionsLayout from 'components/layouts/FunctionsLayout'
|
||||
import { EdgeFunctionsListItem, FunctionsEmptyState } from 'components/interfaces/Functions'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
|
||||
|
||||
const PageLayout: NextPageWithLayout = () => {
|
||||
const { functions } = useStore()
|
||||
const hasFunctions = functions.list().length > 0
|
||||
const { ref } = useParams()
|
||||
const { isLoading, data: functions } = useEdgeFunctionsQuery({ projectRef: ref })
|
||||
|
||||
const hasFunctions = (functions ?? []).length > 0
|
||||
|
||||
if (isLoading || functions === undefined) {
|
||||
return (
|
||||
<div className="space-y-2 w-full py-6">
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader className="w-3/4" />
|
||||
<ShimmeringLoader className="w-1/2" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return hasFunctions ? (
|
||||
<div className="py-6">
|
||||
<TerminalInstructions closable />
|
||||
<EdgeFunctionsList functions={functions.list()} />
|
||||
<div className="py-6 space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-scale-900">{`${functions.length} function${
|
||||
functions.length > 1 ? 's' : ''
|
||||
} deployed`}</span>
|
||||
</div>
|
||||
<Table
|
||||
head={
|
||||
<>
|
||||
<Table.th>Name</Table.th>
|
||||
<Table.th>URL</Table.th>
|
||||
<Table.th className="hidden 2xl:table-cell">Created</Table.th>
|
||||
<Table.th className="lg:table-cell">Last updated</Table.th>
|
||||
<Table.th className="lg:table-cell">Deployments</Table.th>
|
||||
</>
|
||||
}
|
||||
body={
|
||||
<>
|
||||
{functions.length > 0 &&
|
||||
functions.map((item: any) => <EdgeFunctionsListItem key={item.id} function={item} />)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<FunctionsEmptyState />
|
||||
|
||||
@@ -103,24 +103,25 @@ const DatabaseUsage: FC<any> = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mt-3">
|
||||
<div className="flex flex-row space-x-3 text-scale-1000 text-sm">
|
||||
{usage?.disk_volume_size_gb && (
|
||||
<span>Disk Size: {usage.disk_volume_size_gb} GB</span>
|
||||
)}
|
||||
{isPaidTier && (
|
||||
<div className="flex justify-between items-center mt-3">
|
||||
<div className="flex flex-row space-x-3 text-scale-1000 text-sm">
|
||||
{usage?.disk_volume_size_gb && (
|
||||
<span>Disk Size: {usage.disk_volume_size_gb} GB</span>
|
||||
)}
|
||||
<Badge>Auto-Scaling</Badge>
|
||||
</div>
|
||||
|
||||
{isPaidTier && <Badge>Auto-Scaling</Badge>}
|
||||
<Button type="default" icon={<IconExternalLink size={14} strokeWidth={1.5} />}>
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://supabase.com/docs/guides/platform/database-usage"
|
||||
>
|
||||
What is disk size?
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button type="default" icon={<IconExternalLink size={14} strokeWidth={1.5} />}>
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://supabase.com/docs/guides/platform/database-usage"
|
||||
>
|
||||
What is disk size?
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Panel.Content>
|
||||
<Panel.Content>
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -36,7 +36,6 @@ interface SettingsProps {
|
||||
|
||||
const Settings: FC<SettingsProps> = ({ project }) => {
|
||||
const { ui } = useStore()
|
||||
const projectTier = ui.selectedProject?.subscription_tier
|
||||
|
||||
const {
|
||||
data: subscription,
|
||||
|
||||
@@ -3,21 +3,21 @@ import { useRouter } from 'next/router'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { useFlag, useStore } from 'hooks'
|
||||
import { useFlag, useParams, useStore } from 'hooks'
|
||||
import { post } from 'lib/common/fetch'
|
||||
import { PROJECT_STATUS } from 'lib/constants'
|
||||
import { StorageLayout } from 'components/layouts'
|
||||
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
|
||||
import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore'
|
||||
import { NextPageWithLayout } from 'types'
|
||||
import { useProjectApiQuery } from 'data/config/project-api-query'
|
||||
import { find } from 'lodash'
|
||||
|
||||
/**
|
||||
* PageLayout is used to setup layout - as usual it will requires inject global store
|
||||
*/
|
||||
const PageLayout: NextPageWithLayout = ({}) => {
|
||||
const router = useRouter()
|
||||
const { ref } = router.query
|
||||
|
||||
const { ref } = useParams()
|
||||
const { ui } = useStore()
|
||||
const project = ui.selectedProject
|
||||
|
||||
@@ -26,6 +26,11 @@ const PageLayout: NextPageWithLayout = ({}) => {
|
||||
|
||||
const kpsEnabled = useFlag('initWithKps')
|
||||
|
||||
const { data: settings, isLoading } = useProjectApiQuery({ projectRef: ref })
|
||||
const apiService = settings?.autoApiService
|
||||
const serviceKey = find(apiService?.service_api_keys ?? [], (key) => key.tags === 'service_role')
|
||||
const canAccessStorage = !isLoading && apiService && serviceKey !== undefined
|
||||
|
||||
useEffect(() => {
|
||||
if (project && project.status === PROJECT_STATUS.INACTIVE) {
|
||||
post(`${API_URL}/projects/${ref}/restore`, { kps_enabled: kpsEnabled })
|
||||
@@ -42,6 +47,8 @@ const PageLayout: NextPageWithLayout = ({}) => {
|
||||
infoButtonLabel="About storage"
|
||||
infoButtonUrl="https://supabase.com/docs/guides/storage"
|
||||
onClickCta={openCreateBucketModal}
|
||||
disabled={!canAccessStorage}
|
||||
disabledMessage="You need additional permissions to create buckets"
|
||||
>
|
||||
<p className="text-scale-1100 text-sm">
|
||||
Create buckets to store and serve any type of digital content.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 400 400">
|
||||
<path fill="currentColor" d="m57.468 302.66-14.376-8.3 160.15-277.38 14.376 8.3z"/>
|
||||
<path fill="currentColor" d="M39.8 272.2h320.3v16.6H39.8z"/>
|
||||
<path fill="currentColor" d="m206.348 374.026-160.21-92.5 8.3-14.376 160.21 92.5zM345.522 132.947l-160.21-92.5 8.3-14.376 160.21 92.5z"/>
|
||||
<path fill="currentColor" d="m54.482 132.883-8.3-14.375 160.21-92.5 8.3 14.375z"/>
|
||||
<path fill="currentColor" d="m342.568 302.663-160.15-277.38 14.376-8.3 160.15 277.38zM52.5 107.5h16.6v185H52.5z"/>
|
||||
<path fill="currentColor" d="M330.9 107.5h16.6v185h-16.6z"/>
|
||||
<path fill="currentColor" d="m203.522 367-7.25-12.558 139.34-80.45 7.25 12.557z"/>
|
||||
<path fill="currentColor" d="M369.5 297.9c-9.6 16.7-31 22.4-47.7 12.8-16.7-9.6-22.4-31-12.8-47.7 9.6-16.7 31-22.4 47.7-12.8 16.8 9.7 22.5 31 12.8 47.7M90.9 137c-9.6 16.7-31 22.4-47.7 12.8-16.7-9.6-22.4-31-12.8-47.7 9.6-16.7 31-22.4 47.7-12.8 16.7 9.7 22.4 31 12.8 47.7M30.5 297.9c-9.6-16.7-3.9-38 12.8-47.7 16.7-9.6 38-3.9 47.7 12.8 9.6 16.7 3.9 38-12.8 47.7-16.8 9.6-38.1 3.9-47.7-12.8M309.1 137c-9.6-16.7-3.9-38 12.8-47.7 16.7-9.6 38-3.9 47.7 12.8 9.6 16.7 3.9 38-12.8 47.7-16.7 9.6-38.1 3.9-47.7-12.8M200 395.8c-19.3 0-34.9-15.6-34.9-34.9 0-19.3 15.6-34.9 34.9-34.9 19.3 0 34.9 15.6 34.9 34.9 0 19.2-15.6 34.9-34.9 34.9M200 74c-19.3 0-34.9-15.6-34.9-34.9 0-19.3 15.6-34.9 34.9-34.9 19.3 0 34.9 15.6 34.9 34.9 0 19.3-15.6 34.9-34.9 34.9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -39,6 +39,9 @@ Sentry.init({
|
||||
// Safe to ignore since it's not a user-facing issue + we've not received any user feedback/report about it
|
||||
// Ref: https://github.com/supabase/supabase/pull/9729
|
||||
'The provided `href` (/org/[slug]/general) value is missing query values (slug)',
|
||||
'The provided `href` (/org/[slug]/team) value is missing query values (slug)',
|
||||
'The provided `href` (/org/[slug]/billing) value is missing query values (slug)',
|
||||
'The provided `href` (/org/[slug]/invoices) value is missing query values (slug)',
|
||||
],
|
||||
beforeSend: (event) => filterConsoleErrors(event),
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user