mirror of
https://github.com/supabase/supabase.git
synced 2026-10-07 02:15:05 +03:00
173 files changed
+7008
-28076
No files matched your search
@@ -1,32 +0,0 @@
|
||||
name: Studio Build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- studio
|
||||
paths:
|
||||
- 'studio/**'
|
||||
- 'package-lock.json'
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [16.x]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
working-directory: ./
|
||||
- name: Run build
|
||||
run: npx turbo run build --filter=studio
|
||||
working-directory: ./
|
||||
@@ -36,8 +36,9 @@ Go to the [GitHub Developer Settings](https://github.com/settings/developers) pa
|
||||
|
||||
<SocialProviderSetup provider="GitHub" />
|
||||
|
||||
## Register a new OAuth application
|
||||
## Register a new OAuth application on Github.
|
||||
|
||||
- Navigate to `Settings`/`Developer settings`/`OAuth Apps`
|
||||
- Click `Register a new application`. If you've created an app before, click `New OAuth App` here.
|
||||
- In `Application name`, type the name of your app.
|
||||
- In `Homepage URL`, type the full URL to your app's website.
|
||||
|
||||
@@ -217,7 +217,7 @@ employees (
|
||||
id bigint primary key generated always as identity,
|
||||
name text,
|
||||
email text,
|
||||
created_at timestampz default now()
|
||||
created_at timestamptz default now()
|
||||
);
|
||||
|
||||
````
|
||||
|
||||
@@ -40,15 +40,19 @@ drop
|
||||
Even though the SQL code is `create extension`, this is the equivalent of "enabling the extension".
|
||||
To disable an extension call `drop extension`.
|
||||
|
||||
<Admonition type="caution">
|
||||
|
||||
Enabling some extensions with `create extension <extension-name> with schema extensions` may lead to permission issues (e.g., `dblink`, `http`, `pg_cron`).
|
||||
|
||||
</Admonition>
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
Most extensions are installed under the `extensions` schema, which is accessible to public by default. To avoid namespace pollution, we do not recommend creating other entities in the `extensions` schema.
|
||||
|
||||
If you need to restrict user access to tables managed by extensions, we recommend creating a separate schema for installing that specific extension.
|
||||
|
||||
Some extensions can only be created under a specific schema, for eg. `postgis_tiger_geocoder` extension creates a schema named `tiger`. Before enabling such extensions, make sure you have not created a conflicting schema with the same name.
|
||||
|
||||
</Admonition>
|
||||
|
||||
### Full list of extensions
|
||||
|
||||
Supabase is pre-configured with over 50 extensions. You can also install your own SQL extensions directly in the database through our SQL editor.
|
||||
|
||||
@@ -162,7 +162,7 @@ Update your Prisma schema by setting the `directUrl` in the datasource block:
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
directURL = env("DIRECT_URL")
|
||||
directUrl = env("DIRECT_URL")
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/* NOTE: this file is a duplicate of /studio/public/css/fonts.css and should be updated there and then copied here - once we migrate fully to supabase.com/dashboard, this can be deleted */
|
||||
|
||||
/* header and body font */
|
||||
|
||||
@font-face {
|
||||
font-family: 'circular';
|
||||
src: url(/fonts/custom/CustomFont-Book.woff2) format('woff2'),
|
||||
url(/fonts/custom/CustomFont-Book.woff) format('woff');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'circular';
|
||||
src: url(/fonts/custom/CustomFont-BookItalic.woff2) format('woff2'),
|
||||
url(/fonts/custom/CustomFont-BookItalic.woff) format('woff');
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'circular';
|
||||
src: url(/fonts/custom/CustomFont-Medium.woff2) format('woff2'),
|
||||
url(/fonts/custom/CustomFont-Medium.woff) format('woff');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'circular';
|
||||
src: url(/fonts/custom/CustomFont-MediumItalic.woff2) format('woff2'),
|
||||
url(/fonts/custom/CustomFont-MediumItalic.woff) format('woff');
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'circular';
|
||||
src: url(/fonts/custom/CustomFont-Bold.woff2) format('woff2'),
|
||||
url(/fonts/custom/CustomFont-Bold.woff) format('woff');
|
||||
font-weight: 700;
|
||||
font-style: 600;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'circular';
|
||||
src: url(/fonts/custom/CustomFont-BoldItalic.woff2) format('woff2'),
|
||||
url(/fonts/custom/CustomFont-BoldItalic.woff) format('woff');
|
||||
font-style: 600;
|
||||
font-style: italic;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'circular';
|
||||
src: url(/fonts/custom/CustomFont-Black.woff2) format('woff2'),
|
||||
url(/fonts/custom/CustomFont-Black.woff) format('woff');
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'circular';
|
||||
src: url(/fonts/custom/CustomFont-BlackItalic.woff2) format('woff2'),
|
||||
url(/fonts/custom/CustomFont-BlackItalic.woff) format('woff');
|
||||
font-weight: 800;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* mono font */
|
||||
|
||||
@font-face {
|
||||
font-family: 'source code pro';
|
||||
src: url('/fonts/source-code-pro/SourceCodePro-Regular.eot');
|
||||
src: url('/fonts/source-code-pro/SourceCodePro-Regular.woff2') format('woff2'),
|
||||
url('/fonts/source-code-pro/SourceCodePro-Regular.woff') format('woff'),
|
||||
url('/fonts/source-code-pro/SourceCodePro-Regular.ttf') format('truetype'),
|
||||
url('/fonts/source-code-pro/SourceCodePro-Regular.svg#SourceCodePro-Regular') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
Binary file not shown.
File diff suppressed because it is too large.
Load diff
|
After Width: | Height: | Size: 461 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
+30
-30
@@ -3326,9 +3326,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/adjust-sourcemap-loader/node_modules/loader-utils": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz",
|
||||
"integrity": "sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==",
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz",
|
||||
"integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"big.js": "^5.2.2",
|
||||
@@ -3589,9 +3589,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/babel-loader/node_modules/loader-utils": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz",
|
||||
"integrity": "sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==",
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz",
|
||||
"integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"big.js": "^5.2.2",
|
||||
@@ -6303,9 +6303,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/http-cache-semantics": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz",
|
||||
"integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
|
||||
"integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/http-deceiver": {
|
||||
@@ -10011,9 +10011,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-url-loader/node_modules/loader-utils": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz",
|
||||
"integrity": "sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==",
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz",
|
||||
"integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"big.js": "^5.2.2",
|
||||
@@ -11332,9 +11332,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ua-parser-js": {
|
||||
"version": "0.7.31",
|
||||
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.31.tgz",
|
||||
"integrity": "sha512-qLK/Xe9E2uzmYI3qLeOmI0tEOt+TBBQyUIAh4aAgU05FVYzeZrKUdkAZfBNVGRaHVgV0TDkdEngJSw/SyQchkQ==",
|
||||
"version": "0.7.34",
|
||||
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.34.tgz",
|
||||
"integrity": "sha512-cJMeh/eOILyGu0ejgTKB95yKT3zOenSe9UGE3vj6WfiOwgGYnmATUsnDixMFvdU+rNMvWih83hrUP8VwhF9yXQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -14460,9 +14460,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"loader-utils": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz",
|
||||
"integrity": "sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==",
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz",
|
||||
"integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"big.js": "^5.2.2",
|
||||
@@ -14644,9 +14644,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"loader-utils": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz",
|
||||
"integrity": "sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==",
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz",
|
||||
"integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"big.js": "^5.2.2",
|
||||
@@ -16627,9 +16627,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"http-cache-semantics": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz",
|
||||
"integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
|
||||
"integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==",
|
||||
"dev": true
|
||||
},
|
||||
"http-deceiver": {
|
||||
@@ -19281,9 +19281,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"loader-utils": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz",
|
||||
"integrity": "sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==",
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz",
|
||||
"integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"big.js": "^5.2.2",
|
||||
@@ -20273,9 +20273,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"ua-parser-js": {
|
||||
"version": "0.7.31",
|
||||
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.31.tgz",
|
||||
"integrity": "sha512-qLK/Xe9E2uzmYI3qLeOmI0tEOt+TBBQyUIAh4aAgU05FVYzeZrKUdkAZfBNVGRaHVgV0TDkdEngJSw/SyQchkQ==",
|
||||
"version": "0.7.34",
|
||||
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.34.tgz",
|
||||
"integrity": "sha512-cJMeh/eOILyGu0ejgTKB95yKT3zOenSe9UGE3vj6WfiOwgGYnmATUsnDixMFvdU+rNMvWih83hrUP8VwhF9yXQ==",
|
||||
"dev": true
|
||||
},
|
||||
"unicode-canonical-property-names-ecmascript": {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
@@ -1,90 +0,0 @@
|
||||
# Created by .ignore support plugin (hsz.mobi)
|
||||
### Node template
|
||||
# Logs
|
||||
/logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
|
||||
# next.js build output
|
||||
.next
|
||||
|
||||
# nuxt.js build output
|
||||
.nuxt
|
||||
|
||||
# Nuxt generate
|
||||
dist
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless
|
||||
|
||||
# IDE / Editor
|
||||
.idea
|
||||
|
||||
# Service worker
|
||||
sw.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Vim swap files
|
||||
*.swp
|
||||
@@ -1,92 +0,0 @@
|
||||
# Supabase Nuxt.js User Management
|
||||
|
||||
This is a WIP!
|
||||
|
||||
### 1. Create new project
|
||||
|
||||
Sign up to Supabase - [https://app.supabase.com](https://app.supabase.com) and create a new project. Wait for your database to start.
|
||||
|
||||
### 2. Run "User Management" Quickstart
|
||||
|
||||
Once your database has started, head over to your project's `SQL Editor` and run the "User Management Starter" quickstart. On the `SQL editor` page, scroll down until you see `User Management Starter: Sets up a public Profiles table which you can access with your API`. Click that, then click `RUN` to execute that query and create a new `profiles` table. When that's finished, head over to the `Table Editor` and see your new `profiles` table.
|
||||
|
||||
### 3. Get the URL and Key
|
||||
|
||||
Go to the Project Settings (the cog icon), open the API tab, and find your API URL and `anon` key, you'll need these in the next step.
|
||||
|
||||
The `anon` key is your client-side API key. It allows "anonymous access" to your database, until the user has logged in. Once they have logged in, the keys will switch to the user's own login token. This enables row level security for your data. Read more about this [below](#postgres-row-level-security).
|
||||
|
||||

|
||||
|
||||
**_NOTE_**: The `service_role` key has full access to your data, bypassing any security policies. These keys have to be kept secret and are meant to be used in server environments and never on a client or browser.
|
||||
|
||||
### 4. Env vars
|
||||
|
||||
Create a file in this folder `.env`
|
||||
|
||||
```
|
||||
NUXT_PUBLIC_SUPABASE_URL=
|
||||
NUXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
```
|
||||
|
||||
Populate this file with your URL and Key.
|
||||
|
||||
### 5. Run the application
|
||||
|
||||
Run the application: `npm run dev`. Open your browser to `https://localhost:3000/` and you are ready to go 🚀.
|
||||
|
||||
## Supabase details
|
||||
|
||||
### Postgres Row level security
|
||||
|
||||
This project uses very high-level Authorization using Postgres' Role Level Security.
|
||||
When you start a Postgres database on Supabase, we populate it with an `auth` schema, and some helper functions.
|
||||
When a user logs in, they are issued a JWT with the role `authenticated` and their UUID.
|
||||
We can use these details to provide fine-grained control over what each user can and cannot do.
|
||||
|
||||
This is a trimmed-down schema, with the policies:
|
||||
|
||||
```sql
|
||||
-- Create a table for Public Profiles
|
||||
create table profiles (
|
||||
id uuid references auth.users not null,
|
||||
updated_at timestamp with time zone,
|
||||
username text unique,
|
||||
avatar_url text,
|
||||
website text,
|
||||
primary key (id),
|
||||
unique(username),
|
||||
constraint username_length check (char_length(username) >= 3)
|
||||
);
|
||||
alter table profiles enable row level security;
|
||||
create policy "Public profiles are viewable by everyone."
|
||||
on profiles for select
|
||||
using ( true );
|
||||
create policy "Users can insert their own profile."
|
||||
on profiles for insert
|
||||
with check ( auth.uid() = id );
|
||||
create policy "Users can update own profile."
|
||||
on profiles for update
|
||||
using ( auth.uid() = id );
|
||||
-- Set up Realtime!
|
||||
begin;
|
||||
drop publication if exists supabase_realtime;
|
||||
create publication supabase_realtime;
|
||||
commit;
|
||||
alter publication supabase_realtime add table profiles;
|
||||
-- Set up Storage!
|
||||
insert into storage.buckets (id, name)
|
||||
values ('avatars', 'avatars');
|
||||
create policy "Avatar images are publicly accessible."
|
||||
on storage.objects for select
|
||||
using ( bucket_id = 'avatars' );
|
||||
create policy "Anyone can upload an avatar."
|
||||
on storage.objects for insert
|
||||
with check ( bucket_id = 'avatars' );
|
||||
```
|
||||
|
||||
## Authors
|
||||
|
||||
- [Supabase](https://supabase.com)
|
||||
|
||||
Supabase is open source. We'd love for you to follow along and get involved at https://github.com/supabase/supabase
|
||||
@@ -1,7 +0,0 @@
|
||||
# ASSETS
|
||||
|
||||
**This directory is not required, you can delete it if you don't want to use it.**
|
||||
|
||||
This directory contains your un-compiled assets such as LESS, SASS, or JavaScript.
|
||||
|
||||
More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/assets#webpacked).
|
||||
@@ -1,432 +0,0 @@
|
||||
html,
|
||||
body {
|
||||
--custom-font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto,
|
||||
Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||
--custom-bg-color: #101010;
|
||||
--custom-panel-color: #222;
|
||||
--custom-box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.8);
|
||||
--custom-color: #fff;
|
||||
--custom-color-brand: #24b47e;
|
||||
--custom-color-secondary: #666;
|
||||
--custom-border: 1px solid #333;
|
||||
--custom-border-radius: 5px;
|
||||
--custom-spacing: 5px;
|
||||
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: var(--custom-font-family);
|
||||
background-color: var(--custom-bg-color);
|
||||
}
|
||||
|
||||
* {
|
||||
color: var(--custom-color);
|
||||
font-family: var(--custom-font-family);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#__next {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
|
||||
.container {
|
||||
width: 90%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
.row {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.row [class^="col"] {
|
||||
float: left;
|
||||
margin: 0.5rem 2%;
|
||||
min-height: 0.125rem;
|
||||
}
|
||||
.col-1,
|
||||
.col-2,
|
||||
.col-3,
|
||||
.col-4,
|
||||
.col-5,
|
||||
.col-6,
|
||||
.col-7,
|
||||
.col-8,
|
||||
.col-9,
|
||||
.col-10,
|
||||
.col-11,
|
||||
.col-12 {
|
||||
width: 96%;
|
||||
}
|
||||
.col-1-sm {
|
||||
width: 4.33%;
|
||||
}
|
||||
.col-2-sm {
|
||||
width: 12.66%;
|
||||
}
|
||||
.col-3-sm {
|
||||
width: 21%;
|
||||
}
|
||||
.col-4-sm {
|
||||
width: 29.33%;
|
||||
}
|
||||
.col-5-sm {
|
||||
width: 37.66%;
|
||||
}
|
||||
.col-6-sm {
|
||||
width: 46%;
|
||||
}
|
||||
.col-7-sm {
|
||||
width: 54.33%;
|
||||
}
|
||||
.col-8-sm {
|
||||
width: 62.66%;
|
||||
}
|
||||
.col-9-sm {
|
||||
width: 71%;
|
||||
}
|
||||
.col-10-sm {
|
||||
width: 79.33%;
|
||||
}
|
||||
.col-11-sm {
|
||||
width: 87.66%;
|
||||
}
|
||||
.col-12-sm {
|
||||
width: 96%;
|
||||
}
|
||||
.row::after {
|
||||
content: "";
|
||||
display: table;
|
||||
clear: both;
|
||||
}
|
||||
.hidden-sm {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 33.75em) {
|
||||
/* 540px */
|
||||
.container {
|
||||
width: 80%;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 45em) {
|
||||
/* 720px */
|
||||
.col-1 {
|
||||
width: 4.33%;
|
||||
}
|
||||
.col-2 {
|
||||
width: 12.66%;
|
||||
}
|
||||
.col-3 {
|
||||
width: 21%;
|
||||
}
|
||||
.col-4 {
|
||||
width: 29.33%;
|
||||
}
|
||||
.col-5 {
|
||||
width: 37.66%;
|
||||
}
|
||||
.col-6 {
|
||||
width: 46%;
|
||||
}
|
||||
.col-7 {
|
||||
width: 54.33%;
|
||||
}
|
||||
.col-8 {
|
||||
width: 62.66%;
|
||||
}
|
||||
.col-9 {
|
||||
width: 71%;
|
||||
}
|
||||
.col-10 {
|
||||
width: 79.33%;
|
||||
}
|
||||
.col-11 {
|
||||
width: 87.66%;
|
||||
}
|
||||
.col-12 {
|
||||
width: 96%;
|
||||
}
|
||||
.hidden-sm {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 60em) {
|
||||
/* 960px */
|
||||
.container {
|
||||
width: 75%;
|
||||
max-width: 60rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 5px 0;
|
||||
color: var(--custom-color-secondary);
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
border-radius: 5px;
|
||||
border: var(--custom-border);
|
||||
padding: 8px;
|
||||
font-size: 0.9rem;
|
||||
background-color: var(--custom-bg-color);
|
||||
color: var(--custom-color);
|
||||
}
|
||||
|
||||
input[disabled] {
|
||||
color: var(--custom-color-secondary);
|
||||
}
|
||||
|
||||
/* Utils */
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.inline-block {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
.flex.column {
|
||||
flex-direction: column;
|
||||
}
|
||||
.flex.row {
|
||||
flex-direction: row;
|
||||
}
|
||||
.flex.flex-1 {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
.flex-end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.flex-center {
|
||||
justify-content: center;
|
||||
}
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
.text-sm {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 300;
|
||||
}
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
.font-light {
|
||||
font-weight: 300;
|
||||
}
|
||||
.opacity-half {
|
||||
opacity: 50%;
|
||||
}
|
||||
|
||||
/* Button */
|
||||
|
||||
button,
|
||||
.button {
|
||||
color: var(--custom-color);
|
||||
border: var(--custom-border);
|
||||
background-color: var(--custom-bg-color);
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
border-radius: var(--custom-border-radius);
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
button.primary,
|
||||
.button.primary {
|
||||
background-color: var(--custom-color-brand);
|
||||
border: 1px solid var(--custom-color-brand);
|
||||
}
|
||||
|
||||
/* Widgets */
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
display: block;
|
||||
border: var(--custom-border);
|
||||
border-radius: var(--custom-border-radius);
|
||||
padding: var(--custom-spacing);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
border-radius: var(--custom-border-radius);
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
.avatar.image {
|
||||
object-fit: cover;
|
||||
}
|
||||
.avatar.no-image {
|
||||
background-color: #333;
|
||||
border: 1px solid rgb(200, 200, 200);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: absolute;
|
||||
max-width: 100%;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
border-top: var(--custom-border);
|
||||
background-color: var(--custom-bg-color);
|
||||
}
|
||||
.footer div {
|
||||
padding: var(--custom-spacing);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
.footer div > img {
|
||||
height: 20px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.footer > div:first-child {
|
||||
display: none;
|
||||
}
|
||||
.footer > div:nth-child(2) {
|
||||
justify-content: left;
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 60em) {
|
||||
/* 960px */
|
||||
.footer > div:first-child {
|
||||
display: flex;
|
||||
}
|
||||
.footer > div:nth-child(2) {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.mainHeader {
|
||||
width: 100%;
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.avatarPlaceholder {
|
||||
border: var(--custom-border);
|
||||
border-radius: var(--custom-border-radius);
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Auth */
|
||||
|
||||
.auth-widget {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.auth-widget > .button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background-color: #444444;
|
||||
text-transform: none !important;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.auth-widget .button:hover {
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
|
||||
.auth-widget .button > .loader {
|
||||
width: 17px;
|
||||
animation: spin 1s linear infinite;
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
/* Account */
|
||||
|
||||
.account {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.account > * > .avatarField {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.account > * > .avatarField > .avatarContainer {
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
/* Profile Card */
|
||||
|
||||
.profileCard {
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
border: var(--custom-border);
|
||||
background-color: var(--custom-panel-color);
|
||||
padding: 20px 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.profileCard:last-child {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.profileCard > .userInfo {
|
||||
margin-left: 20px;
|
||||
font-weight: 300;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.profileCard > .userInfo > p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profileCard > .userInfo > .username {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.profileCard > .userInfo > .website {
|
||||
font-size: 0.9rem;
|
||||
color: var(--custom-color-brand);
|
||||
margin-bottom: 10px;
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
<template>
|
||||
<div class="account">
|
||||
<div>
|
||||
<label for="email">Email</label>
|
||||
<input id="email" type="text" v-model="state.propSession.user.email" disabled />
|
||||
</div>
|
||||
<div>
|
||||
<label for="username">Username</label>
|
||||
<input id="username" type="text" v-model="state.username" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="website">Website</label>
|
||||
<input id="website" type="website" v-model="state.website" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button class="button block primary" @click="updateProfile" :disabled="state.loading">
|
||||
{{ state.loading ? 'Loading ...' : 'Update' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button @click="signOut" class="button block">Sign Out</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { supabase } from '../lib/supabaseClient'
|
||||
export default {
|
||||
props: ['session'],
|
||||
data() {
|
||||
return {
|
||||
state: {
|
||||
loading: true,
|
||||
username: '',
|
||||
website: '',
|
||||
propSession: this.session,
|
||||
},
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.getProfile()
|
||||
},
|
||||
methods: {
|
||||
async signOut() {
|
||||
const { error } = await supabase.auth.signOut()
|
||||
if (error) console.log('Error logging out:', error.message)
|
||||
},
|
||||
|
||||
setProfile(profile) {
|
||||
this.state.username = profile.username
|
||||
this.state.website = profile.website
|
||||
},
|
||||
|
||||
async getProfile() {
|
||||
try {
|
||||
this.state.loading = true
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select(`username, website, avatar_url`)
|
||||
.eq('id', this.state.propSession.user.id)
|
||||
.single()
|
||||
|
||||
if (error || !data) {
|
||||
throw error || new Error('Profile not found')
|
||||
}
|
||||
|
||||
this.setProfile(data)
|
||||
} catch (error) {
|
||||
console.log('error', error.message)
|
||||
} finally {
|
||||
this.state.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async updateProfile() {
|
||||
try {
|
||||
this.state.loading = true
|
||||
|
||||
const updates = {
|
||||
id: this.state.propSession.user.id,
|
||||
username: this.state.username,
|
||||
website: this.state.website,
|
||||
updated_at: new Date(),
|
||||
}
|
||||
|
||||
const { error } = await supabase.from('profiles').upsert(updates)
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
alert(error.message)
|
||||
} finally {
|
||||
this.state.loading = false
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -1,65 +0,0 @@
|
||||
<template>
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<h1 class="header">Supabase Auth + Storage</h1>
|
||||
<p className="">
|
||||
Experience our Auth and Storage through a simple profile management example. Create a user
|
||||
profile and upload an avatar image. Fast, simple, secure.
|
||||
</p>
|
||||
</div>
|
||||
<form class="col-6 auth-widget" @submit.prevent="handleSubmit">
|
||||
<p class="description">Sign in via magic link with your email below</p>
|
||||
<div>
|
||||
<label for="email-register">Email</label>
|
||||
<input
|
||||
class="inputField"
|
||||
v-model="formValues.email"
|
||||
id="email-register"
|
||||
type="email"
|
||||
placeholder="Your email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button class="button block" :disabled="formValues.loader">
|
||||
{{ formValues.loader ? "Loading ..." : "Sign up with magic link" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { supabase } from "../lib/supabaseClient";
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
formValues: {
|
||||
email: "",
|
||||
loader: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async handleSubmit() {
|
||||
try {
|
||||
this.formValues.loader = true;
|
||||
const { error, data: { user } } = await supabase.auth.signInWithOtp({
|
||||
email: this.formValues.email,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
alert("Check your email for the login link!");
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
} finally {
|
||||
this.formValues.loader = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,35 +0,0 @@
|
||||
<template>
|
||||
<div class="profileCard">
|
||||
<div class="userInfo">
|
||||
<p class="username">{{ state.propProfile.username }}</p>
|
||||
<p class="website">{{ state.propProfile.website }}</p>
|
||||
<p>
|
||||
<small>
|
||||
Last updated
|
||||
{{
|
||||
state.lastUpdated
|
||||
? `${state.lastUpdated.toLocaleDateString()}
|
||||
${state.lastUpdated.toLocaleTimeString()}`
|
||||
: "Never"
|
||||
}}
|
||||
</small>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ["profile"],
|
||||
data() {
|
||||
return {
|
||||
state: {
|
||||
propProfile: this.profile,
|
||||
lastUpdated: this.profile.updated_at
|
||||
? new Date(this.profile.updated_at)
|
||||
: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,22 +0,0 @@
|
||||
<template>
|
||||
<div class="profileList">
|
||||
<template v-for="profile in state.propProfiles">
|
||||
<profile-card :key="profile.id" :profile="profile" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ProfileCard from "./ProfileCard.vue";
|
||||
export default {
|
||||
components: { ProfileCard },
|
||||
props: ["profiles"],
|
||||
data() {
|
||||
return {
|
||||
state: {
|
||||
propProfiles: this.profiles,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,3 +0,0 @@
|
||||
<template>
|
||||
<Nuxt />
|
||||
</template>
|
||||
@@ -1,10 +0,0 @@
|
||||
export const NUXT_PUBLIC_SUPABASE_URL = process.env.NUXT_PUBLIC_SUPABASE_URL
|
||||
export const NUXT_PUBLIC_SUPABASE_ANON_KEY = process.env.NUXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
|
||||
// export type Profile = {
|
||||
// id: string
|
||||
// avatar_url: string
|
||||
// username: string
|
||||
// website: string
|
||||
// updated_at: string
|
||||
// }
|
||||
@@ -1,15 +0,0 @@
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import {
|
||||
NUXT_PUBLIC_SUPABASE_URL,
|
||||
NUXT_PUBLIC_SUPABASE_ANON_KEY,
|
||||
} from "./constants";
|
||||
|
||||
if (!NUXT_PUBLIC_SUPABASE_URL)
|
||||
throw new Error("Missing env.NUXT_PUBLIC_SUPABASE_URL");
|
||||
if (!NUXT_PUBLIC_SUPABASE_ANON_KEY)
|
||||
throw new Error("Missing env.NUXT_PUBLIC_SUPABASE_KEY");
|
||||
|
||||
export const supabase = createClient(
|
||||
NUXT_PUBLIC_SUPABASE_URL,
|
||||
NUXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
);
|
||||
@@ -1,8 +0,0 @@
|
||||
# MIDDLEWARE
|
||||
|
||||
**This directory is not required, you can delete it if you don't want to use it.**
|
||||
|
||||
This directory contains your application middleware.
|
||||
Middleware lets you define custom functions that can be run before rendering either a page or a group of pages.
|
||||
|
||||
More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/routing#middleware).
|
||||
@@ -1,41 +0,0 @@
|
||||
export default {
|
||||
// publicRuntimeConfig: {
|
||||
// URL: process.env.SUPABASE_URL,
|
||||
// KEY: process.env.SUPABASE_KEY
|
||||
// },
|
||||
|
||||
// Disable server-side rendering: https://go.nuxtjs.dev/ssr-mode
|
||||
ssr: false,
|
||||
|
||||
// Global page headers: https://go.nuxtjs.dev/config-head
|
||||
head: {
|
||||
title: 'nuxtjs-user-management',
|
||||
htmlAttrs: {
|
||||
lang: 'en',
|
||||
},
|
||||
meta: [
|
||||
{ charset: 'utf-8' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||
{ hid: 'description', name: 'description', content: '' },
|
||||
],
|
||||
link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }],
|
||||
},
|
||||
|
||||
// Global CSS: https://go.nuxtjs.dev/config-css
|
||||
css: ['@/assets/global.css'],
|
||||
|
||||
// Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins
|
||||
plugins: [],
|
||||
|
||||
// Auto import components: https://go.nuxtjs.dev/config-components
|
||||
components: true,
|
||||
|
||||
// Modules for dev and build (recommended): https://go.nuxtjs.dev/config-modules
|
||||
buildModules: ['@nuxtjs/dotenv'],
|
||||
|
||||
// Modules: https://go.nuxtjs.dev/config-modules
|
||||
modules: [],
|
||||
|
||||
// Build Configuration: https://go.nuxtjs.dev/config-build
|
||||
build: {},
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"name": "nuxtjs-user-management",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nuxt",
|
||||
"build": "nuxt build",
|
||||
"start": "nuxt start",
|
||||
"generate": "nuxt generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxtjs/dotenv": "^1.4.1",
|
||||
"@supabase/supabase-js": "^2.0.4",
|
||||
"core-js": "^3.9.1",
|
||||
"nuxt": "^2.15.8"
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
<template>
|
||||
<div class="container" style="padding: 50px 0 100px">
|
||||
<Auth v-if="!state.session" />
|
||||
<div class="row" v-else>
|
||||
<div class="col-6">
|
||||
<h3>Account</h3>
|
||||
<Account :session="state.session" />
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<h3>Public Profiles</h3>
|
||||
<template v-if="state.profiles.length > 0">
|
||||
<ProfileList :profiles="state.profiles" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { supabase } from "../lib/supabaseClient";
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
state: {
|
||||
session: null,
|
||||
profiles: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
this.state.session = session;
|
||||
});
|
||||
|
||||
supabase.auth.onAuthStateChange((_event, session) => {
|
||||
this.state.session = session;
|
||||
});
|
||||
|
||||
this.getPublicProfiles();
|
||||
},
|
||||
methods: {
|
||||
async getPublicProfiles() {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from("profiles")
|
||||
.select("id, username, avatar_url, website, updated_at")
|
||||
.order("updated_at", { ascending: false });
|
||||
|
||||
if (error || !data) {
|
||||
throw error || new Error("No data");
|
||||
}
|
||||
console.log("data", data);
|
||||
this.state.profiles = data;
|
||||
} catch (error) {
|
||||
console.log("error", error.message);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,7 +0,0 @@
|
||||
# PLUGINS
|
||||
|
||||
**This directory is not required, you can delete it if you don't want to use it.**
|
||||
|
||||
This directory contains JavaScript plugins that you want to run before mounting the root Vue.js application.
|
||||
|
||||
More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/plugins).
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,5 +1,4 @@
|
||||
import React from 'react'
|
||||
import { Space } from '../Space'
|
||||
import Typography from '../Typography'
|
||||
import { MenuContextProvider, useMenuContext } from './MenuContext'
|
||||
|
||||
@@ -8,11 +7,12 @@ import styleHandler from '../../lib/theme/styleHandler'
|
||||
interface MenuProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
ulClassName?: string
|
||||
style?: React.CSSProperties
|
||||
type?: 'text' | 'pills' | 'border'
|
||||
}
|
||||
|
||||
function Menu({ children, className, style, type = 'text' }: MenuProps) {
|
||||
function Menu({ children, className, ulClassName, style, type = 'text' }: MenuProps) {
|
||||
return (
|
||||
<nav
|
||||
role="menu"
|
||||
@@ -23,7 +23,7 @@ function Menu({ children, className, style, type = 'text' }: MenuProps) {
|
||||
style={style}
|
||||
>
|
||||
<MenuContextProvider type={type}>
|
||||
<ul>{children}</ul>
|
||||
<ul className={ulClassName}>{children}</ul>
|
||||
</MenuContextProvider>
|
||||
</nav>
|
||||
)
|
||||
@@ -86,7 +86,7 @@ export function Item({
|
||||
onClick={onClick}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
>
|
||||
{icon && <span className={iconClasses.join(' ')}>{icon}</span>}
|
||||
{icon && <div className={`${iconClasses.join(' ')} min-w-fit`}>{icon}</div>}
|
||||
<span className={contentClasses.join(' ')}>{children}</span>
|
||||
</a>
|
||||
</li>
|
||||
@@ -103,9 +103,7 @@ export function Group({ children, icon, title }: GroupProps) {
|
||||
const __styles = styleHandler('menu')
|
||||
const { type } = useMenuContext()
|
||||
return (
|
||||
<div
|
||||
className={[__styles.group.base, __styles.group.variants[type]].join(' ')}
|
||||
>
|
||||
<div className={[__styles.group.base, __styles.group.variants[type]].join(' ')}>
|
||||
{icon && <span className={__styles.group.icon}>{icon}</span>}
|
||||
<span className={__styles.group.content}>{title}</span>
|
||||
{children}
|
||||
|
||||
@@ -137,7 +137,7 @@ function RadioGroup({
|
||||
interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
|
||||
label: string
|
||||
afterLabel?: string
|
||||
beforeLabel?: string
|
||||
beforeLabel?: string | React.ReactNode
|
||||
description?: string
|
||||
size?: 'tiny' | 'small' | 'medium' | 'large' | 'xlarge'
|
||||
hidden?: boolean
|
||||
|
||||
@@ -7,6 +7,8 @@ import { useDispatch, useTrackedState } from '../../store'
|
||||
import RowRenderer from './RowRenderer'
|
||||
import AwesomeDebouncePromise from 'awesome-debounce-promise'
|
||||
import { ForeignRowSelectorProps } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { useForeignKeyConstraintsQuery } from 'data/database/foreign-key-constraints-query'
|
||||
|
||||
function rowKeyGetter(row: SupaRow) {
|
||||
return row?.idx ?? -1
|
||||
@@ -77,14 +79,23 @@ export const Grid = memo(
|
||||
|
||||
const table = state.table
|
||||
|
||||
function getColumnForeignKey(columnName: string) {
|
||||
const foreignKey = table?.columns.find((x) => x.name == columnName)?.foreignKey ?? {}
|
||||
const { project } = useProjectContext()
|
||||
const { data } = useForeignKeyConstraintsQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
schema: table?.schema ?? undefined,
|
||||
})
|
||||
|
||||
return Boolean(
|
||||
foreignKey.targetTableSchema && foreignKey.targetTableName && foreignKey.targetColumnName
|
||||
function getColumnForeignKey(columnName: string) {
|
||||
const { targetTableSchema, targetTableName, targetColumnName } =
|
||||
table?.columns.find((x) => x.name == columnName)?.foreignKey ?? {}
|
||||
|
||||
return data?.find(
|
||||
(key) =>
|
||||
key.target_schema == targetTableSchema &&
|
||||
key.target_table == targetTableName &&
|
||||
key.target_columns == targetColumnName
|
||||
)
|
||||
? foreignKey
|
||||
: undefined
|
||||
}
|
||||
|
||||
function onRowDoubleClick(row: any, column: any) {
|
||||
@@ -92,11 +103,7 @@ export const Grid = memo(
|
||||
|
||||
if (foreignKey) {
|
||||
onEditForeignKeyColumnValue({
|
||||
foreignKey: {
|
||||
target_column_name: foreignKey.targetColumnName!,
|
||||
target_table_name: foreignKey.targetTableName!,
|
||||
target_table_schema: foreignKey.targetTableSchema!,
|
||||
},
|
||||
foreignKey,
|
||||
row,
|
||||
column,
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useTableRowDeleteAllMutation } from 'data/table-rows/table-row-delete-a
|
||||
import { useTableRowTruncateMutation } from 'data/table-rows/table-row-truncate-mutation'
|
||||
import { useTableRowsCountQuery } from 'data/table-rows/table-rows-count-query'
|
||||
import { useTableRowsQuery } from 'data/table-rows/table-rows-query'
|
||||
import RLSBannerWarning from './RLSBannerWarning'
|
||||
|
||||
// [Joshen] CSV exports require this guard as a fail-safe if the table is
|
||||
// just too large for a browser to keep all the rows in memory before
|
||||
@@ -37,13 +38,16 @@ const Header = ({ table, sorts, filters, onAddColumn, onAddRow, headerActions }:
|
||||
const { selectedRows } = state
|
||||
|
||||
return (
|
||||
<div className="flex h-10 items-center justify-between bg-scale-100 px-5 py-1.5 dark:bg-scale-300">
|
||||
{selectedRows.size > 0 ? (
|
||||
<RowHeader table={table} sorts={sorts} filters={filters} />
|
||||
) : (
|
||||
<DefaultHeader table={table} onAddColumn={onAddColumn} onAddRow={onAddRow} />
|
||||
)}
|
||||
<div className="sb-grid-header__inner">{headerActions}</div>
|
||||
<div>
|
||||
<div className="flex h-10 items-center justify-between bg-scale-100 px-5 py-1.5 dark:bg-scale-300">
|
||||
{selectedRows.size > 0 ? (
|
||||
<RowHeader table={table} sorts={sorts} filters={filters} />
|
||||
) : (
|
||||
<DefaultHeader table={table} onAddColumn={onAddColumn} onAddRow={onAddRow} />
|
||||
)}
|
||||
<div className="sb-grid-header__inner">{headerActions}</div>
|
||||
</div>
|
||||
<RLSBannerWarning />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, IconAlertCircle, IconExternalLink, Modal } from 'ui'
|
||||
import Link from 'next/link'
|
||||
import { useParams, useStore } from 'hooks'
|
||||
import ConfirmationModal from 'components/ui/ConfirmationModal'
|
||||
import type { PostgresTable } from '@supabase/postgres-meta'
|
||||
|
||||
const RLS_ACKNOWLEDGED_KEY = 'supabase-acknowledge-rls-warning'
|
||||
|
||||
export default function RLSBannerWarning() {
|
||||
const { meta } = useStore()
|
||||
const { ref: projectRef, id: tableID } = useParams()
|
||||
|
||||
const isAcknowledged = localStorage.getItem(`${RLS_ACKNOWLEDGED_KEY}-${tableID}`) === 'true'
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const tables: PostgresTable[] = meta.tables.list()
|
||||
const currentTable = tables.find((table) => table.id.toString() === tableID)
|
||||
const rlsEnabled = currentTable?.rls_enabled
|
||||
const isPublicTable = currentTable?.schema === 'public'
|
||||
|
||||
function handleDismissWarning() {
|
||||
localStorage.setItem(`${RLS_ACKNOWLEDGED_KEY}-${tableID}`, 'true')
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isAcknowledged && !rlsEnabled && isPublicTable ? (
|
||||
<div>
|
||||
<div className="text-center bg-amber-500 text-amber-900 text-xs py-2.5 flex items-center justify-center relative">
|
||||
<IconAlertCircle size={16} strokeWidth={2} />
|
||||
<span className="uppercase font-bold ml-2">Warning</span>: This table is publicly
|
||||
readable and writable.{' '}
|
||||
<Link href={`/project/${projectRef}/auth/policies?search=${tableID}`}>
|
||||
<a className="underline ml-2 opacity-80 hover:opacity-100 transition">
|
||||
Enable Row Level Security
|
||||
</a>
|
||||
</Link>
|
||||
<div className="ml-20 absolute right-2">
|
||||
<Button
|
||||
type="outline"
|
||||
className="text-white hover:text-scale-1200"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
danger
|
||||
visible={isOpen}
|
||||
header="Warning: Your data is public"
|
||||
buttonLabel="I understand, dismiss this warning"
|
||||
onSelectCancel={() => setIsOpen(false)}
|
||||
onSelectConfirm={handleDismissWarning}
|
||||
children={
|
||||
<Modal.Content>
|
||||
<div className="mt-4 mb-6 space-y-4">
|
||||
<p className="text-sm text-scale-1100">
|
||||
Data in this table is publicly <u>readable and writable</u> by anyone with the
|
||||
anon key. We strongly recommend creating RLS (Row-Level Security) policies to
|
||||
allow access to this table.
|
||||
</p>
|
||||
<div>
|
||||
<Link href="https://supabase.com/docs/guides/auth/row-level-security">
|
||||
<a target="_blank">
|
||||
<Button type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
|
||||
About RLS Policies
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -62,8 +62,9 @@ const AccessTokenList = observer(() => {
|
||||
<Table.td>
|
||||
<Button
|
||||
as="span"
|
||||
type="danger"
|
||||
title="delete token"
|
||||
type="default"
|
||||
title="Delete token"
|
||||
className="px-1"
|
||||
onClick={() => {
|
||||
setToken(x)
|
||||
setIsOpen(true)
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { FC, useState } from 'react'
|
||||
import { Input, Button, Modal, Form, Alert } from 'ui'
|
||||
import { useState } from 'react'
|
||||
import { Input, Button, Modal, Form, Alert, IconChevronDown, Dropdown, IconExternalLink } from 'ui'
|
||||
import { useStore } from 'hooks'
|
||||
import {
|
||||
NewAccessToken,
|
||||
useAccessTokenCreateMutation,
|
||||
} from 'data/access-tokens/access-tokens-create-mutation'
|
||||
import { useAccessTokenCreateMutation } from 'data/access-tokens/access-tokens-create-mutation'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import Link from 'next/link'
|
||||
|
||||
const NewAccessTokenButton = observer(() => {
|
||||
export interface NewAccessTokenButtonProps {
|
||||
onCreateToken: (token: any) => void
|
||||
}
|
||||
|
||||
const NewAccessTokenButton = observer(({ onCreateToken }: NewAccessTokenButtonProps) => {
|
||||
const { ui } = useStore()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [newToken, setNewToken] = useState<NewAccessToken | undefined>(undefined)
|
||||
const [tokenScope, setTokenScope] = useState<'V0' | undefined>(undefined)
|
||||
|
||||
const validate = (values: any) => {
|
||||
const errors: any = {}
|
||||
if (!values.tokenName) {
|
||||
errors.tokenName = 'Please enter a name for the token'
|
||||
}
|
||||
if (!values.tokenName) errors.tokenName = 'Please enter a name for the token'
|
||||
return errors
|
||||
}
|
||||
|
||||
@@ -26,9 +26,8 @@ const NewAccessTokenButton = observer(() => {
|
||||
setSubmitting(true)
|
||||
|
||||
try {
|
||||
const response = await createAccessToken({ name: values.tokenName })
|
||||
setNewToken(response)
|
||||
|
||||
const response = await createAccessToken({ name: values.tokenName, scope: tokenScope })
|
||||
onCreateToken(response)
|
||||
setSubmitting(false)
|
||||
setIsOpen(false)
|
||||
} catch (error: any) {
|
||||
@@ -36,31 +35,62 @@ const NewAccessTokenButton = observer(() => {
|
||||
category: 'error',
|
||||
message: `Failed to create token: ${error.message}`,
|
||||
})
|
||||
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setNewToken(undefined)
|
||||
setIsOpen(!isOpen)
|
||||
}}
|
||||
>
|
||||
Generate new token
|
||||
</Button>
|
||||
{newToken && <NewTokenItem data={newToken} />}
|
||||
<div className="container max-w-7xl">
|
||||
<div className="flex justify-between">
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
className="rounded-r-none px-3"
|
||||
onClick={() => {
|
||||
setTokenScope(undefined)
|
||||
setIsOpen(true)
|
||||
}}
|
||||
>
|
||||
Generate new token
|
||||
</Button>
|
||||
<Dropdown
|
||||
align="end"
|
||||
side="bottom"
|
||||
overlay={[
|
||||
<Dropdown.Item
|
||||
key="experimental-token"
|
||||
onClick={() => {
|
||||
setTokenScope('V0')
|
||||
setIsOpen(true)
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p className="block text-scale-1200">Generate token for experimental API</p>
|
||||
</div>
|
||||
</Dropdown.Item>,
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
className="rounded-l-none px-[4px] py-[5px]"
|
||||
icon={<IconChevronDown />}
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
closable
|
||||
hideFooter
|
||||
size="small"
|
||||
size="medium"
|
||||
visible={isOpen}
|
||||
onCancel={() => setIsOpen(!isOpen)}
|
||||
header={
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h5 className="text-sm text-scale-1200">Generate New Token</h5>
|
||||
<h5 className="text-sm text-scale-1200">
|
||||
{tokenScope === 'V0' ? 'Generate token for experimental API' : 'Generate New Token'}
|
||||
</h5>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -72,19 +102,47 @@ const NewAccessTokenButton = observer(() => {
|
||||
>
|
||||
{({ isSubmitting }: { isSubmitting: boolean }) => (
|
||||
<div className="py-3 space-y-4">
|
||||
{tokenScope === 'V0' && (
|
||||
<Modal.Content>
|
||||
<Alert
|
||||
withIcon
|
||||
variant="warning"
|
||||
title="The experimental API provides additional endpoints which allows you to manage your organizations and projects."
|
||||
>
|
||||
<p>
|
||||
These include deleting organizations and projects which cannot be undone. As
|
||||
such, be very careful when using this API.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Link href="https://api.supabase.com/api/v0">
|
||||
<a target="_blank">
|
||||
<Button type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
|
||||
Experimental API documentation
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</Alert>
|
||||
</Modal.Content>
|
||||
)}
|
||||
<Modal.Content>
|
||||
<Input
|
||||
id="tokenName"
|
||||
label="Name"
|
||||
placeholder="Type in the token name"
|
||||
placeholder="Provide a name for your token"
|
||||
className="w-full"
|
||||
/>
|
||||
</Modal.Content>
|
||||
<Modal.Separator />
|
||||
<Modal.Content>
|
||||
<Button htmlType="submit" loading={isSubmitting} size="small" block danger>
|
||||
Generate Token
|
||||
</Button>
|
||||
<div className="flex items-center space-x-2 justify-end">
|
||||
<Button type="default" disabled={isSubmitting} onClick={() => setIsOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button htmlType="submit" loading={isSubmitting} disabled={isSubmitting}>
|
||||
Generate token
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
</div>
|
||||
)}
|
||||
@@ -95,28 +153,3 @@ const NewAccessTokenButton = observer(() => {
|
||||
})
|
||||
|
||||
export default NewAccessTokenButton
|
||||
|
||||
interface NewTokenItemProps {
|
||||
data: NewAccessToken
|
||||
}
|
||||
|
||||
const NewTokenItem: FC<NewTokenItemProps> = observer(({ data }) => {
|
||||
return (
|
||||
<Alert withIcon variant="success" title="Successfully generated a new token!">
|
||||
<div className="w-full space-y-2">
|
||||
<p className="text-sm">
|
||||
Do copy this access token and store it in a secure place - you will not be able to see it
|
||||
again.
|
||||
</p>
|
||||
<Input
|
||||
copy
|
||||
readOnly
|
||||
size="small"
|
||||
className="max-w-xl input-mono"
|
||||
value={data.token}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</Alert>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Alert, Input } from 'ui'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { NewAccessToken } from 'data/access-tokens/access-tokens-create-mutation'
|
||||
|
||||
interface NewTokenBannerProps {
|
||||
token: NewAccessToken
|
||||
}
|
||||
|
||||
const NewTokenBanner = observer(({ token }: NewTokenBannerProps) => {
|
||||
return (
|
||||
<Alert withIcon variant="success" title="Successfully generated a new token!">
|
||||
<div className="w-full space-y-2">
|
||||
<p className="text-sm">
|
||||
Do copy this access token and store it in a secure place - you will not be able to see it
|
||||
again.
|
||||
</p>
|
||||
<Input
|
||||
copy
|
||||
readOnly
|
||||
size="small"
|
||||
className="max-w-xl input-mono"
|
||||
value={token.token}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</Alert>
|
||||
)
|
||||
})
|
||||
|
||||
export default NewTokenBanner
|
||||
@@ -1,4 +1,5 @@
|
||||
import AccessTokenList from './AccessTokenList'
|
||||
import NewTokenBanner from './NewTokenBanner'
|
||||
import NewAccessTokenButton from './NewAccessTokenButton'
|
||||
|
||||
export { AccessTokenList, NewAccessTokenButton }
|
||||
export { AccessTokenList, NewTokenBanner, NewAccessTokenButton }
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { FC, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { Button, Input, InputNumber, Toggle, Listbox, IconEye, IconEyeOff } from 'ui'
|
||||
@@ -123,7 +124,9 @@ const FormField: FC<Props> = ({ name, properties, formValues, disabled = false }
|
||||
key={option.value}
|
||||
label={option.label}
|
||||
value={option.value}
|
||||
addOnBefore={() => <img className="h-6 w-6" src={`/img/icons/${option.icon}`} />}
|
||||
addOnBefore={() => (
|
||||
<img className="h-6 w-6" src={`${BASE_PATH}/img/icons/${option.icon}`} />
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</Listbox.Option>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { Alert, Button, Collapsible, Form, IconCheck, IconChevronUp, Input } from 'ui'
|
||||
|
||||
import { useStore, checkPermissions } from 'hooks'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { Provider } from './AuthProvidersForm.types'
|
||||
import { ProviderCollapsibleClasses } from './AuthProvidersForm.constants'
|
||||
import FormField from './FormField'
|
||||
@@ -78,7 +79,7 @@ const ProviderForm: FC<Props> = ({ provider }) => {
|
||||
width={14}
|
||||
/>
|
||||
<img
|
||||
src={`/img/icons/${provider.misc.iconKey}.svg`}
|
||||
src={`${BASE_PATH}/img/icons/${provider.misc.iconKey}.svg`}
|
||||
width={18}
|
||||
alt={`${provider.title} auth icon`}
|
||||
/>
|
||||
|
||||
@@ -53,9 +53,10 @@ const PolicyTableRow: FC<Props> = ({
|
||||
withIcon
|
||||
variant="warning"
|
||||
className="!px-4 !py-3 !mt-3"
|
||||
title="Warning: RLS is disabled"
|
||||
title="Warning: RLS is disabled. Your table is publicly readable and writable."
|
||||
>
|
||||
Anonymous access is allowed to this table
|
||||
Anyone with the anon. key can modify or delete your data. You should turn on RLS and
|
||||
create access policies to keep your data secure.
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { FC, useState } from 'react'
|
||||
import Router from 'next/router'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { Listbox, IconLoader, Button, IconPlus, IconAlertCircle, IconCreditCard } from 'ui'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
import { checkPermissions, useStore } from 'hooks'
|
||||
import { PRICING_TIER_PRODUCT_IDS, STRIPE_PRODUCT_IDS } from 'lib/constants'
|
||||
import { BASE_PATH, PRICING_TIER_PRODUCT_IDS, STRIPE_PRODUCT_IDS } from 'lib/constants'
|
||||
import { SubscriptionPreview } from '../Billing.types'
|
||||
import { getProductPrice, validateSubscriptionUpdatePayload } from '../Billing.utils'
|
||||
import PaymentTotal from './PaymentTotal'
|
||||
@@ -400,7 +401,7 @@ const PaymentSummaryPanel: FC<Props> = ({
|
||||
addOnBefore={() => {
|
||||
return (
|
||||
<img
|
||||
src={`/img/payment-methods/${method.card.brand
|
||||
src={`${BASE_PATH}/img/payment-methods/${method.card.brand
|
||||
.replace(' ', '-')
|
||||
.toLowerCase()}.png`}
|
||||
width="32"
|
||||
|
||||
@@ -7,7 +7,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useStore, checkPermissions, useParams } from 'hooks'
|
||||
import ExtensionCard from './ExtensionCard'
|
||||
import { HIDDEN_EXTENSIONS } from './Extensions.constants'
|
||||
import NoSearchResults from 'components/to-be-cleaned/NoSearchResults'
|
||||
import NoSearchResults from 'components/ui/NoSearchResults'
|
||||
import InformationBox from 'components/ui/InformationBox'
|
||||
import Link from 'next/link'
|
||||
|
||||
@@ -69,7 +69,9 @@ const Extensions: FC<Props> = ({}) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{extensions.length === 0 && <NoSearchResults />}
|
||||
{extensions.length === 0 && (
|
||||
<NoSearchResults searchString={filterString} onResetFilter={() => setFilterString('')} />
|
||||
)}
|
||||
|
||||
<div className="my-8 w-full space-y-12">
|
||||
{enabledExtensions.length > 0 && (
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { FC, useContext } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { Checkbox } from 'ui'
|
||||
import { union, without } from 'lodash'
|
||||
import { CreateHookContext } from './'
|
||||
|
||||
const CheckboxEvents: FC = observer(({}) => {
|
||||
const _localState: any = useContext(CreateHookContext)
|
||||
return (
|
||||
// @ts-ignore
|
||||
<Checkbox.Group
|
||||
name="events"
|
||||
label="Events"
|
||||
id="events"
|
||||
layout="horizontal"
|
||||
size="medium"
|
||||
onChange={(e) => {
|
||||
const temp = _localState.formState.events.value
|
||||
const value = e.target.checked
|
||||
? union(temp, [e.target.value])
|
||||
: without(temp, e.target.value)
|
||||
_localState.onFormChange({
|
||||
key: 'events',
|
||||
value: value,
|
||||
})
|
||||
}}
|
||||
error={_localState.formState.events.error}
|
||||
labelOptional="The type of events that will trigger your function hook"
|
||||
descriptionText="These are the events that are watched by the function hook, only the events selected above will fire the function hook on the table you've selected."
|
||||
>
|
||||
<Checkbox
|
||||
value="INSERT"
|
||||
id="INSERT"
|
||||
label="Insert"
|
||||
description={'Any insert operation on the table'}
|
||||
checked={_localState.formState.events.value.includes('INSERT')}
|
||||
/>
|
||||
<Checkbox
|
||||
value="UPDATE"
|
||||
id="UPDATE"
|
||||
label="Update"
|
||||
description="Any update operation, of any column in the table"
|
||||
checked={_localState.formState.events.value.includes('UPDATE')}
|
||||
/>
|
||||
<Checkbox
|
||||
value="DELETE"
|
||||
id="DELETE"
|
||||
label="Delete"
|
||||
description="Any deletion of a record"
|
||||
checked={_localState.formState.events.value.includes('DELETE')}
|
||||
/>
|
||||
</Checkbox.Group>
|
||||
)
|
||||
})
|
||||
|
||||
export default CheckboxEvents
|
||||
@@ -1,27 +0,0 @@
|
||||
import { FC, useContext } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { Input } from 'ui'
|
||||
import { CreateHookContext } from './'
|
||||
|
||||
const InputName: FC = observer(({}) => {
|
||||
const _localState: any = useContext(CreateHookContext)
|
||||
return (
|
||||
<Input
|
||||
id="name"
|
||||
label="Name"
|
||||
layout="horizontal"
|
||||
placeholder="Name of your function hook"
|
||||
value={_localState.formState.name.value}
|
||||
onChange={(e) =>
|
||||
_localState.onFormChange({
|
||||
key: 'name',
|
||||
value: e.target.value,
|
||||
})
|
||||
}
|
||||
size="medium"
|
||||
error={_localState.formState.name.error}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export default InputName
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
export const hookServiceOptions: {
|
||||
id: string
|
||||
label: string
|
||||
badge: string
|
||||
badgeType: 'brand' | 'amber'
|
||||
description: string
|
||||
img_url: string
|
||||
}[] = [
|
||||
{
|
||||
id: 'http_request',
|
||||
label: 'HTTP Request',
|
||||
badge: 'Alpha',
|
||||
badgeType: 'brand',
|
||||
description: 'Send an HTTP request to any URL.',
|
||||
img_url: 'http-request.png',
|
||||
},
|
||||
{
|
||||
id: 'supabase_function',
|
||||
label: 'Supabase Function',
|
||||
badge: 'Coming soon',
|
||||
badgeType: 'amber',
|
||||
description: 'Choose a Supabase Function to run.',
|
||||
img_url: 'supabase-severless-function.png',
|
||||
},
|
||||
{
|
||||
id: 'google_cloud_function',
|
||||
label: 'Google cloud run',
|
||||
badge: 'Coming soon',
|
||||
badgeType: 'amber',
|
||||
description: 'Choose a google cloud function to run',
|
||||
img_url: 'google-cloud-run.png',
|
||||
},
|
||||
{
|
||||
id: 'aws_lambda_function',
|
||||
label: 'AWS Lambda',
|
||||
badge: 'Coming soon',
|
||||
badgeType: 'amber',
|
||||
description: 'Choose an AWS Lambda function to run.',
|
||||
img_url: 'aws-lambda-severless-function.png',
|
||||
},
|
||||
]
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
import Image from 'next/image'
|
||||
import { FC, useContext } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { Radio, Badge } from 'ui'
|
||||
import { CreateHookContext } from '../'
|
||||
|
||||
interface Props {
|
||||
id: string
|
||||
description: string
|
||||
label: string
|
||||
img_url: string
|
||||
badgeType: 'brand' | 'amber'
|
||||
badge: string
|
||||
}
|
||||
|
||||
const RadioHookService: FC<Props> = observer(
|
||||
({ id, description, label, img_url, badgeType, badge }) => {
|
||||
const _localState: any = useContext(CreateHookContext)
|
||||
return (
|
||||
<Radio
|
||||
id={id}
|
||||
value={id}
|
||||
checked={_localState.formState.hookService.value == id}
|
||||
// @ts-ignore
|
||||
beforeLabel={
|
||||
<>
|
||||
<div className="flex items-center space-x-5">
|
||||
{/* <div className="h-3 w-3"> */}
|
||||
<Image
|
||||
src={`/img/function-providers/${img_url}`}
|
||||
layout="fixed"
|
||||
width="32"
|
||||
height="32"
|
||||
/>
|
||||
{/* </div> */}
|
||||
<div className="flex-col space-y-0">
|
||||
<div className="flex space-x-1">
|
||||
<span className="text-scale-1200">{label}</span>
|
||||
<Badge color={badgeType}>{badge}</Badge>
|
||||
</div>
|
||||
<span className="text-scale-900">{description}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export default RadioHookService
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
import { FC, useContext } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { Radio } from 'ui'
|
||||
|
||||
import { CreateHookContext } from '../'
|
||||
import RadioHookService from './RadioHookService'
|
||||
import { hookServiceOptions } from './RadioGroupHookService.constants'
|
||||
|
||||
const RadioGroupHookService: FC = observer(({}) => {
|
||||
const _localState: any = useContext(CreateHookContext)
|
||||
return (
|
||||
<>
|
||||
<div className="provider-radio-group">
|
||||
<Radio.Group
|
||||
type="cards"
|
||||
layout="vertical"
|
||||
onChange={(event) => {
|
||||
_localState.onFormChange({
|
||||
key: 'hookService',
|
||||
value: event.target.value,
|
||||
})
|
||||
}}
|
||||
value={_localState.formState.hookService.value}
|
||||
error={_localState.formState.hookService.error}
|
||||
>
|
||||
{hookServiceOptions.map((x) => (
|
||||
<RadioHookService key={x.id} {...x} />
|
||||
))}
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
export default RadioGroupHookService
|
||||
@@ -1,30 +0,0 @@
|
||||
import { FC, useContext } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { Select } from 'ui'
|
||||
import { CreateHookContext } from './'
|
||||
|
||||
const SelectEnabledMode: FC = observer(({}) => {
|
||||
const _localState: any = useContext(CreateHookContext)
|
||||
return (
|
||||
<Select
|
||||
id="enabled-mode"
|
||||
label="Enabled mode"
|
||||
layout="horizontal"
|
||||
value={_localState.formState.enabledMode.value}
|
||||
onChange={(e) =>
|
||||
_localState.onFormChange({
|
||||
key: 'enabledMode',
|
||||
value: e.target.value,
|
||||
})
|
||||
}
|
||||
size="small"
|
||||
>
|
||||
<Select.Option value="ORIGIN">Origin</Select.Option>
|
||||
<Select.Option value="REPLICA">Replica</Select.Option>
|
||||
<Select.Option value="ALWAYS">Always</Select.Option>
|
||||
<Select.Option value="DISABLED">Disabled</Select.Option>
|
||||
</Select>
|
||||
)
|
||||
})
|
||||
|
||||
export default SelectEnabledMode
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
import { FC, useContext } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { Button, IconPlus } from 'ui'
|
||||
|
||||
import { CreateHookContext } from '../'
|
||||
import InputServiceHeader from './InputServiceHeader'
|
||||
|
||||
const InputMultiServiceHeaders: FC = observer(({}) => {
|
||||
const _localState: any = useContext(CreateHookContext)
|
||||
|
||||
function onAddArgument() {
|
||||
_localState.onFormArrayChange({
|
||||
key: 'serviceHeaders',
|
||||
value: { name: '', value: '' },
|
||||
operation: 'add',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<h5>HTTP Headers</h5>
|
||||
</div>
|
||||
<div className="space-y-2 pt-4">
|
||||
{_localState.formState.serviceHeaders.value.map(
|
||||
(
|
||||
x: { name: string; value: string; error?: { name?: string; value?: string } },
|
||||
idx: number
|
||||
) => (
|
||||
<InputServiceHeader
|
||||
key={`serviceHeader-${idx}`}
|
||||
idx={idx}
|
||||
name={x.name}
|
||||
value={x.value}
|
||||
error={x.error}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div className="">
|
||||
<Button type="dashed" icon={<IconPlus />} onClick={onAddArgument}>
|
||||
Add a new header
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default InputMultiServiceHeaders
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
import { FC, useContext } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { Button, IconPlus } from 'ui'
|
||||
|
||||
import { CreateHookContext } from '../'
|
||||
import InputServiceParam from './InputServiceParam'
|
||||
|
||||
const InputMultiServiceParams: FC = observer(({}) => {
|
||||
const _localState: any = useContext(CreateHookContext)
|
||||
|
||||
function onAddArgument() {
|
||||
_localState.onFormArrayChange({
|
||||
key: 'serviceParams',
|
||||
value: { name: '', value: '' },
|
||||
operation: 'add',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<h5>HTTP Params</h5>
|
||||
</div>
|
||||
<div className="space-y-2 pt-4">
|
||||
{_localState.formState.serviceParams.value.map(
|
||||
(
|
||||
x: { name: string; value: string; error?: { name?: string; value?: string } },
|
||||
idx: number
|
||||
) => (
|
||||
<InputServiceParam
|
||||
key={`serviceParam-${idx}`}
|
||||
idx={idx}
|
||||
name={x.name}
|
||||
value={x.value}
|
||||
error={x.error}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<Button type="dashed" icon={<IconPlus />} onClick={onAddArgument}>
|
||||
Add a new param
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default InputMultiServiceParams
|
||||
-77
@@ -1,77 +0,0 @@
|
||||
import { FC, useContext, FormEvent } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { Input, IconTrash, Button } from 'ui'
|
||||
import { CreateHookContext } from '../'
|
||||
|
||||
interface Props {
|
||||
idx: number
|
||||
name: string
|
||||
value: string
|
||||
error?: { name?: string; value?: string }
|
||||
}
|
||||
|
||||
const InputServiceHeader: FC<Props> = observer(({ idx, name, value, error }) => {
|
||||
const _localState: any = useContext(CreateHookContext)
|
||||
|
||||
function onNameChange(e: FormEvent<HTMLInputElement>) {
|
||||
const _value = e.currentTarget.value
|
||||
_localState.onFormArrayChange({
|
||||
key: 'serviceHeaders',
|
||||
value: { name: _value, value },
|
||||
idx,
|
||||
operation: 'update',
|
||||
})
|
||||
}
|
||||
|
||||
function onValueChange(e: FormEvent<HTMLInputElement>) {
|
||||
const _value = e.currentTarget.value
|
||||
_localState.onFormArrayChange({
|
||||
key: 'serviceHeaders',
|
||||
value: { name, value: _value },
|
||||
idx,
|
||||
operation: 'update',
|
||||
})
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
_localState.onFormArrayChange({
|
||||
key: 'serviceHeaders',
|
||||
idx,
|
||||
operation: 'delete',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex space-x-1">
|
||||
<Input
|
||||
id={`name-${idx}`}
|
||||
className="flex-1"
|
||||
placeholder="Header name"
|
||||
value={name}
|
||||
onChange={onNameChange}
|
||||
size="small"
|
||||
error={error?.name}
|
||||
/>
|
||||
<Input
|
||||
id={`value-${idx}`}
|
||||
className="flex-1"
|
||||
placeholder="Header value"
|
||||
value={value}
|
||||
onChange={onValueChange}
|
||||
size="small"
|
||||
error={error?.value}
|
||||
/>
|
||||
<div>
|
||||
<Button
|
||||
danger
|
||||
type="primary"
|
||||
icon={<IconTrash size="tiny" />}
|
||||
onClick={onDelete}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default InputServiceHeader
|
||||
-77
@@ -1,77 +0,0 @@
|
||||
import { FC, useContext, FormEvent } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { Input, IconTrash, Button } from 'ui'
|
||||
import { CreateHookContext } from '../'
|
||||
|
||||
interface Props {
|
||||
idx: number
|
||||
name: string
|
||||
value: string
|
||||
error?: { name?: string; value?: string }
|
||||
}
|
||||
|
||||
const InputServiceParam: FC<Props> = observer(({ idx, name, value, error }) => {
|
||||
const _localState: any = useContext(CreateHookContext)
|
||||
|
||||
function onNameChange(e: FormEvent<HTMLInputElement>) {
|
||||
const _value = e.currentTarget.value
|
||||
_localState.onFormArrayChange({
|
||||
key: 'serviceParams',
|
||||
value: { name: _value, value },
|
||||
idx,
|
||||
operation: 'update',
|
||||
})
|
||||
}
|
||||
|
||||
function onValueChange(e: FormEvent<HTMLInputElement>) {
|
||||
const _value = e.currentTarget.value
|
||||
_localState.onFormArrayChange({
|
||||
key: 'serviceParams',
|
||||
value: { name, value: _value },
|
||||
idx,
|
||||
operation: 'update',
|
||||
})
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
_localState.onFormArrayChange({
|
||||
key: 'serviceParams',
|
||||
idx,
|
||||
operation: 'delete',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex space-x-1">
|
||||
<Input
|
||||
id={`name-${idx}`}
|
||||
className="flex-1"
|
||||
placeholder="Param name"
|
||||
value={name}
|
||||
onChange={onNameChange}
|
||||
size="small"
|
||||
error={error?.name}
|
||||
/>
|
||||
<Input
|
||||
id={`value-${idx}`}
|
||||
className="flex-1"
|
||||
placeholder="Param value"
|
||||
value={value}
|
||||
onChange={onValueChange}
|
||||
size="small"
|
||||
error={error?.value}
|
||||
/>
|
||||
<div>
|
||||
<Button
|
||||
danger
|
||||
type="primary"
|
||||
icon={<IconTrash size="tiny" />}
|
||||
onClick={onDelete}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default InputServiceParam
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
import { FC, useContext } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { Input } from 'ui'
|
||||
import { CreateHookContext } from '../'
|
||||
|
||||
// Not used currently
|
||||
|
||||
const InputServiceTimeout: FC = observer(({}) => {
|
||||
const _localState: any = useContext(CreateHookContext)
|
||||
return (
|
||||
<Input
|
||||
id="timeout-ms"
|
||||
type="number"
|
||||
label="Request timeout"
|
||||
layout="horizontal"
|
||||
descriptionText="Request timeout value in millisecond"
|
||||
value={_localState.formState.serviceTimeoutMs.value}
|
||||
onChange={(e) =>
|
||||
_localState.onFormChange({
|
||||
key: 'serviceTimeoutMs',
|
||||
value: e.target.value,
|
||||
})
|
||||
}
|
||||
size="medium"
|
||||
error={_localState.formState.serviceTimeoutMs.error}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export default InputServiceTimeout
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
import { FC, useContext } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { Input } from 'ui'
|
||||
import { CreateHookContext } from '../'
|
||||
|
||||
const InputServiceUrl: FC = observer(({}) => {
|
||||
const _localState: any = useContext(CreateHookContext)
|
||||
return (
|
||||
<Input
|
||||
id="service-url"
|
||||
type="url"
|
||||
label="URL"
|
||||
layout="horizontal"
|
||||
placeholder="http://api.com/path/resource"
|
||||
descriptionText="URL of the HTTP request. Must include HTTP/HTTPS"
|
||||
value={_localState.formState.serviceUrl.value}
|
||||
onChange={(e) =>
|
||||
_localState.onFormChange({
|
||||
key: 'serviceUrl',
|
||||
value: e.target.value,
|
||||
})
|
||||
}
|
||||
size="medium"
|
||||
error={_localState.formState.serviceUrl.error}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export default InputServiceUrl
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
import { FC, useContext } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { Select } from 'ui'
|
||||
import { CreateHookContext } from '../'
|
||||
|
||||
const SelectServiceMethod: FC = observer(({}) => {
|
||||
const _localState: any = useContext(CreateHookContext)
|
||||
return (
|
||||
<Select
|
||||
id="method"
|
||||
label="Method"
|
||||
layout="horizontal"
|
||||
value={_localState.formState.serviceMethod.value}
|
||||
onChange={(e) =>
|
||||
_localState.onFormChange({
|
||||
key: 'serviceMethod',
|
||||
value: e.target.value,
|
||||
})
|
||||
}
|
||||
size="small"
|
||||
>
|
||||
<Select.Option value="GET">GET</Select.Option>
|
||||
<Select.Option value="POST">POST</Select.Option>
|
||||
</Select>
|
||||
)
|
||||
})
|
||||
|
||||
export default SelectServiceMethod
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
import { FC, useContext } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { Alert, Button } from 'ui'
|
||||
import { CreateHookContext } from '../'
|
||||
|
||||
const ServiceUnavailableBox: FC = observer(({}) => {
|
||||
const _localState: any = useContext(CreateHookContext)
|
||||
return (
|
||||
<Alert variant="warning" title="Service under development" withIcon>
|
||||
<div className="space-y-4">
|
||||
<div>We currently do not support this service.</div>
|
||||
<Button
|
||||
type="default"
|
||||
className="flex-grow"
|
||||
onClick={() =>
|
||||
_localState.onFormChange({
|
||||
key: 'hookService',
|
||||
value: 'http_request',
|
||||
})
|
||||
}
|
||||
>
|
||||
Switch to HTTP
|
||||
</Button>
|
||||
</div>
|
||||
</Alert>
|
||||
)
|
||||
})
|
||||
|
||||
export default ServiceUnavailableBox
|
||||
@@ -1,44 +0,0 @@
|
||||
import { FC, useContext } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { SidePanel } from 'ui'
|
||||
|
||||
import { CreateHookContext } from '../'
|
||||
import SelectServiceMethod from './SelectServiceMethod'
|
||||
import InputServiceUrl from './InputServiceUrl'
|
||||
import InputMultiServiceHeaders from './InputMultiServiceHeaders'
|
||||
import InputMultiServiceParams from './InputMultiServiceParams'
|
||||
import ServiceUnavailableBox from './ServiceUnavailableBox'
|
||||
|
||||
const ServiceConfigForm: FC = observer(({}) => {
|
||||
const _localState: any = useContext(CreateHookContext)
|
||||
return (
|
||||
<>
|
||||
{_localState.formState.hookService.value === 'http_request' ? (
|
||||
<>
|
||||
<SidePanel.Separator />
|
||||
<div className="space-y-10">
|
||||
<div className="space-y-6 px-6">
|
||||
<h5>HTTP Request</h5>
|
||||
<SelectServiceMethod />
|
||||
<InputServiceUrl />
|
||||
</div>
|
||||
<SidePanel.Separator />
|
||||
<div className="px-6">
|
||||
<InputMultiServiceHeaders />
|
||||
</div>
|
||||
<SidePanel.Separator />
|
||||
<div className="px-6">
|
||||
<InputMultiServiceParams />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="px-6">
|
||||
<ServiceUnavailableBox />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
export default ServiceConfigForm
|
||||
@@ -1,96 +0,0 @@
|
||||
import Link from 'next/link'
|
||||
import SVG from 'react-inlinesvg'
|
||||
import { FC, useContext } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { Input, Listbox } from 'ui'
|
||||
|
||||
import { CreateHookContext } from './'
|
||||
import { useStore } from 'hooks'
|
||||
|
||||
const TableSelection: FC = observer(({}) => {
|
||||
const { ui } = useStore()
|
||||
const projectRef = ui.selectedProject?.ref
|
||||
const _localState: any = useContext(CreateHookContext)
|
||||
|
||||
if (_localState.tables.length === 0) {
|
||||
return (
|
||||
<Input
|
||||
label="Table"
|
||||
layout="horizontal"
|
||||
disabled
|
||||
placeholder="No tables created, please create one first"
|
||||
// @ts-ignore
|
||||
descriptionText={
|
||||
<p className="text-scale-1000">
|
||||
<span className="text-scale-1000">
|
||||
This is the table the trigger will watch for changes. There's currently no tables
|
||||
created - please create one{' '}
|
||||
</span>
|
||||
<Link href={`/project/${projectRef}/editor`}>
|
||||
<a className="text-brand-900">here</a>
|
||||
</Link>
|
||||
<span className="text-scale-1000"> first.</span>
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Listbox
|
||||
id="table"
|
||||
label="Table"
|
||||
layout="horizontal"
|
||||
value={_localState.formState.tableId.value}
|
||||
onChange={(id) => {
|
||||
const _table = _localState.tables.find((x: any) => x.id === id)
|
||||
if (_table) {
|
||||
_localState.onFormChange({
|
||||
key: 'schema',
|
||||
value: _table.schema,
|
||||
})
|
||||
_localState.onFormChange({
|
||||
key: 'table',
|
||||
value: _table.name,
|
||||
})
|
||||
_localState.onFormChange({
|
||||
key: 'tableId',
|
||||
value: id,
|
||||
})
|
||||
}
|
||||
}}
|
||||
size="medium"
|
||||
error={_localState.formState.table.error}
|
||||
descriptionText="This is the table the trigger will watch for changes. You can only select 1 table for a trigger."
|
||||
>
|
||||
{_localState.tables.map((x: any) => {
|
||||
return (
|
||||
<Listbox.Option
|
||||
id={x.id}
|
||||
key={x.id}
|
||||
value={x.id}
|
||||
label={x.name}
|
||||
addOnBefore={() => (
|
||||
<div className="flex items-center justify-center rounded bg-scale-1200 p-1 text-scale-100 ">
|
||||
<SVG
|
||||
src={'/img/table-editor.svg'}
|
||||
style={{ width: `16px`, height: `16px`, strokeWidth: '1px' }}
|
||||
preProcessor={(code) =>
|
||||
code.replace(/svg/, 'svg class="m-auto text-color-inherit"')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
<p>{x.name}</p>
|
||||
<p className="text-scale-1100">{x.schema}</p>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
)
|
||||
})}
|
||||
</Listbox>
|
||||
)
|
||||
})
|
||||
|
||||
export default TableSelection
|
||||
@@ -1,512 +0,0 @@
|
||||
import { makeAutoObservable } from 'mobx'
|
||||
import { FC, useEffect, createContext } from 'react'
|
||||
import { observer, useLocalObservable } from 'mobx-react-lite'
|
||||
import { isEmpty, mapValues, has, filter, keyBy, isUndefined } from 'lodash'
|
||||
import { Dictionary } from 'components/grid'
|
||||
import { Badge, Input, SidePanel } from 'ui'
|
||||
import { useStore } from 'hooks'
|
||||
|
||||
import InputName from './InputName'
|
||||
import SelectEnabledMode from './SelectEnabledMode'
|
||||
import CheckboxEvents from './CheckboxEvents'
|
||||
import TableSelection from './TableSelection'
|
||||
import RadioGroupHookService from './RadioGroupHookService'
|
||||
import ServiceConfigForm from './ServiceConfigForm'
|
||||
|
||||
import { formatArguments, convertKeyValue, hasWhitespace, isValidHttpUrl } from '../Hooks.utils'
|
||||
|
||||
// [Joshen] TODO: Refactor to use Form component
|
||||
|
||||
class CreateHookFormState {
|
||||
id: number | undefined
|
||||
originalName: string | undefined
|
||||
// @ts-ignore
|
||||
enabledMode: { value: string }
|
||||
// @ts-ignore
|
||||
events: { value: string[]; error?: string }
|
||||
// @ts-ignore
|
||||
name: { value: string; error?: string }
|
||||
// @ts-ignore
|
||||
schema: { value: string }
|
||||
// @ts-ignore
|
||||
table: { value: string; error?: string }
|
||||
// @ts-ignore
|
||||
tableId: { value: number; error?: string }
|
||||
// @ts-ignore
|
||||
hookService: { value: string; error?: string }
|
||||
// @ts-ignore
|
||||
serviceUrl: { value: string; error?: string }
|
||||
// @ts-ignore
|
||||
serviceMethod: { value: string; error?: string }
|
||||
// @ts-ignore
|
||||
serviceHeaders: {
|
||||
value: { name: string; value: string; error?: { name?: string; value?: string } }[]
|
||||
}
|
||||
// @ts-ignore
|
||||
serviceParams: {
|
||||
value: { name: string; value: string; error?: { name?: string; value?: string } }[]
|
||||
}
|
||||
// @ts-ignore
|
||||
serviceTimeoutMs: { value: string; error?: string }
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this)
|
||||
this.reset()
|
||||
}
|
||||
|
||||
get requestBody() {
|
||||
return {
|
||||
id: this.id,
|
||||
activation: 'AFTER',
|
||||
enabled_mode: this.enabledMode.value,
|
||||
events: this.events.value,
|
||||
function_args: [
|
||||
this.serviceUrl.value.replaceAll(' ', ''),
|
||||
this.serviceMethod.value,
|
||||
JSON.stringify(mapValues(keyBy(this.serviceHeaders.value, 'name'), 'value')),
|
||||
formatArguments(this.serviceParams.value),
|
||||
this.serviceTimeoutMs.value,
|
||||
],
|
||||
function_name: 'http_request',
|
||||
function_schema: 'supabase_functions',
|
||||
orientation: 'ROW',
|
||||
name: this.name.value,
|
||||
schema: this.schema.value,
|
||||
table: this.table.value,
|
||||
table_id: this.tableId.value,
|
||||
}
|
||||
}
|
||||
|
||||
reset(hook?: Dictionary<any>) {
|
||||
this.id = hook?.id
|
||||
this.originalName = hook?.name
|
||||
this.enabledMode = { value: hook?.enabled_mode ?? 'ORIGIN' }
|
||||
this.events = { value: hook?.events ?? [] }
|
||||
this.name = { value: hook?.name ?? '' }
|
||||
this.schema = { value: hook?.schema ?? '' }
|
||||
this.table = { value: hook?.table ?? '' }
|
||||
this.tableId = { value: hook?.table_id ?? '' }
|
||||
// TODO: hook obj doesn't have service field
|
||||
this.hookService = { value: 'http_request' }
|
||||
/**
|
||||
* hook.function_args contains 5 params in order
|
||||
* [url, method, headers, params, timeout]
|
||||
*/
|
||||
this.serviceUrl = { value: hook?.function_args?.length == 5 ? hook?.function_args[0] : '' }
|
||||
this.serviceMethod = {
|
||||
value: hook?.function_args?.length == 5 ? hook?.function_args[1] : 'GET',
|
||||
}
|
||||
this.serviceHeaders =
|
||||
hook?.function_args?.length == 5
|
||||
? convertKeyValue(hook?.function_args[2])
|
||||
: { value: [{ name: 'Content-type', value: 'application/json' }] }
|
||||
|
||||
this.serviceParams =
|
||||
hook?.function_args?.length == 5 ? convertKeyValue(hook?.function_args[3]) : { value: [] }
|
||||
|
||||
this.serviceTimeoutMs = {
|
||||
value: hook?.function_args?.length == 5 ? hook?.function_args[4] : '1000',
|
||||
}
|
||||
}
|
||||
|
||||
update(state: Dictionary<any>) {
|
||||
this.enabledMode = state.enabledMode
|
||||
this.events = state.events
|
||||
this.name = state.name
|
||||
this.schema = state.schema
|
||||
this.table = state.table
|
||||
this.tableId = state.tableId
|
||||
this.hookService = state.hookService
|
||||
this.serviceUrl = state.serviceUrl
|
||||
this.serviceMethod = state.serviceMethod
|
||||
this.serviceHeaders = state.serviceHeaders
|
||||
this.serviceParams = state.serviceParams
|
||||
this.serviceTimeoutMs = state.serviceTimeoutMs
|
||||
}
|
||||
}
|
||||
|
||||
interface ICreateHookStore {
|
||||
loading: boolean
|
||||
formState: CreateHookFormState
|
||||
meta: any
|
||||
project: any
|
||||
tables: Dictionary<any>[]
|
||||
onFormChange: (value: { key: string; value: any }) => void
|
||||
onFormArrayChange: (value: {
|
||||
operation: 'add' | 'delete' | 'update'
|
||||
key: string
|
||||
idx?: number
|
||||
value?: any
|
||||
}) => void
|
||||
setDefaultSelectedTable: () => void
|
||||
setLoading: (value: boolean) => void
|
||||
setTables: (value: Dictionary<any>[]) => void
|
||||
validateForm: () => boolean
|
||||
}
|
||||
|
||||
class CreateHookStore implements ICreateHookStore {
|
||||
loading = false
|
||||
formState = new CreateHookFormState()
|
||||
meta = null
|
||||
project = null
|
||||
tables = []
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this)
|
||||
}
|
||||
|
||||
get title() {
|
||||
return this.formState.id
|
||||
? `Edit '${this.formState.originalName}' database webhook`
|
||||
: 'Add a new database webhook'
|
||||
}
|
||||
|
||||
get isEditing() {
|
||||
return this.formState.id != undefined
|
||||
}
|
||||
|
||||
// set first table as default selection
|
||||
setDefaultSelectedTable = () => {
|
||||
if (this.tables?.length != 0) {
|
||||
this.formState.schema.value = (this.tables[0] as any).schema
|
||||
this.formState.table.value = (this.tables[0] as any).name
|
||||
this.formState.tableId.value = (this.tables[0] as any).id
|
||||
}
|
||||
}
|
||||
|
||||
setLoading = (value: boolean) => {
|
||||
this.loading = value
|
||||
}
|
||||
|
||||
setTables = (value: Dictionary<any>[]) => {
|
||||
this.tables = value as any
|
||||
this.setDefaultSelectedTable()
|
||||
}
|
||||
|
||||
onFormChange = ({ key, value }: { key: string; value: any }) => {
|
||||
if (has(this.formState, key)) {
|
||||
const temp = (this.formState as any)[key] as any
|
||||
;(this.formState as any)[key] = { ...temp, value, error: undefined }
|
||||
} else {
|
||||
;(this.formState as any)[key] = { value }
|
||||
}
|
||||
}
|
||||
|
||||
onFormArrayChange = ({
|
||||
operation,
|
||||
key,
|
||||
idx,
|
||||
value,
|
||||
}: {
|
||||
operation: 'add' | 'delete' | 'update'
|
||||
key: string
|
||||
idx?: number
|
||||
value?: any
|
||||
}) => {
|
||||
switch (operation) {
|
||||
case 'add': {
|
||||
if (has(this.formState, key)) {
|
||||
;(this.formState as any)[key].value.push(value)
|
||||
} else {
|
||||
;(this.formState as any)[key] = { value: [value] }
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'delete': {
|
||||
if (has(this.formState, key)) {
|
||||
const temp = filter(
|
||||
(this.formState as any)[key].value,
|
||||
(_: any, index: number) => index != idx
|
||||
) as any
|
||||
;(this.formState as any)[key].value = temp
|
||||
}
|
||||
break
|
||||
}
|
||||
default: {
|
||||
if (has(this.formState, key) && !isUndefined(idx)) {
|
||||
;(this.formState as any)[key].value[idx] = value
|
||||
} else {
|
||||
;(this.formState as any)[key] = { value: [{ value }] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validateForm = () => {
|
||||
let isValidated = true
|
||||
const _state = mapValues(this.formState, (x: { value: any }, key: string) => {
|
||||
switch (key) {
|
||||
case 'name': {
|
||||
if (isEmpty(x.value) || hasWhitespace(x.value)) {
|
||||
isValidated = false
|
||||
return { ...x, error: 'Invalid hook name' }
|
||||
} else {
|
||||
return x
|
||||
}
|
||||
}
|
||||
case 'events': {
|
||||
if (isEmpty(x.value)) {
|
||||
isValidated = false
|
||||
return { ...x, error: 'Select at least 1 event' }
|
||||
} else {
|
||||
return x
|
||||
}
|
||||
}
|
||||
case 'table': {
|
||||
if (isEmpty(x.value)) {
|
||||
isValidated = false
|
||||
return { ...x, error: 'You must choose a table' }
|
||||
} else {
|
||||
return x
|
||||
}
|
||||
}
|
||||
case 'hookService': {
|
||||
if (x.value != 'http_request') {
|
||||
isValidated = false
|
||||
return { ...x, error: 'Not supported service' }
|
||||
} else {
|
||||
return x
|
||||
}
|
||||
}
|
||||
case 'serviceTimeoutMs': {
|
||||
if (isEmpty(x.value) || isNaN(x.value)) {
|
||||
isValidated = false
|
||||
return { ...x, error: 'Invalid input' }
|
||||
} else {
|
||||
return x
|
||||
}
|
||||
}
|
||||
case 'serviceUrl': {
|
||||
if (isEmpty(x.value) || !isValidHttpUrl(x.value)) {
|
||||
isValidated = false
|
||||
return { ...x, error: 'Invalid input' }
|
||||
} else {
|
||||
return x
|
||||
}
|
||||
}
|
||||
case 'serviceMethod': {
|
||||
if (isEmpty(x.value)) {
|
||||
isValidated = false
|
||||
return { ...x, error: 'Invalid input' }
|
||||
} else {
|
||||
return x
|
||||
}
|
||||
}
|
||||
case 'serviceHeaders':
|
||||
case 'serviceParams': {
|
||||
const temp = x.value?.map((i: Dictionary<any>) => {
|
||||
const error: any = { name: undefined, value: undefined }
|
||||
if (isEmpty(i.name) || hasWhitespace(i.name)) {
|
||||
isValidated = false
|
||||
error.name = 'Invalid name'
|
||||
}
|
||||
if (isEmpty(i.value)) {
|
||||
isValidated = false
|
||||
error.value = 'Missing value'
|
||||
}
|
||||
return { ...i, error }
|
||||
})
|
||||
x.value = temp
|
||||
return x
|
||||
}
|
||||
default:
|
||||
return x
|
||||
}
|
||||
})
|
||||
if (!isValidated) {
|
||||
this.formState.update(_state)
|
||||
}
|
||||
return isValidated
|
||||
}
|
||||
}
|
||||
|
||||
export const CreateHookContext = createContext<ICreateHookStore | null>(null)
|
||||
|
||||
interface CreateHookProps {
|
||||
hook?: any
|
||||
visible: boolean
|
||||
setVisible: (value: boolean) => void
|
||||
}
|
||||
|
||||
const CreateHook: FC<CreateHookProps> = ({ hook, visible = true, setVisible }) => {
|
||||
const { meta, ui } = useStore()
|
||||
const project = ui.selectedProject
|
||||
|
||||
const _localState: any = useLocalObservable(() => new CreateHookStore())
|
||||
_localState.meta = meta
|
||||
_localState.project = project
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSchemas = async () => {
|
||||
await _localState.meta.tables.load()
|
||||
const tables = _localState!.meta!.tables.list(
|
||||
(table: any) => !meta.excludedSchemas.includes(table.schema)
|
||||
)
|
||||
_localState.setTables(tables)
|
||||
}
|
||||
|
||||
fetchSchemas()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (hook) {
|
||||
_localState.formState.reset(hook)
|
||||
} else {
|
||||
_localState.formState.reset()
|
||||
_localState.setDefaultSelectedTable()
|
||||
}
|
||||
}, [visible, hook])
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
if (_localState.validateForm()) {
|
||||
_localState.setLoading(true)
|
||||
|
||||
const body = _localState.formState.requestBody
|
||||
|
||||
const response: any = _localState.isEditing
|
||||
? await _localState.meta.hooks.update(body.id, body)
|
||||
: await _localState.meta.hooks.create(body)
|
||||
|
||||
if (response.error) {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: `Failed to create hook: ${response.error.message ?? 'Submit request failed'}`,
|
||||
})
|
||||
_localState.setLoading(false)
|
||||
} else {
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
message: `${_localState.isEditing ? 'Updated' : 'Created new'} hook called ${
|
||||
response.name
|
||||
}`,
|
||||
})
|
||||
_localState.setLoading(false)
|
||||
setVisible(!visible)
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
ui.setNotification({ category: 'error', message: `Failed to create hook: ${error.message}` })
|
||||
_localState.setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SidePanel
|
||||
size="large"
|
||||
visible={visible}
|
||||
loading={_localState.loading}
|
||||
header={_localState.title}
|
||||
className="hooks-sidepanel mr-0 transform transition-all duration-300 ease-in-out"
|
||||
onConfirm={handleSubmit}
|
||||
onCancel={() => setVisible(!visible)}
|
||||
>
|
||||
<CreateHookContext.Provider value={_localState}>
|
||||
<div className="space-y-10 py-6">
|
||||
{_localState.isEditing ? (
|
||||
<>
|
||||
<div className="space-y-10 px-6">
|
||||
<InputName />
|
||||
<SelectEnabledMode />
|
||||
</div>
|
||||
|
||||
{_localState.formState.serviceHeaders.value.length > 0 && (
|
||||
<>
|
||||
<SidePanel.Separator />
|
||||
<div className="space-y-2 px-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h5 className="text-base text-scale-1200">HTTP Headers</h5>
|
||||
<Badge color="gray">Read only</Badge>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{_localState.formState.serviceHeaders.value.map(
|
||||
(serviceHeader: { name: string; value: string }) => (
|
||||
<div
|
||||
key={serviceHeader.name}
|
||||
className="flex items-center justify-center space-x-2"
|
||||
>
|
||||
<Input
|
||||
disabled
|
||||
className="w-full flex-1"
|
||||
readOnly
|
||||
value={serviceHeader.name}
|
||||
/>
|
||||
<Input
|
||||
disabled
|
||||
className="w-full flex-1"
|
||||
readOnly
|
||||
value={serviceHeader.value}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mx-6 mt-12 text-sm text-scale-1100">
|
||||
<strong>Note:</strong> You can only edit a webhook's name and mode. To change
|
||||
other settings, you'll need to delete and recreate the webhook.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{_localState.formState.serviceParams.value.length > 0 && (
|
||||
<>
|
||||
<SidePanel.Separator />
|
||||
<div className="space-y-2 px-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h5 className="text-base text-scale-1200">HTTP Parameters</h5>
|
||||
<Badge color="gray">Read only</Badge>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{_localState.formState.serviceParams.value.map(
|
||||
(serviceParam: { name: string; value: string }) => (
|
||||
<div
|
||||
key={serviceParam.name}
|
||||
className="flex items-center justify-center space-x-2"
|
||||
>
|
||||
<Input
|
||||
disabled
|
||||
className="w-full flex-1"
|
||||
readOnly
|
||||
value={serviceParam.name}
|
||||
/>
|
||||
<Input
|
||||
disabled
|
||||
className="w-full flex-1"
|
||||
readOnly
|
||||
value={serviceParam.value}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="px-6">
|
||||
<InputName />
|
||||
</div>
|
||||
<SidePanel.Separator />
|
||||
<div className="space-y-6 px-6">
|
||||
<h5 className="text-base text-scale-1200">Conditions to fire hook</h5>
|
||||
<TableSelection />
|
||||
<CheckboxEvents />
|
||||
</div>
|
||||
<SidePanel.Separator />
|
||||
<div className="space-y-6 px-6">
|
||||
<h5 className="text-base text-scale-1200">Type of hook</h5>
|
||||
<RadioGroupHookService />
|
||||
</div>
|
||||
<ServiceConfigForm />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CreateHookContext.Provider>
|
||||
</SidePanel>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(CreateHook)
|
||||
@@ -1,56 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { useStore } from 'hooks'
|
||||
import TextConfirmModal from 'components/ui/Modals/TextConfirmModal'
|
||||
|
||||
type DeleteHookProps = {
|
||||
hook?: any
|
||||
visible: boolean
|
||||
setVisible: (value: boolean) => void
|
||||
} & any
|
||||
|
||||
const DeleteHook: React.FC<DeleteHookProps> = ({ hook, visible, setVisible }) => {
|
||||
const { ui, meta } = useStore()
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const { id, name, schema } = hook ?? {}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
setLoading(true)
|
||||
if (!id) {
|
||||
throw Error('Invalid hook info')
|
||||
}
|
||||
const response: any = await meta.hooks.del(id)
|
||||
if (response.error) {
|
||||
throw response.error
|
||||
} else {
|
||||
ui.setNotification({ category: 'success', message: `Successfully removed ${name}` })
|
||||
setVisible(false)
|
||||
}
|
||||
} catch (error: any) {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: `Failed to delete ${name}: ${error.message}`,
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TextConfirmModal
|
||||
visible={visible}
|
||||
onCancel={() => setVisible(!visible)}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete this hook"
|
||||
loading={loading}
|
||||
confirmLabel={`Delete hook ${name}`}
|
||||
confirmPlaceholder="Type in name of hook"
|
||||
confirmString={name}
|
||||
text={`This will delete your hook called ${name} of schema ${schema}.`}
|
||||
alert="You cannot recover this hook once it is deleted!"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(DeleteHook)
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { useStore } from 'hooks'
|
||||
import TextConfirmModal from 'components/ui/Modals/TextConfirmModal'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { useDatabaseTriggerDeleteMutation } from 'data/database-triggers/database-trigger-delete-mutation'
|
||||
import { PostgresTrigger } from '@supabase/postgres-meta'
|
||||
|
||||
interface DeleteHookModalProps {
|
||||
visible: boolean
|
||||
selectedHook?: PostgresTrigger
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const DeleteHookModal = ({ selectedHook, visible, onClose }: DeleteHookModalProps) => {
|
||||
const { ui } = useStore()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { id, name, schema } = selectedHook ?? {}
|
||||
|
||||
const { project } = useProjectContext()
|
||||
const { mutateAsync: deleteDatabaseTrigger } = useDatabaseTriggerDeleteMutation()
|
||||
|
||||
async function handleDelete() {
|
||||
if (!project) {
|
||||
return console.error('Project ref is required')
|
||||
}
|
||||
if (!id) {
|
||||
return ui.setNotification({ category: 'error', message: 'Unable find selected hook' })
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
await deleteDatabaseTrigger({
|
||||
id,
|
||||
projectRef: project.ref,
|
||||
connectionString: project.connectionString,
|
||||
})
|
||||
ui.setNotification({ category: 'success', message: `Successfully deleted ${name}` })
|
||||
onClose()
|
||||
} catch (error: any) {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: `Failed to delete ${name}: ${error.message}`,
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TextConfirmModal
|
||||
visible={visible}
|
||||
size="medium"
|
||||
onCancel={() => onClose()}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete database webhook"
|
||||
loading={loading}
|
||||
confirmLabel={`Delete ${name}`}
|
||||
confirmPlaceholder="Type in name of webhook"
|
||||
confirmString={name || ''}
|
||||
text={`This will delete the webhook "${name}" from the schema "${schema}".`}
|
||||
alert="You cannot recover this webhook once it is deleted!"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(DeleteHookModal)
|
||||
@@ -0,0 +1,454 @@
|
||||
import Image from 'next/image'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { PostgresTrigger } from '@supabase/postgres-meta'
|
||||
import { Button, SidePanel, Form, Input, Listbox, Checkbox, Radio, Badge, Modal } from 'ui'
|
||||
|
||||
import { useStore } from 'hooks'
|
||||
import { tryParseJson, uuidv4 } from 'lib/helpers'
|
||||
import HTTPRequestFields from './HTTPRequestFields'
|
||||
import { isValidHttpUrl } from './Hooks.utils'
|
||||
import { AVAILABLE_WEBHOOK_TYPES, HOOK_EVENTS } from './Hooks.constants'
|
||||
import { FormSection, FormSectionLabel, FormSectionContent } from 'components/ui/Forms'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { useDatabaseTriggerCreateMutation } from 'data/database-triggers/database-trigger-create-mutation'
|
||||
import { useDatabaseTriggerUpdateMutation } from 'data/database-triggers/database-trigger-update-transaction-mutation'
|
||||
import ConfirmationModal from 'components/ui/ConfirmationModal'
|
||||
|
||||
export interface EditHookPanelProps {
|
||||
visible: boolean
|
||||
selectedHook?: PostgresTrigger
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export type HTTPArgument = { id: string; name: string; value: string }
|
||||
|
||||
const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) => {
|
||||
// [Joshen] Need to change to use RQ once Alaister's PR goes in
|
||||
const { meta, ui } = useStore()
|
||||
const submitRef: any = useRef()
|
||||
const [isEdited, setIsEdited] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isClosingPanel, setIsClosingPanel] = useState(false)
|
||||
|
||||
// [Joshen] There seems to be some bug between Checkbox.Group within the Form component
|
||||
// hence why this external state as a temporary workaround
|
||||
const [events, setEvents] = useState<string[]>([])
|
||||
const [eventsError, setEventsError] = useState<string>()
|
||||
|
||||
// For HTTP request
|
||||
const [httpHeaders, setHttpHeaders] = useState<HTTPArgument[]>([])
|
||||
const [httpParameters, setHttpParameters] = useState<HTTPArgument[]>([])
|
||||
|
||||
const { project } = useProjectContext()
|
||||
const { mutateAsync: createDatabaseTrigger } = useDatabaseTriggerCreateMutation()
|
||||
const { mutateAsync: updateDatabaseTrigger } = useDatabaseTriggerUpdateMutation()
|
||||
|
||||
const tables = meta.tables.list()
|
||||
const initialValues = {
|
||||
name: selectedHook?.name ?? '',
|
||||
table_id: selectedHook?.table_id ?? '',
|
||||
function_name: selectedHook?.function_name ?? 'http_request',
|
||||
http_url: selectedHook?.function_args?.[0] ?? '',
|
||||
http_method: selectedHook?.function_args?.[1] ?? 'GET',
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setIsEdited(false)
|
||||
setIsSubmitting(false)
|
||||
setIsClosingPanel(false)
|
||||
|
||||
// Reset form fields outside of the Form context
|
||||
if (selectedHook !== undefined) {
|
||||
setEvents(selectedHook.events)
|
||||
|
||||
const [url, method, headers, parameters] = selectedHook.function_args
|
||||
const formattedHeaders = tryParseJson(headers) || {}
|
||||
setHttpHeaders(
|
||||
Object.keys(formattedHeaders).map((key) => {
|
||||
return { id: uuidv4(), name: key, value: formattedHeaders[key] }
|
||||
})
|
||||
)
|
||||
const formattedParameters = tryParseJson(parameters) || {}
|
||||
setHttpParameters(
|
||||
Object.keys(formattedParameters).map((key) => {
|
||||
return { id: uuidv4(), name: key, value: formattedParameters[key] }
|
||||
})
|
||||
)
|
||||
} else {
|
||||
setEvents([])
|
||||
setHttpHeaders([{ id: uuidv4(), name: 'Content-type', value: 'application/json' }])
|
||||
setHttpParameters([{ id: uuidv4(), name: '', value: '' }])
|
||||
}
|
||||
}
|
||||
}, [visible, selectedHook])
|
||||
|
||||
const onClosePanel = () => {
|
||||
if (isEdited) setIsClosingPanel(true)
|
||||
else onClose()
|
||||
}
|
||||
|
||||
const onUpdateSelectedEvents = (event: string) => {
|
||||
if (events.includes(event)) {
|
||||
setEvents(events.filter((e) => e !== event))
|
||||
} else {
|
||||
setEvents(events.concat(event))
|
||||
}
|
||||
setEventsError(undefined)
|
||||
}
|
||||
|
||||
const validate = (values: any) => {
|
||||
const errors: any = {}
|
||||
|
||||
if (!values.name) {
|
||||
errors['name'] = 'Please provide a name for your webhook'
|
||||
}
|
||||
if (!values.table_id) {
|
||||
errors['table_id'] = 'Please select a table for which your webhook will trigger from'
|
||||
}
|
||||
|
||||
if (values.function_name === 'http_request') {
|
||||
// For HTTP requests
|
||||
if (!values.http_url) {
|
||||
errors['http_url'] = 'Please provide a URL'
|
||||
} else if (!values.http_url.startsWith('http')) {
|
||||
errors['http_url'] = 'Please include HTTP/HTTPs to your URL'
|
||||
} else if (!isValidHttpUrl(values.http_url)) {
|
||||
errors['http_url'] = 'Please provide a valid URL'
|
||||
}
|
||||
} else if (values.function_name === 'supabase_function') {
|
||||
// For Supabase Edge Functions
|
||||
}
|
||||
|
||||
if (JSON.stringify(values) !== JSON.stringify(initialValues)) setIsEdited(true)
|
||||
return errors
|
||||
}
|
||||
|
||||
const onSubmit = async (values: any) => {
|
||||
if (!project?.ref) {
|
||||
return console.error('Project ref is required')
|
||||
}
|
||||
if (events.length === 0) {
|
||||
return setEventsError('Please select at least one event')
|
||||
}
|
||||
|
||||
const selectedTable = meta.tables.byId(values.table_id)
|
||||
if (!selectedTable) {
|
||||
return ui.setNotification({ category: 'error', message: 'Unable to find selected table' })
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
events,
|
||||
activation: 'AFTER',
|
||||
orientation: 'ROW',
|
||||
enabled_mode: 'ORIGIN',
|
||||
name: values.name,
|
||||
table: selectedTable.name,
|
||||
schema: selectedTable.schema,
|
||||
table_id: values.table_id,
|
||||
function_name: values.function_name,
|
||||
function_schema: 'supabase_functions',
|
||||
function_args: [],
|
||||
}
|
||||
|
||||
if (values.function_name === 'http_request') {
|
||||
const serviceTimeoutMs = '1000'
|
||||
const headers = httpHeaders
|
||||
.filter((header) => header.name && header.value)
|
||||
.reduce((a: any, b: any) => {
|
||||
a[b.name] = b.value
|
||||
return a
|
||||
}, {})
|
||||
const parameters = httpParameters
|
||||
.filter((param) => param.name && param.value)
|
||||
.reduce((a: any, b: any) => {
|
||||
a[b.name] = b.value
|
||||
return a
|
||||
}, {})
|
||||
payload.function_args = [
|
||||
values.http_url,
|
||||
values.http_method,
|
||||
JSON.stringify(headers),
|
||||
JSON.stringify(parameters),
|
||||
serviceTimeoutMs,
|
||||
]
|
||||
} else if (values.function_name === 'supabase_function') {
|
||||
payload.function_args = []
|
||||
}
|
||||
|
||||
if (selectedHook === undefined) {
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
await createDatabaseTrigger({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
payload,
|
||||
})
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
message: `Successfully created new webhook "${values.name}"`,
|
||||
})
|
||||
onClose()
|
||||
} catch (error: any) {
|
||||
ui.setNotification({
|
||||
error,
|
||||
category: 'error',
|
||||
message: `Failed to create webhook: ${error.message}`,
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
await updateDatabaseTrigger({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
originalTrigger: selectedHook,
|
||||
updatedTrigger: payload,
|
||||
})
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
message: `Successfully updated webhook "${values.name}"`,
|
||||
})
|
||||
onClose()
|
||||
} catch (error: any) {
|
||||
ui.setNotification({
|
||||
error,
|
||||
category: 'error',
|
||||
message: `Failed to update webhook: ${error.message}`,
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidePanel
|
||||
size="xlarge"
|
||||
visible={visible}
|
||||
header={
|
||||
selectedHook === undefined ? (
|
||||
'Create a new database webhook'
|
||||
) : (
|
||||
<>
|
||||
Update webhook <code className="text-sm">{selectedHook.name}</code>
|
||||
</>
|
||||
)
|
||||
}
|
||||
className="hooks-sidepanel mr-0 transform transition-all duration-300 ease-in-out"
|
||||
onConfirm={() => {}}
|
||||
onCancel={() => onClosePanel()}
|
||||
customFooter={
|
||||
<div className="flex w-full justify-end space-x-3 border-t border-scale-500 px-3 py-4">
|
||||
<Button
|
||||
size="tiny"
|
||||
type="default"
|
||||
htmlType="button"
|
||||
onClick={onClosePanel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="tiny"
|
||||
type="primary"
|
||||
htmlType="button"
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
onClick={() => submitRef?.current?.click()}
|
||||
>
|
||||
{selectedHook === undefined ? 'Create webhook' : 'Update webhook'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Form validateOnBlur initialValues={initialValues} onSubmit={onSubmit} validate={validate}>
|
||||
{() => {
|
||||
return (
|
||||
<div>
|
||||
<FormSection
|
||||
header={<FormSectionLabel className="lg:!col-span-4">General</FormSectionLabel>}
|
||||
>
|
||||
<FormSectionContent loading={false} className="lg:!col-span-8">
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
label="Name"
|
||||
descriptionText="Do not use spaces/whitespaces"
|
||||
/>
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
<SidePanel.Separator />
|
||||
<FormSection
|
||||
header={
|
||||
<FormSectionLabel
|
||||
className="lg:!col-span-4"
|
||||
description={
|
||||
<p className="text-sm text-scale-1000">
|
||||
Select which table and events will trigger your function hook
|
||||
</p>
|
||||
}
|
||||
>
|
||||
Conditions to fire hook
|
||||
</FormSectionLabel>
|
||||
}
|
||||
>
|
||||
<FormSectionContent loading={false} className="lg:!col-span-8">
|
||||
<Listbox
|
||||
size="medium"
|
||||
id="table_id"
|
||||
name="table_id"
|
||||
label="Table"
|
||||
descriptionText="This is the table the trigger will watch for changes. You can only select 1 table for a trigger."
|
||||
>
|
||||
<Listbox.Option
|
||||
key={'table-no-selection'}
|
||||
id={'table-no-selection'}
|
||||
label={'---'}
|
||||
value={'no-selection'}
|
||||
>
|
||||
---
|
||||
</Listbox.Option>
|
||||
{tables.map((table) => (
|
||||
<Listbox.Option
|
||||
key={table.id}
|
||||
id={table.id.toString()}
|
||||
value={table.id}
|
||||
label={table.name}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<p>{table.name}</p>
|
||||
<p className="text-scale-1100">{table.schema}</p>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox>
|
||||
<Checkbox.Group
|
||||
id="events"
|
||||
name="events"
|
||||
label="Events"
|
||||
error={eventsError}
|
||||
descriptionText="These are the events that are watched by the webhook, only the events selected above will fire the webhook on the table you've selected."
|
||||
>
|
||||
{HOOK_EVENTS.map((event) => (
|
||||
<Checkbox
|
||||
key={event.value}
|
||||
value={event.value}
|
||||
label={event.label}
|
||||
description={event.description}
|
||||
checked={events.includes(event.value)}
|
||||
onChange={() => onUpdateSelectedEvents(event.value)}
|
||||
/>
|
||||
))}
|
||||
</Checkbox.Group>
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
<SidePanel.Separator />
|
||||
<FormSection
|
||||
header={
|
||||
<FormSectionLabel className="lg:!col-span-4">
|
||||
Hook configuration
|
||||
</FormSectionLabel>
|
||||
}
|
||||
>
|
||||
<FormSectionContent loading={false} className="lg:!col-span-8">
|
||||
<Radio.Group
|
||||
id="function_name"
|
||||
name="function_name"
|
||||
label="Type of hook"
|
||||
type="cards"
|
||||
>
|
||||
{AVAILABLE_WEBHOOK_TYPES.map((webhook) => (
|
||||
<Radio
|
||||
disabled={webhook.disabled}
|
||||
key={webhook.value}
|
||||
id={webhook.value}
|
||||
value={webhook.value}
|
||||
label=""
|
||||
beforeLabel={
|
||||
<div className="flex items-center space-x-5">
|
||||
<Image src={webhook.icon} layout="fixed" width="32" height="32" />
|
||||
<div className="flex-col space-y-0">
|
||||
<div className="flex space-x-2">
|
||||
<p className="text-scale-1200">{webhook.label}</p>
|
||||
{webhook.disabled ? (
|
||||
<Badge color="amber">Coming soon</Badge>
|
||||
) : (
|
||||
<Badge color="green">Alpha</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-scale-1000">{webhook.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
<SidePanel.Separator />
|
||||
|
||||
<HTTPRequestFields
|
||||
httpHeaders={httpHeaders}
|
||||
httpParameters={httpParameters}
|
||||
onAddHeader={() =>
|
||||
setHttpHeaders(httpHeaders.concat({ id: uuidv4(), name: '', value: '' }))
|
||||
}
|
||||
onUpdateHeader={(idx, property, value) =>
|
||||
setHttpHeaders(
|
||||
httpHeaders.map((header, i) => {
|
||||
if (idx === i) return { ...header, [property]: value }
|
||||
else return header
|
||||
})
|
||||
)
|
||||
}
|
||||
onRemoveHeader={(idx) => setHttpHeaders(httpHeaders.filter((_, i) => idx !== i))}
|
||||
onAddParameter={() =>
|
||||
setHttpParameters(httpParameters.concat({ id: uuidv4(), name: '', value: '' }))
|
||||
}
|
||||
onUpdateParameter={(idx, property, value) =>
|
||||
setHttpParameters(
|
||||
httpParameters.map((param, i) => {
|
||||
if (idx === i) return { ...param, [property]: value }
|
||||
else return param
|
||||
})
|
||||
)
|
||||
}
|
||||
onRemoveParameter={(idx) =>
|
||||
setHttpParameters(httpParameters.filter((_, i) => idx !== i))
|
||||
}
|
||||
/>
|
||||
|
||||
<button ref={submitRef} type="submit" className="hidden" />
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Form>
|
||||
</SidePanel>
|
||||
<ConfirmationModal
|
||||
visible={isClosingPanel}
|
||||
header="Confirm to close"
|
||||
buttonLabel="Confirm"
|
||||
onSelectCancel={() => setIsClosingPanel(false)}
|
||||
onSelectConfirm={() => {
|
||||
setIsClosingPanel(false)
|
||||
setIsEdited(false)
|
||||
onClose()
|
||||
}}
|
||||
children={
|
||||
<Modal.Content>
|
||||
<p className="py-4 text-sm text-scale-1100">
|
||||
There are unsaved changes. Are you sure you want to close the panel? Your changes will
|
||||
be lost.
|
||||
</p>
|
||||
</Modal.Content>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditHookPanel
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Button, Listbox, Input, SidePanel, IconTrash, IconPlus } from 'ui'
|
||||
import { FormSection, FormSectionLabel, FormSectionContent } from 'components/ui/Forms'
|
||||
import { HTTPArgument } from './EditHookPanel'
|
||||
|
||||
interface HTTPRequestFieldsProps {
|
||||
httpHeaders: HTTPArgument[]
|
||||
httpParameters: HTTPArgument[]
|
||||
onAddHeader: () => void
|
||||
onUpdateHeader: (idx: number, property: string, value: string) => void
|
||||
onRemoveHeader: (idx: number) => void
|
||||
onAddParameter: () => void
|
||||
onUpdateParameter: (idx: number, property: string, value: string) => void
|
||||
onRemoveParameter: (idx: number) => void
|
||||
}
|
||||
|
||||
const HTTPRequestFields = ({
|
||||
httpHeaders = [],
|
||||
httpParameters = [],
|
||||
onAddHeader,
|
||||
onUpdateHeader,
|
||||
onRemoveHeader,
|
||||
onAddParameter,
|
||||
onUpdateParameter,
|
||||
onRemoveParameter,
|
||||
}: HTTPRequestFieldsProps) => {
|
||||
return (
|
||||
<>
|
||||
<FormSection
|
||||
header={<FormSectionLabel className="lg:!col-span-4">HTTP Request</FormSectionLabel>}
|
||||
>
|
||||
<FormSectionContent loading={false} className="lg:!col-span-8">
|
||||
<Listbox id="http_method" name="http_method" size="medium" label="Method">
|
||||
<Listbox.Option id="GET" value="GET" label="GET">
|
||||
GET
|
||||
</Listbox.Option>
|
||||
<Listbox.Option id="POST" value="POST" label="POST">
|
||||
POST
|
||||
</Listbox.Option>
|
||||
</Listbox>
|
||||
<Input
|
||||
id="http_url"
|
||||
name="http_url"
|
||||
label="URL"
|
||||
placeholder="http://api.com/path/resource"
|
||||
descriptionText="URL of the HTTP request. Must include HTTP/HTTPS"
|
||||
/>
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
<SidePanel.Separator />
|
||||
<FormSection
|
||||
header={<FormSectionLabel className="lg:!col-span-4">HTTP Headers</FormSectionLabel>}
|
||||
>
|
||||
<FormSectionContent loading={false} className="lg:!col-span-8">
|
||||
<div className="space-y-2">
|
||||
{httpHeaders.map((header, idx: number) => (
|
||||
<div key={header.id} className="flex items-center space-x-2">
|
||||
<Input
|
||||
value={header.name}
|
||||
size="small"
|
||||
className="w-full"
|
||||
placeholder="Header name"
|
||||
onChange={(event: any) => onUpdateHeader(idx, 'name', event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
value={header.value}
|
||||
size="small"
|
||||
className="w-full"
|
||||
placeholder="Header value"
|
||||
onChange={(event: any) => onUpdateHeader(idx, 'value', event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="default"
|
||||
size="medium"
|
||||
icon={<IconTrash size="tiny" />}
|
||||
className="px-[10px] py-[9px]"
|
||||
onClick={() => onRemoveHeader(idx)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<Button type="dashed" size="tiny" icon={<IconPlus />} onClick={onAddHeader}>
|
||||
Add a new header
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
<SidePanel.Separator />
|
||||
<FormSection
|
||||
header={<FormSectionLabel className="lg:!col-span-4">HTTP Parameters</FormSectionLabel>}
|
||||
>
|
||||
<FormSectionContent loading={false} className="lg:!col-span-8">
|
||||
<div className="space-y-2">
|
||||
{httpParameters.map((parameter, idx: number) => (
|
||||
<div key={parameter.id} className="flex items-center space-x-2">
|
||||
<Input
|
||||
size="small"
|
||||
value={parameter.name}
|
||||
className="w-full"
|
||||
placeholder="Parameter name"
|
||||
onChange={(event: any) => onUpdateParameter(idx, 'name', event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
size="small"
|
||||
value={parameter.value}
|
||||
className="w-full"
|
||||
placeholder="Parameter value"
|
||||
onChange={(event: any) => onUpdateParameter(idx, 'value', event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="default"
|
||||
size="medium"
|
||||
icon={<IconTrash size="tiny" />}
|
||||
className="px-[10px] py-[9px]"
|
||||
onClick={() => onRemoveParameter(idx)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<Button type="dashed" size="tiny" icon={<IconPlus />} onClick={onAddParameter}>
|
||||
Add a new parameter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default HTTPRequestFields
|
||||
@@ -0,0 +1,51 @@
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
|
||||
export const ENABLED_MODE_OPTIONS = [
|
||||
{ label: 'Origin', value: 'ORIGIN', description: 'This is the default behaviour' },
|
||||
{
|
||||
label: 'Replica',
|
||||
value: 'REPLICA',
|
||||
description: 'Will only fire if the session is in “replica” mode',
|
||||
},
|
||||
{
|
||||
label: 'Always',
|
||||
value: 'ALWAYS',
|
||||
description: 'Will fire regardless of the current replication role',
|
||||
},
|
||||
{ label: 'Disabled', value: 'DISABLED', description: 'Will not fire' },
|
||||
]
|
||||
|
||||
export const HOOK_EVENTS = [
|
||||
{
|
||||
label: 'Insert',
|
||||
value: 'INSERT',
|
||||
description: 'Any insert operation on the table',
|
||||
},
|
||||
{
|
||||
label: 'Update',
|
||||
value: 'UPDATE',
|
||||
description: 'Any update operation, of any column in the table',
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
value: 'DELETE',
|
||||
description: 'Any deletion of a record',
|
||||
},
|
||||
]
|
||||
|
||||
export const AVAILABLE_WEBHOOK_TYPES = [
|
||||
{
|
||||
disabled: false,
|
||||
value: 'http_request',
|
||||
icon: `${BASE_PATH}/img/function-providers/http-request.png`,
|
||||
label: 'HTTP Request',
|
||||
description: 'Send an HTTP request to any URL.',
|
||||
},
|
||||
{
|
||||
disabled: true,
|
||||
value: 'supabase_function',
|
||||
icon: `${BASE_PATH}/img/function-providers/supabase-severless-function.png`,
|
||||
label: 'Supabase Edge Functions',
|
||||
description: 'Choose a Supabase edge function to run.',
|
||||
},
|
||||
]
|
||||
@@ -5,8 +5,10 @@ import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { Badge, Button, Dropdown, IconMoreVertical, IconTrash, IconEdit3 } from 'ui'
|
||||
|
||||
import { checkPermissions, useStore } from 'hooks'
|
||||
import { checkPermissions } from 'hooks'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { useDatabaseHooks } from 'data/database-triggers/database-triggers-query'
|
||||
|
||||
interface Props {
|
||||
schema: string
|
||||
@@ -21,12 +23,15 @@ const HookList: FC<Props> = ({
|
||||
editHook = () => {},
|
||||
deleteHook = () => {},
|
||||
}) => {
|
||||
const { meta } = useStore()
|
||||
const hooks = meta.hooks.list()
|
||||
const filteredHooks = hooks.filter((x: any) =>
|
||||
includes(x.name.toLowerCase(), filterString.toLowerCase())
|
||||
const { project } = useProjectContext()
|
||||
const { data: hooks } = useDatabaseHooks({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
const filteredHooks = (hooks ?? []).filter(
|
||||
(x: any) => includes(x.name.toLowerCase(), filterString.toLowerCase()) && x.schema === schema
|
||||
)
|
||||
const _hooks = filteredHooks.filter((x: any) => x.schema == schema)
|
||||
const canUpdateWebhook = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'triggers')
|
||||
|
||||
function onEdit(trigger: any) {
|
||||
@@ -39,7 +44,7 @@ const HookList: FC<Props> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{_hooks.map((x: any) => (
|
||||
{filteredHooks.map((x: any) => (
|
||||
<Table.tr key={x.id}>
|
||||
<Table.td className="space-x-2">
|
||||
<p>{x.name}</p>
|
||||
|
||||
@@ -1,81 +1,65 @@
|
||||
import { FC } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { includes, uniqBy, map as lodashMap } from 'lodash'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { Button, Input, IconSearch, IconLoader } from 'ui'
|
||||
import { Button, Input, IconSearch, IconExternalLink } from 'ui'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
import { useStore, checkPermissions } from 'hooks'
|
||||
import { checkPermissions } from 'hooks'
|
||||
import SchemaTable from './SchemaTable'
|
||||
import AlphaPreview from 'components/to-be-cleaned/AlphaPreview'
|
||||
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
|
||||
|
||||
function isHooksEnabled(schemas: any): boolean {
|
||||
return schemas.some((schema: any) => schema.name === 'supabase_functions')
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { useDatabaseHooks } from 'data/database-triggers/database-triggers-query'
|
||||
import { FormHeader } from 'components/ui/Forms'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import ClientLoadingError from 'components/ui/ClientLoadingError'
|
||||
import NoSearchResults from 'components/ui/NoSearchResults'
|
||||
|
||||
export interface HooksListProps {
|
||||
createHook: () => void
|
||||
editHook: (hook: any) => void
|
||||
deleteHook: (hook: any) => void
|
||||
}
|
||||
|
||||
const HooksList: FC<any> = ({
|
||||
hooksEnabled,
|
||||
filterString,
|
||||
setFilterString = () => {},
|
||||
const HooksList = ({
|
||||
createHook = () => {},
|
||||
editHook = () => {},
|
||||
deleteHook = () => {},
|
||||
enableHooks = () => {},
|
||||
}) => {
|
||||
const { meta } = useStore()
|
||||
const hooks = meta.hooks.list()
|
||||
const schemas = meta.schemas.list()
|
||||
const filteredHooks = hooks.filter((x: any) =>
|
||||
}: HooksListProps) => {
|
||||
const { project } = useProjectContext()
|
||||
const {
|
||||
data: hooks,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useDatabaseHooks({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
const [filterString, setFilterString] = useState<string>('')
|
||||
|
||||
const filteredHooks = (hooks || []).filter((x: any) =>
|
||||
includes(x.name.toLowerCase(), filterString.toLowerCase())
|
||||
)
|
||||
const filteredHookSchemas = lodashMap(uniqBy(filteredHooks, 'schema'), 'schema')
|
||||
const canCreateWebhooks = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'triggers')
|
||||
|
||||
const isEnabled = hooksEnabled || isHooksEnabled(schemas)
|
||||
|
||||
if (!isEnabled) {
|
||||
return (
|
||||
<>
|
||||
{schemas.length === 0 ? (
|
||||
<span className="px-6 py-4 text-scale-1100">Checking if hooks are enabled...</span>
|
||||
) : (
|
||||
<ProductEmptyState
|
||||
title="Hooks"
|
||||
ctaButtonLabel="Enable Hooks"
|
||||
onClickCta={() => enableHooks()}
|
||||
disabled={!canCreateWebhooks}
|
||||
disabledMessage="You need additional permissions to enable hooks"
|
||||
>
|
||||
<AlphaPreview />
|
||||
<p className="text-sm text-scale-1100">Enable hooks on your project.</p>
|
||||
</ProductEmptyState>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (meta.hooks.isLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center space-x-2">
|
||||
<IconLoader className="animate-spin" size={14} />
|
||||
<p>Loading hooks...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (meta.hooks.hasError) {
|
||||
return (
|
||||
<p className="px-6 py-4">
|
||||
<p>Error connecting to API</p>
|
||||
<p>{`${meta.hooks.error?.message ?? 'Unknown error'}`}</p>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{hooks.length == 0 ? (
|
||||
{isLoading ? (
|
||||
<div className="py-4 space-y-2">
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader className="w-3/4" />
|
||||
<ShimmeringLoader className="w-1/2" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<ClientLoadingError
|
||||
projectRef={project?.ref ?? ''}
|
||||
description="Failed to retrieve database webhooks"
|
||||
/>
|
||||
) : (hooks || []).length == 0 ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<ProductEmptyState
|
||||
title="Database Webhooks"
|
||||
@@ -92,56 +76,71 @@ const HooksList: FC<any> = ({
|
||||
</ProductEmptyState>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full space-y-4 py-4">
|
||||
<div className="flex items-center justify-between px-6">
|
||||
<Input
|
||||
placeholder="Filter by name"
|
||||
size="small"
|
||||
icon={<IconSearch size="tiny" />}
|
||||
value={filterString}
|
||||
onChange={(e) => setFilterString(e.target.value)}
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormHeader
|
||||
title="Database Webhooks"
|
||||
description="Send real-time data from your database to another system whenever a table event occurs"
|
||||
/>
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Button disabled={!canCreateWebhooks} onClick={() => createHook()}>
|
||||
Create a new hook
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
{!canCreateWebhooks && (
|
||||
<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 webhooks
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
{filteredHooks.length <= 0 && (
|
||||
<div className="mx-auto flex max-w-lg items-center justify-center space-x-3 rounded border p-6 shadow-md dark:border-dark">
|
||||
<p>No results match your filter query</p>
|
||||
<Button type="outline" onClick={() => setFilterString('')}>
|
||||
Reset filter
|
||||
</Button>
|
||||
<div className="w-full space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Input
|
||||
placeholder="Search for a webhook"
|
||||
size="small"
|
||||
icon={<IconSearch size="tiny" />}
|
||||
value={filterString}
|
||||
onChange={(e) => setFilterString(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Link href="https://supabase.com/docs/guides/database/webhooks">
|
||||
<a target="_blank">
|
||||
<Button type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
|
||||
Documentation
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Button disabled={!canCreateWebhooks} onClick={() => createHook()}>
|
||||
Create a new hook
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
{!canCreateWebhooks && (
|
||||
<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 webhooks
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{filteredHookSchemas.map((schema: any) => (
|
||||
<SchemaTable
|
||||
key={schema}
|
||||
filterString={filterString}
|
||||
schema={schema}
|
||||
editHook={editHook}
|
||||
deleteHook={deleteHook}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{filteredHooks.length <= 0 && (
|
||||
<NoSearchResults
|
||||
searchString={filterString}
|
||||
onResetFilter={() => setFilterString('')}
|
||||
/>
|
||||
)}
|
||||
{filteredHookSchemas.map((schema: any) => (
|
||||
<SchemaTable
|
||||
key={schema}
|
||||
filterString={filterString}
|
||||
schema={schema}
|
||||
editHook={editHook}
|
||||
deleteHook={deleteHook}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -20,13 +20,13 @@ const SchemaTable: FC<Props> = ({
|
||||
return (
|
||||
<div key={schema} className="">
|
||||
<div className="sticky top-0 backdrop-blur backdrop-filter">
|
||||
<div className="flex items-baseline space-x-1 py-2 px-6">
|
||||
<div className="flex items-baseline space-x-1 py-2">
|
||||
<h5 className="text-scale-1000">schema</h5>
|
||||
<h4>{schema}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<Table
|
||||
className="table-fixed px-6"
|
||||
className="table-fixed"
|
||||
head={
|
||||
<>
|
||||
<Table.th key="name" className="space-x-4">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button, Input, Toggle, IconSearch, IconAlertCircle } from 'ui'
|
||||
import { checkPermissions, useStore } from 'hooks'
|
||||
import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmModal'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import NoSearchResults from 'components/to-be-cleaned/NoSearchResults'
|
||||
import NoSearchResults from 'components/ui/NoSearchResults'
|
||||
import InformationBox from 'components/ui/InformationBox'
|
||||
|
||||
interface Props {
|
||||
@@ -83,7 +83,7 @@ const PublicationsList: FC<Props> = ({ onSelectPublication = () => {} }) => {
|
||||
</div>
|
||||
</div>
|
||||
{publications.length === 0 ? (
|
||||
<NoSearchResults />
|
||||
<NoSearchResults searchString={filterString} onResetFilter={() => setFilterString('')} />
|
||||
) : (
|
||||
<div className="overflow-hidden rounded">
|
||||
<Table
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { partition } from 'lodash'
|
||||
import { FC, useState, useEffect } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { PostgresRole } from '@supabase/postgres-meta'
|
||||
@@ -13,12 +13,9 @@ import RoleRow from './RoleRow'
|
||||
import { SUPABASE_ROLES } from './Roles.constants'
|
||||
import CreateRolePanel from './CreateRolePanel'
|
||||
import DeleteRoleModal from './DeleteRoleModal'
|
||||
import NoSearchResults from 'components/ui/NoSearchResults'
|
||||
|
||||
interface Props {
|
||||
onSelectRole: (role: any) => void
|
||||
}
|
||||
|
||||
const RolesList: FC<Props> = ({}) => {
|
||||
const RolesList = ({}) => {
|
||||
const { meta } = useStore()
|
||||
|
||||
const [maxConnectionLimit, setMaxConnectionLimit] = useState(0)
|
||||
@@ -66,7 +63,7 @@ const RolesList: FC<Props> = ({}) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 -mt-2 flex items-center justify-between">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Input
|
||||
size="small"
|
||||
@@ -216,12 +213,7 @@ const RolesList: FC<Props> = ({}) => {
|
||||
</div>
|
||||
|
||||
{filterString.length > 0 && filteredRoles.length === 0 && (
|
||||
<div className="bg-scale-100 dark:bg-scale-300 border border-scale-300 dark:border-scale-500 px-6 py-4 rounded space-y-1">
|
||||
<p className="text-sm text-scale-1100">No results found</p>
|
||||
<p className="text-sm text-scale-1000">
|
||||
Your search for "{filterString}" did not return any results
|
||||
</p>
|
||||
</div>
|
||||
<NoSearchResults searchString={filterString} onResetFilter={() => setFilterString('')} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { FC } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { Button, IconChevronLeft } from 'ui'
|
||||
import Divider from 'components/ui/Divider'
|
||||
|
||||
import Panel from 'components/ui/Panel'
|
||||
|
||||
interface Props {
|
||||
selectedRole: any
|
||||
onSelectBack: () => void
|
||||
}
|
||||
|
||||
const RolesSettings: FC<Props> = ({ selectedRole, onSelectBack = () => {} }) => {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
<div className="">
|
||||
<nav className="-mb-px flex">
|
||||
<div className="group mr-4 inline-flex items-center p-1 ">
|
||||
<Button
|
||||
type="outline"
|
||||
onClick={() => onSelectBack()}
|
||||
icon={<IconChevronLeft size="small" />}
|
||||
style={{ padding: '5px' }}
|
||||
/>
|
||||
</div>
|
||||
<a
|
||||
href="#"
|
||||
className="
|
||||
group mx-4
|
||||
inline-flex items-center border-b-2 border-green-500 p-1 text-sm font-medium text-typography-body-strong-light focus:outline-none dark:text-typography-body-strong-dark "
|
||||
aria-current="page"
|
||||
>
|
||||
<span>Permissions</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Panel>
|
||||
<Panel.Content className="flex w-full items-center justify-between">
|
||||
<p className="text-sm">Super user</p>
|
||||
<code className="text-sm">{selectedRole.is_superuser ? 'true' : 'false'}</code>
|
||||
</Panel.Content>
|
||||
<Divider light />
|
||||
<Panel.Content className="flex w-full items-center justify-between">
|
||||
<p className="text-sm">User can login</p>
|
||||
<code className="text-sm">{selectedRole.can_login ? 'true' : 'false'}</code>
|
||||
</Panel.Content>
|
||||
<Divider light />
|
||||
<Panel.Content className="flex w-full items-center justify-between">
|
||||
<p className="text-sm">User can create databases</p>
|
||||
<code className="text-sm">{selectedRole.can_create_db ? 'true' : 'false'}</code>
|
||||
</Panel.Content>
|
||||
<Divider light />
|
||||
<Panel.Content className="flex w-full items-center justify-between">
|
||||
<p className="text-sm">
|
||||
User can initiate streaming replication and put the system in and out of backup mode
|
||||
</p>
|
||||
<code className="text-sm">{selectedRole.is_replication_role ? 'true' : 'false'}</code>
|
||||
</Panel.Content>
|
||||
<Divider light />
|
||||
<Panel.Content className="flex w-full items-center justify-between">
|
||||
<p className="text-sm">User bypasses every row level security policy</p>
|
||||
<code className="text-sm">{selectedRole.can_bypass_rls ? 'true' : 'false'}</code>
|
||||
</Panel.Content>
|
||||
</Panel>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(RolesSettings)
|
||||
@@ -20,6 +20,7 @@ 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'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
|
||||
interface Props {
|
||||
selectedSchema: string
|
||||
@@ -39,6 +40,7 @@ const TableList: FC<Props> = ({
|
||||
onOpenTable = () => {},
|
||||
}) => {
|
||||
const { meta } = useStore()
|
||||
const { isLoading } = meta.tables
|
||||
const [filterString, setFilterString] = useState<string>('')
|
||||
const canUpdateTables = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables')
|
||||
|
||||
@@ -150,7 +152,14 @@ const TableList: FC<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{tables.length === 0 ? (
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-4 space-y-2">
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader className="w-3/4" />
|
||||
<ShimmeringLoader className="w-1/2" />
|
||||
</div>
|
||||
) : tables.length === 0 ? (
|
||||
<NoSearchResults />
|
||||
) : (
|
||||
<div className="my-4 w-full">
|
||||
|
||||
@@ -21,6 +21,7 @@ import ChooseFunctionForm from './ChooseFunctionForm'
|
||||
import FormEmptyBox from 'components/ui/FormBoxEmpty'
|
||||
import NoTableState from 'components/ui/States/NoTableState'
|
||||
import { useStore } from 'hooks'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
|
||||
class CreateTriggerFormState {
|
||||
id: number | undefined
|
||||
@@ -545,7 +546,7 @@ const ListboxTable: FC = observer(({}) => {
|
||||
addOnBefore={() => (
|
||||
<div className="flex items-center justify-center rounded bg-scale-1200 p-1 text-scale-100 ">
|
||||
<SVG
|
||||
src={'/img/table-editor.svg'}
|
||||
src={`${BASE_PATH}/img/table-editor.svg`}
|
||||
style={{ width: `16px`, height: `16px`, strokeWidth: '1px' }}
|
||||
preProcessor={(code) =>
|
||||
code.replace(/svg/, 'svg class="m-auto text-color-inherit"')
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { WrapperMeta } from './Wrappers.types'
|
||||
|
||||
export const WRAPPERS: WrapperMeta[] = [
|
||||
@@ -5,7 +6,7 @@ export const WRAPPERS: WrapperMeta[] = [
|
||||
name: 'stripe_wrapper',
|
||||
handlerName: 'stripe_fdw_handler',
|
||||
validatorName: 'stripe_fdw_validator',
|
||||
icon: '/img/icons/stripe-icon.svg',
|
||||
icon: `${BASE_PATH}/img/icons/stripe-icon.svg`,
|
||||
extensionName: 'StripeFdw',
|
||||
label: 'Stripe',
|
||||
docsUrl: 'https://supabase.com/docs/guides/database/wrappers/stripe',
|
||||
@@ -409,7 +410,7 @@ export const WRAPPERS: WrapperMeta[] = [
|
||||
name: 'firebase_wrapper',
|
||||
handlerName: 'firebase_fdw_handler',
|
||||
validatorName: 'firebase_fdw_validator',
|
||||
icon: '/img/icons/firebase-icon.svg',
|
||||
icon: `${BASE_PATH}/img/icons/firebase-icon.svg`,
|
||||
extensionName: 'FirebaseFdw',
|
||||
label: 'Firebase',
|
||||
docsUrl: 'https://supabase.com/docs/guides/database/wrappers/firebase',
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
import { checkPermissions, useParams, useStore } from 'hooks'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
|
||||
const WrappersDisabledState = () => {
|
||||
const { ui, meta } = useStore()
|
||||
@@ -65,8 +66,8 @@ const WrappersDisabledState = () => {
|
||||
backgroundSize: '45%',
|
||||
backgroundPosition: '105% 40%',
|
||||
backgroundImage: ui.isDarkTheme
|
||||
? 'url("/img/wrappers-dark.png")'
|
||||
: 'url("/img/wrappers-light.png")',
|
||||
? `url("${BASE_PATH}/img/wrappers-dark.png")`
|
||||
: `url("${BASE_PATH}/img/wrappers-light.png")`,
|
||||
}}
|
||||
>
|
||||
<div className="w-3/5 space-y-8">
|
||||
|
||||
@@ -2,7 +2,6 @@ import TableList from './Tables/TableList'
|
||||
import ColumnList from './Tables/ColumnList'
|
||||
|
||||
import RolesList from './Roles/RolesList'
|
||||
import RolesSettings from './Roles/RolesSettings'
|
||||
|
||||
import Extensions from './Extensions/Extensions'
|
||||
|
||||
@@ -27,7 +26,6 @@ export {
|
||||
TableList,
|
||||
ColumnList,
|
||||
RolesList,
|
||||
RolesSettings,
|
||||
Extensions,
|
||||
Wrappers,
|
||||
CreateWrapper,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FC } from 'react'
|
||||
import { IconBookOpen, IconGitHub, Button, Badge } from 'ui'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
|
||||
interface Props {
|
||||
language: string
|
||||
@@ -13,7 +14,7 @@ const ClientLibrary: FC<Props> = ({ language, releaseState, officialSupport, doc
|
||||
return (
|
||||
<div className="flex items-start space-x-6">
|
||||
<img
|
||||
src={`/img/libraries/${language.toLowerCase()}-icon.svg`}
|
||||
src={`${BASE_PATH}/img/libraries/${language.toLowerCase()}-icon.svg`}
|
||||
alt={`${language} logo`}
|
||||
width="21"
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { FC } from 'react'
|
||||
import { IconChevronRight } from 'ui'
|
||||
import { useStore } from 'hooks'
|
||||
import Link from 'next/link'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
|
||||
interface Props {
|
||||
framework: string
|
||||
@@ -29,8 +30,12 @@ const ExampleProject: FC<Props> = ({ framework, title, description, url }) => {
|
||||
<div className="mr-4 flex flex-col">
|
||||
<img
|
||||
className="transition-all group-hover:scale-110"
|
||||
src={`/img/libraries/${framework.toLowerCase()}${
|
||||
['expo', 'nextjs'].includes(framework.toLowerCase()) ? (isDarkTheme ? '-dark' : '') : ''
|
||||
src={`${BASE_PATH}/img/libraries/${framework.toLowerCase()}${
|
||||
['expo', 'nextjs'].includes(framework.toLowerCase())
|
||||
? isDarkTheme
|
||||
? '-dark'
|
||||
: ''
|
||||
: ''
|
||||
}-icon.svg`}
|
||||
alt={`${framework} logo`}
|
||||
width={26}
|
||||
|
||||
@@ -47,7 +47,7 @@ const NewProjectPanel: FC<Props> = ({}) => {
|
||||
type="default"
|
||||
icon={
|
||||
<SVG
|
||||
src="/img/table-editor.svg"
|
||||
src={`${router.basePath}/img/table-editor.svg`}
|
||||
style={{ width: `${14}px`, height: `${14}px` }}
|
||||
preProcessor={(code) =>
|
||||
code.replace(/svg/, 'svg class="m-auto text-color-inherit"')
|
||||
@@ -65,7 +65,7 @@ const NewProjectPanel: FC<Props> = ({}) => {
|
||||
type="default"
|
||||
icon={
|
||||
<SVG
|
||||
src="/img/sql-editor.svg"
|
||||
src={`${router.basePath}/img/sql-editor.svg`}
|
||||
style={{ width: `${14}px`, height: `${14}px` }}
|
||||
preProcessor={(code) =>
|
||||
code.replace(/svg/, 'svg class="m-auto text-color-inherit"')
|
||||
|
||||
@@ -17,7 +17,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { checkPermissions, useStore } from 'hooks'
|
||||
import { delete_, patch } from 'lib/common/fetch'
|
||||
import { getURL } from 'lib/helpers'
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { API_URL, BASE_PATH } from 'lib/constants'
|
||||
import Panel from 'components/ui/Panel'
|
||||
import { AddNewPaymentMethodModal } from 'components/interfaces/Billing'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
@@ -158,7 +158,7 @@ const PaymentMethods: FC<Props> = ({
|
||||
<div key={paymentMethod.id} className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-8">
|
||||
<img
|
||||
src={`/img/payment-methods/${paymentMethod.card.brand
|
||||
src={`${BASE_PATH}/img/payment-methods/${paymentMethod.card.brand
|
||||
.replace(' ', '-')
|
||||
.toLowerCase()}.png`}
|
||||
width="32"
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import Image from 'next/image'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { useState, FC, Fragment } from 'react'
|
||||
import { useState, Fragment } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { Badge, Button, Loading, Listbox, IconUser, Modal, IconAlertCircle } from 'ui'
|
||||
import { Badge, Button, Loading, Listbox, IconUser, Modal, IconAlertCircle, IconLoader } from 'ui'
|
||||
|
||||
import { Member, Role } from 'types'
|
||||
import { Member } from 'types'
|
||||
import { useStore, useParams } from 'hooks'
|
||||
import { isInviteExpired, getUserDisplayName } from '../Organization.utils'
|
||||
|
||||
import { useProfileQuery } from 'data/profile/profile-query'
|
||||
import { useOrganizationMemberUpdateMutation } from 'data/organizations/organization-member-update-mutation'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import MemberActions from './MemberActions'
|
||||
import RolesHelperModal from './RolesHelperModal/RolesHelperModal'
|
||||
import { getRolesManagementPermissions } from './TeamSettings.utils'
|
||||
import { isInviteExpired, getUserDisplayName } from '../Organization.utils'
|
||||
import { useProfileQuery } from 'data/profile/profile-query'
|
||||
import { useOrganizationRolesQuery } from 'data/organizations/organization-roles-query'
|
||||
import { useOrganizationDetailQuery } from 'data/organizations/organization-detail-query'
|
||||
import { useOrganizationMemberUpdateMutation } from 'data/organizations/organization-member-update-mutation'
|
||||
|
||||
interface SelectedMember extends Member {
|
||||
oldRoleId: number
|
||||
@@ -21,24 +24,38 @@ interface SelectedMember extends Member {
|
||||
}
|
||||
|
||||
export interface MembersViewProps {
|
||||
roles: Role[]
|
||||
members: Member[]
|
||||
searchString: string
|
||||
}
|
||||
|
||||
const MembersView = ({ searchString, roles, members }: MembersViewProps) => {
|
||||
const MembersView = ({ searchString }: MembersViewProps) => {
|
||||
const { ui } = useStore()
|
||||
const { slug } = useParams()
|
||||
|
||||
const { data: profile } = useProfileQuery()
|
||||
const { data: detailData, isLoading: isLoadingOrgDetails } = useOrganizationDetailQuery({ slug })
|
||||
const { data: rolesData, isLoading: isLoadingRoles } = useOrganizationRolesQuery({ slug })
|
||||
const { mutate: updateOrganizationMember, isLoading } = useOrganizationMemberUpdateMutation({
|
||||
onSuccess() {
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
message: `Successfully updated role for ${getUserDisplayName(selectedMember)}`,
|
||||
})
|
||||
},
|
||||
onError() {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: `Failed to update role for ${getUserDisplayName(selectedMember)}`,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const roles = rolesData?.roles ?? []
|
||||
const members = detailData?.members ?? []
|
||||
const { rolesAddable, rolesRemovable } = getRolesManagementPermissions(roles)
|
||||
|
||||
const [selectedMember, setSelectedMember] = useState<SelectedMember>()
|
||||
const [userRoleChangeModalVisible, setUserRoleChangeModalVisible] = useState(false)
|
||||
|
||||
if (!members) return <div />
|
||||
|
||||
const filteredMembers = (
|
||||
!searchString
|
||||
? members
|
||||
@@ -59,21 +76,6 @@ const MembersView = ({ searchString, roles, members }: MembersViewProps) => {
|
||||
return roles.find((x: any) => x.id === id)?.name
|
||||
}
|
||||
|
||||
const { isLoading, mutate } = useOrganizationMemberUpdateMutation({
|
||||
onSuccess() {
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
message: `Successfully updated role for ${getUserDisplayName(selectedMember)}`,
|
||||
})
|
||||
},
|
||||
onError() {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: `Failed to update role for ${getUserDisplayName(selectedMember)}`,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const handleRoleChange = async () => {
|
||||
if (!selectedMember) return
|
||||
|
||||
@@ -86,11 +88,20 @@ const MembersView = ({ searchString, roles, members }: MembersViewProps) => {
|
||||
throw new Error('gotrue_id is required')
|
||||
}
|
||||
|
||||
mutate({ slug, gotrueId: gotrue_id, roleId: newRoleId })
|
||||
|
||||
updateOrganizationMember({ slug, gotrueId: gotrue_id, roleId: newRoleId })
|
||||
setUserRoleChangeModalVisible(false)
|
||||
}
|
||||
|
||||
if (isLoadingOrgDetails) {
|
||||
return (
|
||||
<div className="py-4 space-y-2">
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader className="w-3/4" />
|
||||
<ShimmeringLoader className="w-1/2" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded">
|
||||
@@ -176,10 +187,13 @@ const MembersView = ({ searchString, roles, members }: MembersViewProps) => {
|
||||
</Table.td>
|
||||
|
||||
<Table.td>
|
||||
{!role && <p>{x.is_owner ? 'Owner' : 'Developer'}</p>}
|
||||
{role && (
|
||||
{isLoadingRoles ? (
|
||||
<div className="w-[140px]">
|
||||
<IconLoader className="animate-spin" size={16} strokeWidth={1.5} />
|
||||
</div>
|
||||
) : role !== undefined ? (
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Tooltip.Trigger className="w-[140px]">
|
||||
<Listbox
|
||||
className={disableRoleEdit ? 'pointer-events-none' : ''}
|
||||
disabled={disableRoleEdit}
|
||||
@@ -231,6 +245,29 @@ const MembersView = ({ searchString, roles, members }: MembersViewProps) => {
|
||||
<></>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-sm text-scale-1100">Invalid role</p>
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<IconAlertCircle size={16} strokeWidth={1.5} />
|
||||
</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', // background
|
||||
'border border-scale-200 ', //border
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">
|
||||
This user has an invalid role, please reach out to us via
|
||||
support
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
)}
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
|
||||
@@ -117,7 +117,7 @@ const TeamSettings = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="container my-4 max-w-4xl space-y-8">
|
||||
<MembersView searchString={searchString} roles={roles} members={members} />
|
||||
<MembersView searchString={searchString} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button, IconExternalLink } from 'ui'
|
||||
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { checkPermissions, useParams, useStore } from 'hooks'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
|
||||
interface Props {}
|
||||
|
||||
@@ -63,8 +64,8 @@ const VaultToggle: FC<Props> = () => {
|
||||
backgroundSize: isNotAvailable ? '50%' : '40%',
|
||||
backgroundPosition: '100% 24%',
|
||||
backgroundImage: ui.isDarkTheme
|
||||
? 'url("/img/vault-dark.png")'
|
||||
: 'url("/img/vault-light.png")',
|
||||
? `url("${BASE_PATH}/img/vault-dark.png")`
|
||||
: `url("${BASE_PATH}/img/vault-light.png")`,
|
||||
}}
|
||||
>
|
||||
<div className="w-3/5 space-y-8">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import HCaptcha from '@hcaptcha/react-hcaptcha'
|
||||
import { useStore } from 'hooks'
|
||||
import { post } from 'lib/common/fetch'
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { API_URL, BASE_PATH } from 'lib/constants'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useRef, useState } from 'react'
|
||||
import { Button, Form, Input } from 'ui'
|
||||
@@ -35,9 +35,9 @@ const ForgotPasswordForm = () => {
|
||||
hcaptchaToken: token ?? undefined,
|
||||
redirectTo: `${
|
||||
process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview'
|
||||
? process.env.NEXT_PUBLIC_VERCEL_URL
|
||||
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
|
||||
: process.env.NEXT_PUBLIC_SITE_URL
|
||||
}/reset-password`,
|
||||
}${BASE_PATH}/reset-password`,
|
||||
})
|
||||
const error = response.error
|
||||
|
||||
@@ -71,7 +71,7 @@ const ForgotPasswordForm = () => {
|
||||
>
|
||||
{({ isSubmitting }: { isSubmitting: boolean }) => {
|
||||
return (
|
||||
<div className="flex flex-col space-y-4 pt-4">
|
||||
<div className="flex flex-col pt-4 space-y-4">
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
@@ -96,7 +96,7 @@ const ForgotPasswordForm = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-overlay-border border-t" />
|
||||
<div className="border-t border-overlay-border" />
|
||||
|
||||
<Button
|
||||
block
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import HCaptcha from '@hcaptcha/react-hcaptcha'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useStore } from 'hooks'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { auth, getReturnToPath } from 'lib/gotrue'
|
||||
import { useRef, useState } from 'react'
|
||||
import { Button, Form, Input } from 'ui'
|
||||
@@ -35,9 +36,9 @@ const SignInSSOForm = () => {
|
||||
captchaToken: token ?? undefined,
|
||||
redirectTo: `${
|
||||
process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview'
|
||||
? process.env.NEXT_PUBLIC_VERCEL_URL
|
||||
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
|
||||
: process.env.NEXT_PUBLIC_SITE_URL
|
||||
}${getReturnToPath()}`,
|
||||
}${BASE_PATH}${getReturnToPath()}`,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { auth, getReturnToPath } from 'lib/gotrue'
|
||||
import { Button, IconGitHub } from 'ui'
|
||||
|
||||
@@ -9,9 +10,9 @@ const SignInWithGitHub = () => {
|
||||
options: {
|
||||
redirectTo: `${
|
||||
process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview'
|
||||
? process.env.NEXT_PUBLIC_VERCEL_URL
|
||||
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
|
||||
: process.env.NEXT_PUBLIC_SITE_URL
|
||||
}${getReturnToPath()}`,
|
||||
}${BASE_PATH}${getReturnToPath()}`,
|
||||
},
|
||||
})
|
||||
if (error) throw error
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Alert, Button, Form, IconEye, IconEyeOff, Input } from 'ui'
|
||||
|
||||
import { useStore } from 'hooks'
|
||||
import { post } from 'lib/common/fetch'
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { API_URL, BASE_PATH } from 'lib/constants'
|
||||
import { passwordSchema } from 'lib/schemas'
|
||||
import PasswordConditionsHelper from './PasswordConditionsHelper'
|
||||
|
||||
@@ -39,9 +39,9 @@ const SignUpForm = () => {
|
||||
hcaptchaToken: token ?? null,
|
||||
redirectTo: `${
|
||||
process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview'
|
||||
? process.env.NEXT_PUBLIC_VERCEL_URL
|
||||
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
|
||||
: process.env.NEXT_PUBLIC_SITE_URL
|
||||
}/sign-in`,
|
||||
}${BASE_PATH}/sign-in`,
|
||||
})
|
||||
const error = response.error
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ const GridHeaderActions: FC<Props> = ({
|
||||
<div className="mt-[1px]">
|
||||
<RenderAPIPreviewToggle />
|
||||
</div>
|
||||
<Link href={`/project/${projectRef}/auth/policies#${table.id}`}>
|
||||
<Link href={`/project/${projectRef}/auth/policies?search=${table.id}`}>
|
||||
<a>
|
||||
<Button
|
||||
type={table.rls_enabled ? 'link' : 'warning'}
|
||||
@@ -85,7 +85,6 @@ const GridHeaderActions: FC<Props> = ({
|
||||
)
|
||||
}
|
||||
>
|
||||
{/* RLS {table.rls_enabled ? 'is' : 'not'} enabled */}
|
||||
{!table.rls_enabled
|
||||
? 'RLS is not enabled'
|
||||
: `${policies.length == 0 ? 'No' : policies.length} active RLS polic${
|
||||
|
||||
+15
-12
@@ -8,22 +8,19 @@ import SortPopover from 'components/grid/components/header/sort'
|
||||
import { formatFilterURLParams, formatSortURLParams } from 'components/grid/SupabaseGrid.utils'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { useTableRowsQuery } from 'data/table-rows/table-rows-query'
|
||||
import { ForeignKeyConstraint } from 'data/database/foreign-key-constraints-query'
|
||||
import { useStore } from 'hooks'
|
||||
import { IconLoader, SidePanel } from 'ui'
|
||||
|
||||
import ActionBar from '../../ActionBar'
|
||||
import { useEncryptedColumns } from './ForeignRowSelector.utils'
|
||||
import SelectorGrid from './SelectorGrid'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Pagination from './Pagination'
|
||||
|
||||
export interface ForeignRowSelectorProps {
|
||||
visible: boolean
|
||||
foreignKey?: {
|
||||
target_table_schema: string
|
||||
target_table_name: string
|
||||
target_column_name: string
|
||||
}
|
||||
foreignKey?: ForeignKeyConstraint
|
||||
onSelect: (value: any) => void
|
||||
closePanel: () => void
|
||||
}
|
||||
@@ -38,13 +35,20 @@ const ForeignRowSelector = ({
|
||||
const { project } = useProjectContext()
|
||||
|
||||
const {
|
||||
target_table_schema: schemaName,
|
||||
target_table_name: tableName,
|
||||
target_column_name: columnName,
|
||||
target_id: tableId,
|
||||
target_schema: schemaName,
|
||||
target_table: tableName,
|
||||
target_columns: columnName,
|
||||
} = foreignKey ?? {}
|
||||
|
||||
const tables = meta.tables.list()
|
||||
const table = tables.find((table) => table.schema === schemaName && table.name === tableName)
|
||||
const table = tableId ? meta.tables.byId(tableId) : undefined
|
||||
|
||||
useEffect(() => {
|
||||
if (!table && tableId) {
|
||||
meta.tables.loadById(tableId)
|
||||
}
|
||||
}, [table, tableId])
|
||||
|
||||
const encryptedColumns = useEncryptedColumns({ schemaName, tableName })
|
||||
|
||||
const supaTable =
|
||||
@@ -85,7 +89,6 @@ const ForeignRowSelector = ({
|
||||
|
||||
return (
|
||||
<SidePanel
|
||||
key="ForeignRowSelector"
|
||||
visible={visible}
|
||||
size="large"
|
||||
header={
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { SidePanel } from 'ui'
|
||||
|
||||
import { useStore } from 'hooks'
|
||||
import JsonEditor from './JsonCodeEditor'
|
||||
import TwoOptionToggle from './TwoOptionToggle'
|
||||
import TwoOptionToggle from 'components/ui/TwoOptionToggle'
|
||||
import DrilldownViewer from './DrilldownViewer'
|
||||
import ActionBar from '../../ActionBar'
|
||||
import { minifyJSON, prettifyJSON, tryParseJson } from 'lib/helpers'
|
||||
|
||||
+20
-2
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { isUndefined, partition, isEmpty, noop } from 'lodash'
|
||||
import { SidePanel } from 'ui'
|
||||
import { Dictionary } from 'components/grid'
|
||||
import type { PostgresTable } from '@supabase/postgres-meta'
|
||||
|
||||
import { useForeignKeyConstraintsQuery } from 'data/database/foreign-key-constraints-query'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import ActionBar from '../ActionBar'
|
||||
import HeaderTitle from './HeaderTitle'
|
||||
import InputField from './InputField'
|
||||
@@ -51,6 +53,21 @@ const RowEditor = ({
|
||||
(rowField: any) => !rowField.isNullable
|
||||
)
|
||||
|
||||
const { project } = useProjectContext()
|
||||
const { data } = useForeignKeyConstraintsQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
schema: selectedTable.schema,
|
||||
})
|
||||
|
||||
const foreignKey = useMemo(
|
||||
() =>
|
||||
data && referenceRow?.foreignKey?.id
|
||||
? data.find((key) => key.id === referenceRow.foreignKey?.id)
|
||||
: undefined,
|
||||
[data, referenceRow?.foreignKey?.id]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setErrors({})
|
||||
@@ -201,8 +218,9 @@ const RowEditor = ({
|
||||
</form>
|
||||
|
||||
<ForeignRowSelector
|
||||
key={`foreign-row-selector-${foreignKey?.id ?? 'null'}`}
|
||||
visible={isSelectingForeignKey}
|
||||
foreignKey={referenceRow?.foreignKey}
|
||||
foreignKey={foreignKey}
|
||||
onSelect={onSelectForeignRowValue}
|
||||
closePanel={() => {
|
||||
setIsSelectingForeignKey(false)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Modal } from 'ui'
|
||||
import type { PostgresTable, PostgresColumn } from '@supabase/postgres-meta'
|
||||
|
||||
import { useStore } from 'hooks'
|
||||
import { entityTypeKeys } from 'data/entity-types/keys'
|
||||
import { useTableRowCreateMutation } from 'data/table-rows/table-row-create-mutation'
|
||||
import { useTableRowUpdateMutation } from 'data/table-rows/table-row-update-mutation'
|
||||
import { RowEditor, ColumnEditor, TableEditor } from '.'
|
||||
@@ -263,7 +264,7 @@ const SidePanelEditor = ({
|
||||
ui.setNotification({ category: 'error', message: response.error.message })
|
||||
} else {
|
||||
await meta.tables.loadById(selectedTable!.id)
|
||||
queryClient.invalidateQueries(sqlKeys.query(project?.ref, ['foreignKeyConstraints']))
|
||||
queryClient.invalidateQueries(sqlKeys.query(project?.ref, ['foreign-key-constraints']))
|
||||
onColumnSaved(configuration.isEncrypted)
|
||||
setIsEdited(false)
|
||||
closePanel()
|
||||
@@ -297,21 +298,27 @@ const SidePanelEditor = ({
|
||||
try {
|
||||
if (isDuplicating) {
|
||||
const duplicateTable = find(tables, { id: tableId }) as PostgresTable
|
||||
|
||||
toastId = ui.setNotification({
|
||||
category: 'loading',
|
||||
message: `Duplicating table: ${duplicateTable.name}...`,
|
||||
})
|
||||
|
||||
const table: any = await meta.duplicateTable(payload, {
|
||||
isRLSEnabled,
|
||||
isRealtimeEnabled,
|
||||
isDuplicateRows,
|
||||
duplicateTable,
|
||||
})
|
||||
|
||||
await queryClient.invalidateQueries(entityTypeKeys.list(project?.ref))
|
||||
|
||||
ui.setNotification({
|
||||
id: toastId,
|
||||
category: 'success',
|
||||
message: `Table ${duplicateTable.name} has been successfully duplicated into ${table.name}!`,
|
||||
})
|
||||
|
||||
onTableCreated(table)
|
||||
} else if (isNewRecord) {
|
||||
toastId = ui.setNotification({
|
||||
@@ -327,17 +334,22 @@ const SidePanelEditor = ({
|
||||
isRealtimeEnabled,
|
||||
importContent
|
||||
)
|
||||
|
||||
await queryClient.invalidateQueries(entityTypeKeys.list(project?.ref))
|
||||
|
||||
ui.setNotification({
|
||||
id: toastId,
|
||||
category: 'success',
|
||||
message: `Table ${table.name} is good to go!`,
|
||||
})
|
||||
|
||||
onTableCreated(table)
|
||||
} else if (selectedTableToEdit) {
|
||||
toastId = ui.setNotification({
|
||||
category: 'loading',
|
||||
message: `Updating table: ${selectedTableToEdit?.name}...`,
|
||||
})
|
||||
|
||||
const { table, hasError }: any = await meta.updateTable(
|
||||
toastId,
|
||||
selectedTableToEdit,
|
||||
@@ -345,6 +357,7 @@ const SidePanelEditor = ({
|
||||
columns,
|
||||
isRealtimeEnabled
|
||||
)
|
||||
|
||||
if (hasError) {
|
||||
ui.setNotification({
|
||||
id: toastId,
|
||||
@@ -352,6 +365,8 @@ const SidePanelEditor = ({
|
||||
message: `Table ${table.name} has been updated, but there were some errors`,
|
||||
})
|
||||
} else {
|
||||
await queryClient.invalidateQueries(entityTypeKeys.list(project?.ref))
|
||||
|
||||
ui.setNotification({
|
||||
id: toastId,
|
||||
category: 'success',
|
||||
@@ -359,7 +374,8 @@ const SidePanelEditor = ({
|
||||
})
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries(sqlKeys.query(project?.ref, ['foreignKeyConstraints']))
|
||||
|
||||
queryClient.invalidateQueries(sqlKeys.query(project?.ref, ['foreign-key-constraints']))
|
||||
} catch (error: any) {
|
||||
saveTableError = true
|
||||
ui.setNotification({ id: toastId, category: 'error', message: error.message })
|
||||
@@ -422,6 +438,7 @@ const SidePanelEditor = ({
|
||||
onSaveJSON={onSaveJSON}
|
||||
/>
|
||||
<ForeignRowSelector
|
||||
key={`foreign-row-selector-${selectedForeignKeyToEdit?.foreignKey?.id ?? 'null'}`}
|
||||
visible={sidePanelKey === 'foreign-row-selector'}
|
||||
foreignKey={selectedForeignKeyToEdit?.foreignKey}
|
||||
closePanel={onClosePanel}
|
||||
|
||||
+36
-8
@@ -1,6 +1,6 @@
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { isUndefined, isEmpty } from 'lodash'
|
||||
import { Badge, Checkbox, SidePanel, Input, Alert } from 'ui'
|
||||
import { Badge, Checkbox, SidePanel, Input, Alert, IconBookOpen, Button } from 'ui'
|
||||
import type { PostgresTable, PostgresType } from '@supabase/postgres-meta'
|
||||
|
||||
import { useStore } from 'hooks'
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from './TableEditor.utils'
|
||||
import { useForeignKeyConstraintsQuery } from 'data/database/foreign-key-constraints-query'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface Props {
|
||||
table?: PostgresTable
|
||||
@@ -55,7 +56,6 @@ const TableEditor: FC<Props> = ({
|
||||
const { project } = useProjectContext()
|
||||
const isNewRecord = isUndefined(table)
|
||||
|
||||
const tables = meta.tables.list()
|
||||
const enumTypes = meta.types.list(
|
||||
(type: PostgresType) => !meta.excludedSchemas.includes(type.schema)
|
||||
)
|
||||
@@ -226,18 +226,46 @@ const TableEditor: FC<Props> = ({
|
||||
<p>
|
||||
Restrict access to your table by enabling RLS and writing Postgres policies.
|
||||
</p>
|
||||
<p>
|
||||
RLS is secure by default - all normal access to this table must be allowed by a
|
||||
policy.
|
||||
</p>
|
||||
{!tableFields.isRLSEnabled && (
|
||||
|
||||
{tableFields.isRLSEnabled ? (
|
||||
<Alert
|
||||
withIcon
|
||||
variant="warning"
|
||||
className="!px-4 !py-3 mt-3"
|
||||
title="RLS policies are required to query data"
|
||||
>
|
||||
<p>
|
||||
You need to write a policy before you can query data from this table.
|
||||
Without a policy, querying this table will result in an <u>empty array</u>{' '}
|
||||
of results.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
<Link href="https://supabase.com/docs/guides/auth/row-level-security">
|
||||
<a target="_blank">
|
||||
<Button type="default" icon={<IconBookOpen strokeWidth={1.5} />}>
|
||||
RLS Documentation
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert
|
||||
withIcon
|
||||
variant="danger"
|
||||
className="!px-4 !py-3 mt-3"
|
||||
title="Turning off RLS means that you are allowing anonymous access to your table"
|
||||
>
|
||||
As such, anyone with the anonymous key can modify or delete your data.
|
||||
<p>As such, anyone with the anonymous key can modify or delete your data.</p>
|
||||
<p className="mt-4">
|
||||
<Link href="https://supabase.com/docs/guides/auth/row-level-security">
|
||||
<a target="_blank">
|
||||
<Button type="default" icon={<IconBookOpen strokeWidth={1.5} />}>
|
||||
RLS Documentation
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FC, useRef, useEffect, useState } from 'react'
|
||||
import { useRef, useEffect, useState } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { useRouter } from 'next/router'
|
||||
import { find, isUndefined, noop } from 'lodash'
|
||||
|
||||
@@ -75,14 +75,14 @@ const AccountLayout: FC<Props> = ({ children, title, breadcrumbs }) => {
|
||||
links: [
|
||||
{
|
||||
isActive: router.pathname === `/account/me`,
|
||||
icon: '/img/user.svg',
|
||||
icon: `${router.basePath}/img/user.svg`,
|
||||
label: 'Preferences',
|
||||
href: `/account/me`,
|
||||
key: `/account/me`,
|
||||
},
|
||||
{
|
||||
isActive: router.pathname === `/account/tokens`,
|
||||
icon: '/img/user.svg',
|
||||
icon: `${router.basePath}/img/user.svg`,
|
||||
label: 'Access Tokens',
|
||||
href: `/account/tokens`,
|
||||
key: `/account/tokens`,
|
||||
@@ -97,14 +97,14 @@ const AccountLayout: FC<Props> = ({ children, title, breadcrumbs }) => {
|
||||
links: [
|
||||
{
|
||||
key: 'ext-guides',
|
||||
icon: '/img/book.svg',
|
||||
icon: `${router.basePath}/img/book.svg`,
|
||||
label: 'Guides',
|
||||
href: 'https://supabase.com/docs',
|
||||
isExternal: true,
|
||||
},
|
||||
{
|
||||
key: 'ext-guides',
|
||||
icon: '/img/book-open.svg',
|
||||
icon: `${router.basePath}/img/book-open.svg`,
|
||||
label: 'API Reference',
|
||||
href: 'https://supabase.com/docs/guides/database/api',
|
||||
isExternal: true,
|
||||
|
||||
@@ -16,7 +16,8 @@ interface Props {
|
||||
|
||||
const DatabaseLayout: FC<Props> = ({ title, children }) => {
|
||||
const { meta, ui, vault, backups } = useStore()
|
||||
const { isInitialized, isLoading, error } = meta.tables
|
||||
const { isLoading } = meta.schemas
|
||||
const { isInitialized, error } = meta.tables
|
||||
const project = ui.selectedProject
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types'
|
||||
import SVG from 'react-inlinesvg'
|
||||
import { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types'
|
||||
import { IconBook, IconBookOpen } from 'ui'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
|
||||
export const generateDocsMenu = (
|
||||
ref: string,
|
||||
@@ -71,7 +72,7 @@ export const generateDocsMenu = (
|
||||
url: `/project/${ref}/api/graphiql`,
|
||||
icon: (
|
||||
<SVG
|
||||
src="/img/graphql.svg"
|
||||
src={`${BASE_PATH}/img/graphql.svg`}
|
||||
style={{ width: `${16}px`, height: `${16}px` }}
|
||||
className="text-scale-1200"
|
||||
preProcessor={(code) =>
|
||||
|
||||
@@ -83,14 +83,16 @@ const HelpPopover: FC<Props> = () => {
|
||||
>
|
||||
<Image
|
||||
className="absolute left-0 top-0 opacity-50"
|
||||
src={'/img/support/discord-bg-small.jpg'}
|
||||
src={`${router.basePath}/img/support/discord-bg-small.jpg`}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
alt="discord illustration header"
|
||||
/>
|
||||
<Button
|
||||
type="secondary"
|
||||
icon={<SVG src={`/img/discord-icon.svg`} className="h-4 w-4" />}
|
||||
icon={
|
||||
<SVG src={`${router.basePath}/img/discord-icon.svg`} className="h-4 w-4" />
|
||||
}
|
||||
>
|
||||
<span style={{ color: '#404EED' }}>Join Discord server</span>
|
||||
</Button>
|
||||
@@ -106,7 +108,7 @@ const HelpPopover: FC<Props> = () => {
|
||||
>
|
||||
<Image
|
||||
className="absolute left-0 top-0 opacity-50"
|
||||
src={'/img/support/github-bg.jpg?v-1'}
|
||||
src={`${router.basePath}/img/support/github-bg.jpg?v-1`}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
alt="discord illustration header"
|
||||
|
||||
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user