Merge pull request #10741 from supabase/master

Prod deploy Dashboard
This commit is contained in:
Joshen Lim authored and GitHub committed 2022-12-06 16:34:33 +08:00
commit df66ff2c56
75 files changed
+485 -544

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
name: Mirror Image
on:
workflow_dispatch:
inputs:
version:
description: "Image tag"
required: true
type: string
jobs:
mirror:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: docker/login-action@v2
with:
registry: public.ecr.aws
username: ${{ secrets.PROD_ACCESS_KEY_ID }}
password: ${{ secrets.PROD_SECRET_ACCESS_KEY }}
- uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: akhilerm/tag-push-action@v2.1.0
with:
src: docker.io/supabase/studio:${{ inputs.version }}
dst: |
public.ecr.aws/supabase/studio:${{ inputs.version }}
ghcr.io/supabase/studio:${{ inputs.version }}
+12 -1
View File
@@ -16,13 +16,17 @@ on:
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- id: meta
uses: docker/metadata-action@v4
with:
images: |
supabase/studio
public.ecr.aws/t3w2s2c9/studio
public.ecr.aws/supabase/studio
ghcr.io/supabase/studio
flavor: |
latest=false
tags: |
@@ -49,6 +53,13 @@ jobs:
username: ${{ secrets.PROD_ACCESS_KEY_ID }}
password: ${{ secrets.PROD_SECRET_ACCESS_KEY }}
- name: Login to GHCR
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v3
with:
push: true
+1 -1
View File
@@ -75,7 +75,7 @@ Then visit, and edit, any of the following sites:
| ---------------------------------------------- | -------------- | ---------- | ------------------------------------ | ------------------------ |
| [supabase.com](https://supabase.com) | `/apps/www` | www | The main website | http://localhost:3000 |
| [app.supabase.com](https://app.supabase.com) | `/studio` | studio | Studio dashboard | http://localhost:8082 |
| [supabase.com/docs](https://supabase.com/docs) | `/apps/docs` | docs | Guides and Reference (Next.js based) | http://localhost:3001 |
| [supabase.com/docs](https://supabase.com/docs) | `/apps/docs` | docs | Guides and Reference (Next.js based) | http://localhost:3001/docs |
#### Running sites individually
+1 -1
View File
@@ -58,7 +58,7 @@ Supabase is a combination of open source tools. We’re building the features of
Supabase is a [hosted platform](https://app.supabase.com). You can sign up and start using Supabase without installing anything.
You can also [self-host](https://supabase.com/docs/guides/hosting/overview) and [develop locally](https://supabase.com/docs/guides/local-development).
![Architecture](https://user-images.githubusercontent.com/70828596/187547862-ffa9d058-0c3a-4851-a3e7-92ccfca4b596.png)
![Architecture](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png)
- [PostgreSQL](https://www.postgresql.org/) is an object-relational database system with over 30 years of active development that has earned it a strong reputation for reliability, feature robustness, and performance.
- [Realtime](https://github.com/supabase/realtime) is an Elixir server that allows you to listen to PostgreSQL inserts, updates, and deletes using websockets. Realtime polls Postgres' built-in replication functionality for database changes, converts changes to JSON, then broadcasts the JSON over websockets to authorized clients.
@@ -237,7 +237,7 @@ export const menuItems: NavMenu = {
items: [
{ name: 'Overview', url: '/guides/realtime', items: [] },
{ name: 'Quickstart', url: '/guides/realtime/quickstart', items: [] },
{ name: 'Postgres CDC', url: '/guides/realtime/postgres-cdc', items: [] },
{ name: 'Postgres Changes', url: '/guides/realtime/postgres-changes', items: [] },
{ name: 'Rate Limits', url: '/guides/realtime/rate-limits', items: [] },
],
},
+2 -2
View File
@@ -92,7 +92,7 @@ Fast GraphQL APIs using our custom Postgres GraphQL extension. [Docs](/docs/guid
### Realtime Database changes
Receive your database changes through websockets. [Docs](/docs/guides/realtime/postgres-cdc).
Receive your database changes through websockets. [Docs](/docs/guides/realtime/postgres-changes).
### User Broadcasting
@@ -156,7 +156,7 @@ Both Postgres and the Supabase Platform are production-ready. Some tools we offe
| Database | Point-in-Time Recovery | `alpha` |
| Database | Vault | `alpha` |
| Studio | | `GA` |
| Realtime | Postgres CDC | `GA` |
| Realtime | Postgres Changes | `GA` |
| Realtime | Broadcast | `beta` |
| Realtime | Presence | `beta` |
| Storage | Backend (S3) | `GA` |
@@ -8,7 +8,7 @@ export const meta = {
<Admonition type="caution">
The pg_net API is in beta. Functions signatures may change.
The pg_net API is in alpha. Functions signatures may change.
</Admonition>
@@ -150,102 +150,6 @@ request_id
After triggering `http_post`, use [`http_get_result`](#http_get_result) to get the result of the request.
## `http_collect_response` [#http_collect_response]
Given a `request_id` reference, retrieves the response.
When `async:=false` is set it is recommended that [statement_timeout](https://www.postgresql.org/docs/13/runtime-config-client.html) is set for the maximum amount of time the caller is willing to wait in case the response is slow to populate.
### Signature
<Admonition type="caution">
This is a Postgres SECURITY DEFINER function
</Admonition>
```sql
net.http_collect_response(
-- request_id reference
request_id bigint,
-- when `true`, return immediately. when `false` wait for the request to complete before returning
async bool default true
)
-- http response composite wrapped in a result type
returns net.http_response_result
strict
volatile
parallel safe
```
### Usage
<Admonition type="caution">
`net.http_collect_response` must be in a separate transaction from the calls to `net.http_<method>`
</Admonition>
```sql
select
net.http_post(
url:='https://httpbin.org/post',
body:='{"hello": "world"}'::jsonb
) as request_id;
request_id
----------
1
(1 row)
select * from net.http_collect_response(1, async:=false);
status | message | response
--------+---------+----------
SUCCESS ok (
status_code := 200,
headers := '{"date": ...}',
body := '{"args": ...}'
)::net.http_response_result
select
(response).body::json
from
net.http_collect_response(request_id:=1);
body
-------------------------------------------------------------------
{
"args": {},
"data": "{\"hello\": \"world\"}",
"files": {},
"form": {},
"headers": {
"Accept": "*/*",
"Content-Length": "18",
"Content-Type": "application/json",
"Host": "httpbin.org",
"User-Agent": "pg_net/0.2",
"X-Amzn-Trace-Id": "Root=1-61031a5c-7e1afeae69bffa8614d8e48e"
},
"json": {
"hello": "world"
},
"origin": "135.63.38.488",
"url": "https://httpbin.org/post"
}
(1 row)
```
Where `response` is a composite:
```sql
status_code integer
headers jsonb
body text
```
Possible values for `net.http_response_result.status` are `('PENDING', 'SUCCESS', 'ERROR')`
## Resources
- Source code: [github.com/supabase/pg_net](https://github.com/supabase/pg_net/)
@@ -106,7 +106,7 @@ You should see the response `{ "message":"Hello Functions!" }`.
<details>
<summary>Implementation details</summary>
- All Edge Functions are `POST` requests.
- Edge Functions don't serve HTML content (`GET` requests that return `text/html` are rewritten to `text/plain`).
- The `Authorization` header is required. You can use either the `ANON` key, the `SERVICE_ROLE` key, or a logged-in user's JWT.
- The Function is proxied through the local API (`http://localhost:54321`)
@@ -30,7 +30,7 @@ Manually enter a couple of rows of data, so that we have something to read from
## The `Get all` reviews route in Zuplo
Login to Zuplo at [portal.zuplo.com](https://portal.zuplo.com] and create a new project in Zuplo - I went with `supabase-ski-reviews`.
Login to Zuplo at [portal.zuplo.com](https://portal.zuplo.com) and create a new project in Zuplo - I went with `supabase-ski-reviews`.
Select the **File** tab and choose **Routes**. Add your first route with the following settings:
@@ -12,7 +12,7 @@ Custom Domains are currently in beta, and are being slowly made available to pro
</Admonition>
Custom domains allow you to present a branded experience to your users. You can also use a subdomain for the purposes of this guide.
Custom domains allow you to present a branded experience to your users. Currently, you must use a subdomain (e.g., `api.example.com`, rather than `example.com`) for the purposes of this guide.
To get started:
+5 -5
View File
@@ -3,7 +3,7 @@ import Layout from '~/layouts/DefaultGuideLayout'
export const meta = {
id: 'realtime',
title: 'Realtime',
description: 'Supabase Realtime with Broadcast, Presence, and Postgres CDC.',
description: 'Supabase Realtime with Broadcast, Presence, and Postgres Changes.',
sidebar_label: 'Overview',
}
@@ -11,11 +11,11 @@ Supabase provides a globally distributed cluster of [Realtime](https://github.co
- [Broadcast](#broadcast): Send ephemeral messages from client to clients with low latency.
- [Presence](#presence): Track and synchronize shared state between clients.
- [Postgres CDC](#postgres-cdc): Listen to Postgres database changes and send them to authorized clients.
- [Postgres Changes](#postgres-changes): Listen to Postgres database changes and send them to authorized clients.
A [channel](https://hexdocs.pm/phoenix/channels.html) is the basic building block of Realtime and narrows the scope of data flow to subscribed clients. You can think of a channel as a chatroom where participants are able to see who's online and send and receive messages; similar to a Discord or Slack channel.
All clients can connect to a channel and take advantage of the built-in features, Broadcast and Presence, while extenstions, like Postgres CDC, must be enabled prior to use.
All clients can connect to a channel and take advantage of the built-in features, Broadcast and Presence, while extenstions, like Postgres Changes, must be enabled prior to use.
## Broadcast
@@ -37,9 +37,9 @@ Clients are free to come-and-go as they please, and as long as they are all subs
The neat thing about Presence is that if a client is suddenly disconnected (for example, they go offline), their state will be automatically removed from the shared state. If you've ever tried to build an “I'm online” feature which handles unexpected disconnects, you'll appreciate how useful this is.
## Postgres CDC
## Postgres Changes
Postgres Change Data Capture (CDC) enables you to listen to database changes and have them broadcast to authorized clients based on [Row Level Security (RLS)](/docs/guides/auth/row-level-security) policies.
Postgres Changes enable you to listen to database changes and have them broadcast to authorized clients based on [Row Level Security (RLS)](/docs/guides/auth/row-level-security) policies.
This works by Realtime polling your database's logical replication slot for changes, passing those changes to the [apply_rls](https://github.com/supabase/walrus#reading-wal) SQL function to determine which clients have permission, and then using Broadcast to send those changes to clients.
@@ -1,18 +1,18 @@
import Layout from '~/layouts/DefaultGuideLayout'
export const meta = {
id: 'postgres-cdc',
title: 'Postgres CDC',
description: "Getting started with Realtime's Postgres CDC feature",
id: 'postgres-changes',
title: 'Postgres Changes',
description: "Getting started with Realtime's Postgres Changes feature",
}
Realtime's Postgres Change Data Capture (CDC) feature listens for database changes and sends them to clients. Clients are required to subscribe with a JWT dictating which changes they are allowed to receive based on the database's [Row Level Security](/docs/guides/auth/row-level-security).
Realtime's Postgres Changes feature listens for database changes and sends them to clients. Clients are required to subscribe with a JWT dictating which changes they are allowed to receive based on the database's [Row Level Security](/docs/guides/auth/row-level-security).
Anyone with access to a valid JWT signed with the project's JWT secret is able to listen to your database's changes, unless tables have [Row Level Security](/docs/guides/auth/row-level-security) enabled and policies in place.
Clients can choose to receive `INSERT`, `UPDATE`, `DELETE`, or `*` (all) changes for all changes in a schema, a table in a schema, or a column's value in a table. Your clients should only listen to tables in the `public` schema and you must first enable the tables you want your clients to listen to.
Postgres CDC works out of the box for tables in the `public` schema. You can listen to tables in your private schemas by granting table `SELECT` permissions to the database role found in your access token. You can run a query similar to the following:
Postgres Changes works out of the box for tables in the `public` schema. You can listen to tables in your private schemas by granting table `SELECT` permissions to the database role found in your access token. You can run a query similar to the following:
```sql
GRANT SELECT ON "private_schema"."table" TO authenticated;
@@ -7,7 +7,7 @@ export const meta = {
sidebar_label: 'Quickstart',
}
Learn how to build [multiplayer.dev](https://multiplayer.dev), a collaborative app that demonstrates Broadcast, Presence, and Postgres CDC using [Realtime](/docs/guides/realtime).
Learn how to build [multiplayer.dev](https://multiplayer.dev), a collaborative app that demonstrates Broadcast, Presence, and Postgres Changes using [Realtime](/docs/guides/realtime).
<div className="video-container">
<iframe
@@ -185,7 +185,7 @@ If a channel is set up without a presence key, the server generates a random UUI
## Insert and Receive Persisted Messages
[Postgres Change Data Capture (CDC)](/docs/guides/realtime#postgres-cdc) enables your client to insert, update, or delete database records and send the changes to clients. Create a `messages` table to keep track of messages created by users in specific rooms:
[Postgres Changes](/docs/guides/realtime#postgres-changes) enables your client to insert, update, or delete database records and send the changes to clients. Create a `messages` table to keep track of messages created by users in specific rooms:
```sql
create table messages (
+1 -1
View File
@@ -56,7 +56,7 @@ You can use it completely, or just the features you need.
className="card"
to={'/guides/realtime'}
title={'Realtime'}
description={'Build with Broadcast, Presence, and Postgres CDC.'}
description={'Build with Broadcast, Presence, and Postgres Changes.'}
style={{ height: '100%' }}
/>
</div>
+1 -1
View File
@@ -799,7 +799,7 @@
</url>
<url>
<loc>https://supabase.com/docs/guides/realtime/postgres-cdc</loc>
<loc>https://supabase.com/docs/guides/realtime/postgres-changes</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
@@ -18,7 +18,7 @@ To understand how TCE works, let's first do a deep-dive into an important encryp
<div className="bg-gray-300 rounded-lg p-6 italic">
This post is a sneak peek of a big feature we are shipping on LW6. To not miss a thing, get your ticket for [Supabase Launch Week 6!](https://supabase.com/launch-week).
This post is a sneak peek of a big feature we are shipping on LW6. To not miss a thing, get your ticket for [Supabase Launch Week 6!](https://supabase.com/launch-week).
</div>
@@ -28,23 +28,23 @@ The current state-of-the-art in encryption libraries is [libsodium](https://doc.
**libsodium** offers a range of APIs for authenticated secret and public key encryption, key derivation, encrypted streaming, [AEAD](https://en.wikipedia.org/wiki/Authenticated_encryption), various forms of hashing, and much more.
This powerful API is available to PostgreSQL using the [**pgsodium**](https://github.com/michelp/pgsodium) extension. **pgsodium** provides all the functionality of the full **libsodium** API, but previously it required developers to set up database encryption themselves, which remained a challenge even for those familiar with database administration.
This powerful API is available to PostgreSQL using the [**pgsodium**](https://github.com/michelp/pgsodium) extension. **pgsodium** provides all the functionality of the full **libsodium** API, but previously it required developers to set up database encryption themselves, which remained a challenge even for those familiar with database administration.
To solve this problem, **pgsodium** now has a full key management API, primarily via the table `pgsodium.key` and the `pgsodium.create_key()` function. This key table contains no raw keys, but instead uses libsodium Key IDs to derive keys that are used internally for encryption. A [key derivation function](https://libsodium.gitbook.io/doc/key_derivation) is used with an internal root key that is unavailable to SQL and not stored in the database, but rather managed by you externally using flexible scripts, or by Supabase automatically as part of our service offering.
To solve this problem, **pgsodium** now has a full key management API, primarily via the table `pgsodium.key` and the `pgsodium.create_key()` function. This key table contains no raw keys, but instead uses libsodium Key IDs to derive keys that are used internally for encryption. A [key derivation function](https://libsodium.gitbook.io/doc/key_derivation) is used with an internal root key that is unavailable to SQL and not stored in the database, but rather managed by you externally using flexible scripts, or by Supabase automatically as part of our service offering.
The simplest way to use **pgsodium** to encrypt and decrypt data is to first create a ******Key ID******. Valid Key IDs are stored in pgsodium in a special extension table, and they can be created using the `pgsodium.create_key()` function. This function takes a number of arguments depending on how it's used, but the simplest case is to create a new key with no arguments:
The simplest way to use **pgsodium** to encrypt and decrypt data is to first create a **Key ID**. Valid Key IDs are stored in pgsodium in a special extension table, and they can be created using the `pgsodium.create_key()` function. This function takes a number of arguments depending on how it's used, but the simplest case is to create a new key with no arguments:
```sql
select * from pgsodium.create_key();
-[ RECORD 1 ]---+-------------------------------------
id | eaa20d8c-c77c-4985-9f73-2a5f5d1f1e6d
name |
name |
status | valid
key_type | aead-det
key_id | 2
key_context | \x7067736f6469756d
created | 2022-11-13 21:19:35.765823+00
expires |
expires |
associated_data |
```
@@ -65,13 +65,13 @@ This produces the following encrypted “ciphertext” using the `aead-det` algo
crypto_aead_det_encrypt | \\x099baa820250d7375ed141f8f1936af384bc229f3de1010a6eff6ffdaf3998baffbae75b5cd83d1c469407ff2d3764a428b742
```
Now to decrypt the ciphertext, pass it to the decryption function *with the same Key ID*:
Now to decrypt the ciphertext, pass it to the decryption function _with the same Key ID_:
```sql
select *
select *
from convert_from(pgsodium.crypto_aead_det_decrypt (
'\\x099baa820250d7375ed141f8f1936af384bc229f3de1010a6eff6ffdaf3998baffbae75b5cd83d1c469407ff2d3764a428b742',
'this is associated data',
'\\x099baa820250d7375ed141f8f1936af384bc229f3de1010a6eff6ffdaf3998baffbae75b5cd83d1c469407ff2d3764a428b742',
'this is associated data',
'eaa20d8c-c77c-4985-9f73-2a5f5d1f1e6d'::uuid
), 'utf8');
```
@@ -83,24 +83,24 @@ Which recovers the original “plaintext” message:
convert_from | this is the message
```
In the above example, there is *no raw key* like shown in the pgcrypto example above, only a Key ID which is used to derive the key used to encrypt the message and authenticate it with the associated data. In fact, it is impossible for a SQL user to derive the key used above, and if the Key ID is stored, then no decrypted information will leak into backups, disk storage, or the database WAL stream.
In the above example, there is _no raw key_, only a Key ID which is used to derive the key used to encrypt the message and authenticate it with the associated data. In fact, it is impossible for a SQL user to derive the key used above, and if the Key ID is stored, then no encryption keys or decrypted information will leak into backups, disk storage, or the database WAL stream.
## Transparent Column Encryption
As of **pgsodium** 3.0.0 and up, the extension offers a simple and declarative Transparent Column Encryption feature (TCE). This feature is now shipped with all Supabase projects. TCE allows you to specify encrypted columns within a table and generates a new view that “wraps” that table to decrypt the contents.
As of **pgsodium** 3.0.0 and up, the extension offers a simple and declarative Transparent Column Encryption feature (TCE). This feature is now shipped with all Supabase projects. TCE allows you to specify encrypted columns within a table and generates a new view that “wraps” that table to decrypt the contents.
TCE works using two dynamically generated objects for tables that contain encrypted columns:
TCE works using two dynamically generated objects for tables that contain encrypted columns:
- an `INSERT UPDATE` trigger that encrypts data when it is inserted or modified
- a view that is created to wrap the table to decrypt the data when it is accessed
To “transparently” decrypt the table, access the dynamically generated view *instead of the table*. For every encrypted column in the table, the view will have an additional decrypted column that shows the decrypted result.
To “transparently” decrypt the table, access the dynamically generated view _instead of the table_. For every encrypted column in the table, the view will have an additional decrypted column that shows the decrypted result.
It's worth noting at this point that sometimes there is some confusion about handling encrypted data with TCE. The `T` stands for ***********Transparent*********** which means, you can always see decrypted data through the view, where the decrypted data can't be see is when stored on disk, or in pg_dumps, backups, WAL streams, etc. This is often called ******************Encryption At Rest****************** and is one layer in many that may be used to encrypt and protect your data.
It's worth noting at this point that sometimes there is some confusion about handling encrypted data with TCE. The `T` stands for **Transparent** which means, you can always see decrypted data through the view, where the decrypted data can't be see is when stored on disk, or in pg_dumps, backups, WAL streams, etc. This is often called **Encryption At Rest** and is one layer in many that may be used to encrypt and protect your data.
Often Transparent encryption is understood to be “Transparent Disk Encryption” or “Full Disk Encryption”, this is where a drive is encrypted but reading and writing that drive is decrypted. TCE is similar to this, where data on disk is encrypted, but it is more fine grained, only particular columns are encrypted. The data is also encrypted in the sense that the table stored on disk contains encrypted data, without the view or the key, pg_dumps and backups still contain encrypted data, this is not possible with disk-only encryption.
Often Transparent encryption is understood to be “Transparent Disk Encryption” or “Full Disk Encryption”, this is where a drive is encrypted but reading and writing that drive is decrypted. TCE is similar to this, where data on disk is encrypted, but it is more fine grained, only particular columns are encrypted. The data is also encrypted in the sense that the table stored on disk contains encrypted data, without the view or the key, pg_dumps and backups still contain encrypted data, this is not possible with disk-only encryption.
For the moment TCE only works for columns of type `text` (or types castable to `text` like `json`). Soon we will also support `bytea` and possibly more as use cases and tests get better.
For the moment TCE only works for columns of type `text` (or types castable to `text` like `json`). Soon we will also support `bytea` and possibly more as use cases and tests get better.
TCE uses one of PostgreSQL's lesser-known features: [`SECURITY LABEL`](https://www.postgresql.org/docs/current/sql-security-label.html). A security label can be thought of as a simple label which is attached to an object (a table, column, etc). Each label is scoped to an extension and that extension can provide security features depending on the label.
@@ -127,7 +127,7 @@ SECURITY LABEL FOR pgsodium
IS 'ENCRYPT WITH KEY ID e348034b-3f07-4878-aad6-000511d12826';
```
The advantage of this approach is simplicity - the user creates one key and labels a column with it. The cryptographic algorithm for this approach uses a *nonceless* encryption algorithm called `crypto_aead_det_xchacha20()`. This algorithm is written by the author of libsodium and can be found [here](https://github.com/jedisct1/libsodium-xchacha20-siv).
The advantage of this approach is simplicity - the user creates one key and labels a column with it. The cryptographic algorithm for this approach uses a _nonceless_ encryption algorithm called `crypto_aead_det_xchacha20()`. This algorithm is written by the author of libsodium and can be found [here](https://github.com/jedisct1/libsodium-xchacha20-siv).
Using one key for an entire column means that whoever can decrypt one row can decrypt them all from a database dump. Also changing (rotating) the key means rewriting the whole table.
@@ -153,11 +153,11 @@ Notice also how there is a `DEFAULT` value for the `key_id`. In a way, this give
### One Key ID per Row with Nonce Support
The default cryptographic algorithm for the above approach uses a *nonceless* encryption algorithm called `[crypto_aead_det_xchacha20()](https://github.com/jedisct1/libsodium-xchacha20-siv)`. This algorithm has the advantage that it does not require nonce values, the disadvantage is that duplicate plaintexts will produce duplicate ciphertexts.
The default cryptographic algorithm for the above approach uses a _nonceless_ encryption algorithm called `[crypto_aead_det_xchacha20()](https://github.com/jedisct1/libsodium-xchacha20-siv)`. This algorithm has the advantage that it does not require nonce values, the disadvantage is that duplicate plaintexts will produce duplicate ciphertexts.
Nonces are some extra cryptographic context that is used in many cryptographic algorithms to produce different ciphertexts, even if the plaintexts are the same. The nonce does not have to be secret, but it *does* have to be unique. **pgsodium** comes with a useful function `pgsodium.crypto_aead_det_noncegen()` that will generate a cryptographically secure nonce for you, and in almost all cases it's best to use that function unless you know specifically what you are doing. In password hashing approaches, this is often similar to how a “salt” value is used to deduplicate password hashes.
Nonces are some extra cryptographic context that is used in many cryptographic algorithms to produce different ciphertexts, even if the plaintexts are the same. The nonce does not have to be secret, but it _does_ have to be unique. **pgsodium** comes with a useful function `pgsodium.crypto_aead_det_noncegen()` that will generate a cryptographically secure nonce for you, and in almost all cases it's best to use that function unless you know specifically what you are doing. In password hashing approaches, this is often similar to how a “salt” value is used to deduplicate password hashes.
Duplicate ciphertexts cannot be used to “attack the key”, it can only reveal the duplication. However, duplication is still information. In our examples so far, an attacker might be able to use this information to determine that two accounts share the same credit card number. While not technically breaking the encryption, this still leaks information to an attacker.
Duplicate ciphertexts cannot be used to “attack the key”, it can only reveal the duplication. However, duplication is still information. In our examples so far, an attacker might be able to use this information to determine that two accounts share the same credit card number. While not technically breaking the encryption, this still leaks information to an attacker.
```sql
CREATE TABLE credit_cards (
@@ -172,15 +172,15 @@ SECURITY LABEL FOR pgsodium
IS 'ENCRYPT WITH KEY COLUMN key_id NONCE nonce';
```
This is the most secure form of TCE - there is a unique key ID and a unique nonce per row.
This is the most secure form of TCE - there is a unique key ID and a unique nonce per row.
### One Key ID per Row with Associated Data
The encryption that is used for TCE is one of a family of functions provided by **libsodium** to do Authenticated Encryption with Associated Data or [AEAD Encryption](https://en.wikipedia.org/wiki/Authenticated_encryption). The “associated” data is plaintext (unencrypted) information that is mixed into the authentication signature of the encrypted data, such that when you authenticate the data, you also know that the associated data is authentic.
The encryption that is used for TCE is one of a family of functions provided by **libsodium** to do Authenticated Encryption with Associated Data or [AEAD Encryption](https://en.wikipedia.org/wiki/Authenticated_encryption). The “associated” data is plaintext (unencrypted) information that is mixed into the authentication signature of the encrypted data, such that when you authenticate the data, you also know that the associated data is authentic.
AEAD is helpful because often you have metadata associated with a secret, which isn't confidential but must not be forged.
AEAD is helpful because often you have metadata associated with a secret, which isn't confidential but must not be forged.
In our credit card example, we might associate a "`credit_card_number`" with an "`account_id`". But what if a malicious actor wanted to use someone else's credit card on their own account? If someone could forge the `account_id` data column, swapping an `account_id` with their own `account_id`, then you could be tricked into using the wrong credit card. By “associating” the `account_id` with the `credit_card_number`, it cannot be forged without throwing an error.
In our credit card example, we might associate a "`credit_card_number`" with an "`account_id`". But what if a malicious actor wanted to use someone else's credit card on their own account? If someone could forge the `account_id` data column, swapping an `account_id` with their own `account_id`, then you could be tricked into using the wrong credit card. By “associating” the `account_id` with the `credit_card_number`, it cannot be forged without throwing an error.
Like above, this is done simply by extending the security label with the associated data column:
@@ -202,18 +202,18 @@ The new label indicates which column is to be associated with the secret, and th
## Using an Encrypted Table
Now that you have TCE setup for a table, it's easy to use by simply inserting data into the table, and querying that data by looking at its generated view. The view is named `decrypted_<table_name>` and by default is in the same schema as your table:
Now that you have TCE setup for a table, it's easy to use by simply inserting data into the table, and querying that data by looking at its generated view. The view is named `decrypted_<table_name>` and by default is in the same schema as your table:
```sql
INSERT INTO credit_cards (
credit_card_number,
account_id
)
)
VALUES
('1234-5678-8765-4321', 123);
```
Now that you have inserted data, look at the table and notice how the credit card number is encrypted. This is the data that is stored on disk, the encrypted card number, the key id, and the account id, ********************************but the key itself is not stored********************************. This means if someone gets a backup or dump of your database, they cannot decrypt the credit card number, they do not have the key, only the key ID:
Now that you have inserted data, look at the table and notice how the credit card number is encrypted. This is the data that is stored on disk, the encrypted card number, the key id, and the account id, **but the key itself is not stored**. This means if someone gets a backup or dump of your database, they cannot decrypt the credit card number, they do not have the key, only the key ID:
```sql
> select * from credit_cards where account_id = 123;
@@ -238,11 +238,11 @@ key_id | 7f753c4f-8c68-457a-8801-1798b2e9f44d
nonce | \x300a14aa721184ff7cf0f6bf088da267
```
Notice how there is a new column called `decrypted_credit_card_number`. This column is not stored in database or on disk at all, it is generated “on-the-fly” as you select from the view. Database dumps do not contain this information, only the view itself, and most importantly, *************************************raw decryption keys are never stored*************************************.
Notice how there is a new column called `decrypted_credit_card_number`. This column is not stored in database or on disk at all, it is generated “on-the-fly” as you select from the view. Database dumps do not contain this information, only the view itself, and most importantly, **raw decryption keys are never stored**.
## Future possibilities
We're always thinking about the future possibilities for features and tools that we can bring to the PostgreSQL community, and we'd love to hear from you about what kind of encryption features you'd like to see. Some things we've considered but not yet explored yet are:
We're always thinking about the future possibilities for features and tools that we can bring to the PostgreSQL community, and we'd love to hear from you about what kind of encryption features you'd like to see. Some things we've considered but not yet explored yet are:
- Built-in Key Management Server (KMS) with REST API ala AWS or GCP.
- Seamless Integration with external KMS services for key management.
@@ -251,7 +251,7 @@ We're always thinking about the future possibilities for features and tools that
- Group encryption using [signcryption](https://github.com/jedisct1/libsodium-signcryption)
- Your idea here?
There's a lot of potential in the world of cryptography with Postgres and pgsodium, and we'd love to hear any ideas you may have as well. Join us in our [Discord #encryption channel](https://discord.com/channels/839993398554656828/1009906326480101417) if you want to chat more about it with us!
There's a lot of potential in the world of cryptography with Postgres and pgsodium, and we'd love to hear any ideas you may have as well. Join us in our [Discord #encryption channel](https://discord.com/channels/839993398554656828/1009906326480101417) if you want to chat more about it with us!
## More Postgres Resources
+1 -1
View File
@@ -20,7 +20,7 @@
{
"text": "Careers",
"description": "Join the Supabase team and get involved.",
"url": "https://about.supabase.com/careers",
"url": "https://supabase.com/careers",
"icon": "M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
}
]
+1 -1
View File
@@ -229,7 +229,7 @@
"icon": "M15.042 21.672L13.684 16.6m0 0l-2.51 2.225.569-9.47 5.227 7.917-3.286-.672zM12 2.25V4.5m5.834.166l-1.591 1.591M20.25 10.5H18M7.757 14.743l-1.59 1.59M6 10.5H3.75m4.007-4.243l-1.59-1.59",
"features": [
{
"title": "Change Data Capture (CDC)",
"title": "Postgres Changes",
"tiers": {
"free": true,
"pro": true,
+1 -1
View File
@@ -30,7 +30,7 @@
{
"icon": "curious",
"title": "Polyglot",
"text": "Broad skillets. Growth mindset."
"text": "Broad skillsets. Growth mindset."
},
{
"icon": "process",
+7 -2
View File
@@ -1320,8 +1320,8 @@ module.exports = [
},
{
permanent: true,
source: '/docs/guides/realtime/postgres-changes',
destination: '/docs/guides/realtime/postgres-cdc',
source: '/docs/guides/realtime/postgres-cdc',
destination: '/docs/guides/realtime/postgres-changes',
},
{
permanent: true,
@@ -1565,4 +1565,9 @@ module.exports = [
source: '/docs/guides/functions/examples',
destination: '/docs/guides/functions',
},
{
permanent: true,
source: '/projects',
destination: 'https://app.supabase.com/projects',
},
]
+1 -1
View File
@@ -103,7 +103,7 @@ const Team = () => {
</p>
</div>
<div className=" col-span-4 pt-8 md:mt-0 md:text-right">
<a href="https://about.supabase.com/careers">
<a href="https://supabase.com/careers">
<Button size="medium" className="text-white">
Join the team
</Button>
+1 -1
View File
@@ -144,7 +144,7 @@ function RealtimePage() {
Listen to changes in the Database inserts, updates, and deletes and other changes.
</p>
<div className="not-prose mt-3">
<Link passHref href="/docs/guides/realtime/postgres-cdc">
<Link passHref href="/docs/guides/realtime/postgres-changes">
<Button as="a" type="default">
View docs
</Button>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

After

Width:  |  Height:  |  Size: 175 KiB

@@ -0,0 +1,3 @@
# Upload files to Storage
This example shows how to process `multipart/form-data` in an Edge Function. Then, upload files in the request to Supabase Storage and insert other fields as a record to a database table.
@@ -0,0 +1,66 @@
// This example shows how to use Edge Functions to read incoming multipart/form-data request,
// and write files to Supabase Storage and other fields to a database table.
import { Application, Router } from 'https://deno.land/x/oak/mod.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
const MB = 1024 * 1024
const app = new Application()
app.use(async (ctx) => {
const body = ctx.request.body({ type: 'form-data' })
const formData = await body.value.read({
// Need to set the maxSize so files will be stored in memory.
// This is necessary as Edge Functions don't have disk write access.
// We are setting the max size as 10MB (an Edge Function has a max memory limit of 150MB)
// For more config options, check: https://deno.land/x/oak@v11.1.0/mod.ts?s=FormDataReadOptions
maxSize: 10 * MB,
})
if (!formData.files || !formData.files.length) {
ctx.response.status = 400
ctx.response.body = 'missing file'
return
}
const supabaseClient = createClient(
// Supabase API URL - env var exported by default.
Deno.env.get('SUPABASE_URL') ?? '',
// Supabase API ANON KEY - env var exported by default.
Deno.env.get('SUPABASE_ANON_KEY') ?? ''
)
//upload image to Storage
const file = formData.files[0]
const timestamp = +new Date()
const uploadName = `${file.name}-${timestamp}`
const { data: upload, error: uploadError } = await supabaseClient.storage
.from('images')
.upload(uploadName, file.content!.buffer, {
contentType: file.contentType,
cacheControl: '3600',
upsert: false,
})
if (uploadError) {
console.error(uploadError)
ctx.response.status = 500
ctx.response.body = 'Failed to upload the file'
return
}
// insert record to messages table
const { error } = await supabaseClient
.from('comments')
.insert({ message: formData.fields!.message || '', image_path: upload.path })
if (error) {
console.error(error)
ctx.response.status = 500
ctx.response.body = 'Fail to add the record'
return
}
ctx.response.status = 201
ctx.response.body = 'Success!'
})
await app.listen({ port: 8000 })
+41 -17
View File
@@ -27,11 +27,14 @@
Para documentação completa, visite [supabase.com/docs](https://supabase.com/docs)
Para ver como Contribuir, visite [Primeiros Passos](./DEVELOPERS.md)
## Comunidade & Suporte
- [Fórum da comunidade](https://github.com/supabase/supabase/discussions). Melhor para: ajuda com a construção, discussões sobre melhores práticas em banco de dados.
- [GitHub Issues](https://github.com/supabase/supabase/issues). Melhor para: problemas ou erros que você encontrar usando o Supabase.
- [Suporte por E-mail](https://supabase.com/docs/support#business-support). Melhor para: problemas com o seu banco de dados ou infraestrutura.
- [Discord](https://discord.supabase.com). Melhor para: compartilhar seus aplicativos e se divertir com a comunidade.
## Status
@@ -53,6 +56,7 @@ Supabase é uma combinação de ferramentas de código aberto. Nós estamos cons
**Arquitetura atual**
Supabase é uma [plataforma hospedada](https://app.supabase.com). Você pode se cadastrar e começar a usar Supabase sem instalar nada. Nós ainda estamos criando a experiência de trabalho local - esse é o nosso foco principal, junto com a estabilidade da plataforma.
Voce tambem pode [auto-hospedar](https://supabase.com/docs/guides/hosting/overview) e [develop locally](https://supabase.com/docs/guides/local-development).
![Arquitetura](https://user-images.githubusercontent.com/70828596/187547862-ffa9d058-0c3a-4851-a3e7-92ccfca4b596.png)
@@ -66,7 +70,7 @@ Supabase é uma [plataforma hospedada](https://app.supabase.com). Você pode se
#### Bibliotecas de cliente
Nossa biblioteca de cliente é modular. Cada sub-biblioteca é uma implementação independente para cada sistema externo. Esse é uma das formas de apoiar as ferramentas existentes.
Nossa biblioteca de cliente é modular. Cada sub-biblioteca é uma implementação independente para cada sistema externo. Essa é uma das formas de apoiar as ferramentas existentes.
<table style="table-layout:fixed; white-space: nowrap;">
<tr>
@@ -81,6 +85,7 @@ Nossa biblioteca de cliente é modular. Cada sub-biblioteca é uma implementaç
<th><a href="https://github.com/supabase/gotrue" target="_blank" rel="noopener noreferrer">GoTrue</a></th>
<th><a href="https://github.com/supabase/realtime" target="_blank" rel="noopener noreferrer">Realtime</a></th>
<th><a href="https://github.com/supabase/storage-api" target="_blank" rel="noopener noreferrer">Storage</a></th>
<th>Funções</th>
</tr>
<!-- TEMPLATE FOR NEW ROW -->
<!-- START ROW
@@ -93,7 +98,7 @@ Nossa biblioteca de cliente é modular. Cada sub-biblioteca é uma implementaç
<td><a href="https://github.com/supabase-community/storage-lang" target="_blank" rel="noopener noreferrer">storage-lang</a></td>
</tr>
END ROW -->
<th colspan="6">⚡️ Oficial ⚡️</th>
<th colspan="7">⚡️ Oficial ⚡️</th>
<tr>
<td>JavaScript (TypeScript)</td>
<td><a href="https://github.com/supabase/supabase-js" target="_blank" rel="noopener noreferrer">supabase-js</a></td>
@@ -101,8 +106,18 @@ Nossa biblioteca de cliente é modular. Cada sub-biblioteca é uma implementaç
<td><a href="https://github.com/supabase/gotrue-js" target="_blank" rel="noopener noreferrer">gotrue-js</a></td>
<td><a href="https://github.com/supabase/realtime-js" target="_blank" rel="noopener noreferrer">realtime-js</a></td>
<td><a href="https://github.com/supabase/storage-js" target="_blank" rel="noopener noreferrer">storage-js</a></td>
<td><a href="https://github.com/supabase/functions-js" target="_blank" rel="noopener noreferrer">functions-js</a></td>
</tr>
<th colspan="6">💚 Comunidade 💚</th>
<tr>
<td>Flutter</td>
<td><a href="https://github.com/supabase/supabase-flutter" target="_blank" rel="noopener noreferrer">supabase-flutter</a></td>
<td><a href="https://github.com/supabase/postgrest-dart" target="_blank" rel="noopener noreferrer">postgrest-dart</a></td>
<td><a href="https://github.com/supabase/gotrue-dart" target="_blank" rel="noopener noreferrer">gotrue-dart</a></td>
<td><a href="https://github.com/supabase/realtime-dart" target="_blank" rel="noopener noreferrer">realtime-dart</a></td>
<td><a href="https://github.com/supabase/storage-dart" target="_blank" rel="noopener noreferrer">storage-dart</a></td>
<td><a href="https://github.com/supabase/functions-dart" target="_blank" rel="noopener noreferrer">functions-dart</a></td>
</tr>
<th colspan="7">💚 Comunidade 💚</th>
<tr>
<td>C#</td>
<td><a href="https://github.com/supabase-community/supabase-csharp" target="_blank" rel="noopener noreferrer">supabase-csharp</a></td>
@@ -110,22 +125,16 @@ Nossa biblioteca de cliente é modular. Cada sub-biblioteca é uma implementaç
<td><a href="https://github.com/supabase-community/gotrue-csharp" target="_blank" rel="noopener noreferrer">gotrue-csharp</a></td>
<td><a href="https://github.com/supabase-community/realtime-csharp" target="_blank" rel="noopener noreferrer">realtime-csharp</a></td>
<td><a href="https://github.com/supabase-community/storage-csharp" target="_blank" rel="noopener noreferrer">storage-csharp</a></td>
</tr>
<tr>
<td>Flutter</td>
<td><a href="https://github.com/supabase/supabase-flutter" target="_blank" rel="noopener noreferrer">supabase-dart</a></td>
<td><a href="https://github.com/supabase/postgrest-dart" target="_blank" rel="noopener noreferrer">postgrest-dart</a></td>
<td><a href="https://github.com/supabase/gotrue-dart" target="_blank" rel="noopener noreferrer">gotrue-lang</a></td>
<td><a href="https://github.com/supabase/realtime-dart" target="_blank" rel="noopener noreferrer">realtime-dart</a></td>
<td><a href="https://github.com/supabase/storage-dart" target="_blank" rel="noopener noreferrer">storage-dart</a></td>
<td><a href="https://github.com/supabase-community/functions-csharp" target="_blank" rel="noopener noreferrer">functions-csharp</a></td>
</tr>
<tr>
<td>Go</td>
<td>-</td>
<td><a href="https://github.com/supabase-community/postgrest-go" target="_blank" rel="noopener noreferrer">postgrest-go</a></td>
<td><a href="https://github.com/supabase-community/gotrue-go" target="_blank" rel="noopener noreferrer">gotrue-go</a></td>
<td>-</td>
<td>-</td>
<td>-</td>
<td><a href="https://github.com/supabase-community/storage-go" target="_blank" rel="noopener noreferrer">storage-go</a></td>
<td><a href="https://github.com/supabase-community/functions-go" target="_blank" rel="noopener noreferrer">functions-go</a></td>
</tr>
<tr>
<td>Java</td>
@@ -134,14 +143,16 @@ Nossa biblioteca de cliente é modular. Cada sub-biblioteca é uma implementaç
<td><a href="https://github.com/supabase-community/gotrue-java" target="_blank" rel="noopener noreferrer">gotrue-java</a></td>
<td>-</td>
<td>-</td>
<td>-</td>
</tr>
<tr>
<td>Kotlin</td>
<td>-</td>
<td><a href="https://github.com/supabase-community/supabase-kt" target="_blank" rel="noopener noreferrer">supabase-kt</a></td>
<td><a href="https://github.com/supabase-community/postgrest-kt" target="_blank" rel="noopener noreferrer">postgrest-kt</a></td>
<td><a href="https://github.com/supabase-community/gotrue-kt" target="_blank" rel="noopener noreferrer">gotrue-kt</a></td>
<td>-</td>
<td>-</td>
<td><a href="https://github.com/supabase-community/supabase-kt/tree/master/Realtime" target="_blank" rel="noopener noreferrer">realtime-kt</a></td>
<td><a href="https://github.com/supabase-community/supabase-kt/tree/master/Storage" target="_blank" rel="noopener noreferrer">storage-kt</a></td>
<td><a href="https://github.com/supabase-community/supabase-kt/tree/master/Functions" target="_blank" rel="noopener noreferrer">functions-kt</a></td>
</tr>
<tr>
<td>Python</td>
@@ -149,7 +160,8 @@ Nossa biblioteca de cliente é modular. Cada sub-biblioteca é uma implementaç
<td><a href="https://github.com/supabase-community/postgrest-py" target="_blank" rel="noopener noreferrer">postgrest-py</a></td>
<td><a href="https://github.com/supabase-community/gotrue-py" target="_blank" rel="noopener noreferrer">gotrue-py</a></td>
<td><a href="https://github.com/supabase-community/realtime-py" target="_blank" rel="noopener noreferrer">realtime-py</a></td>
<td>-</td>
<td><a href="https://github.com/supabase-community/storage-py" target="_blank" rel="noopener noreferrer">storage-py</a></td>
<td><a href="https://github.com/supabase-community/functions-py" target="_blank" rel="noopener noreferrer">functions-py</a></td>
</tr>
<tr>
<td>Ruby</td>
@@ -158,6 +170,7 @@ Nossa biblioteca de cliente é modular. Cada sub-biblioteca é uma implementaç
<td>-</td>
<td>-</td>
<td>-</td>
<td>-</td>
</tr>
<tr>
<td>Rust</td>
@@ -166,6 +179,7 @@ Nossa biblioteca de cliente é modular. Cada sub-biblioteca é uma implementaç
<td>-</td>
<td>-</td>
<td>-</td>
<td>-</td>
</tr>
<tr>
<td>Swift</td>
@@ -174,6 +188,16 @@ Nossa biblioteca de cliente é modular. Cada sub-biblioteca é uma implementaç
<td><a href="https://github.com/supabase-community/gotrue-swift" target="_blank" rel="noopener noreferrer">gotrue-swift</a></td>
<td><a href="https://github.com/supabase-community/realtime-swift" target="_blank" rel="noopener noreferrer">realtime-swift</a></td>
<td><a href="https://github.com/supabase-community/storage-swift" target="_blank" rel="noopener noreferrer">storage-swift</a></td>
<td><a href="https://github.com/supabase-community/functions-swift" target="_blank" rel="noopener noreferrer">functions-swift</a></td>
</tr>
<tr>
<td>Godot Engine (GDScript)</td>
<td><a href="https://github.com/supabase-community/godot-engine.supabase" target="_blank" rel="noopener noreferrer">supabase-gdscript</a></td>
<td><a href="https://github.com/supabase-community/postgrest-gdscript" target="_blank" rel="noopener noreferrer">postgrest-gdscript</a></td>
<td><a href="https://github.com/supabase-community/gotrue-gdscript" target="_blank" rel="noopener noreferrer">gotrue-gdscript</a></td>
<td><a href="https://github.com/supabase-community/realtime-gdscript" target="_blank" rel="noopener noreferrer">realtime-gdscript</a></td>
<td><a href="https://github.com/supabase-community/storage-gdscript" target="_blank" rel="noopener noreferrer">storage-gdscript</a></td>
<td><a href="https://github.com/supabase-community/functions-gdscript" target="_blank" rel="noopener noreferrer">functions-gdscript</a></td>
</tr>
</table>
@@ -137,7 +137,7 @@ const RowHeader: FC<RowHeaderProps> = ({ sorts, filters }) => {
} else {
const rowIdxs = Array.from(selectedRows) as number[]
const rows = allRows.filter((x) => rowIdxs.includes(x.idx))
const { error } = state.rowService!.delete(rows)
const { error } = await state.rowService!.delete(rows)
if (error) {
if (state.onError) state.onError(error)
} else {
@@ -17,13 +17,13 @@ const RowContextMenu: React.FC<RowContextMenuProps> = ({}) => {
confirmAlert({
title: 'Confirm to delete',
message: 'Are you sure you want to delete this row? This action cannot be undone.',
onConfirm: async () => {
onAsyncConfirm: async () => {
const { props } = p
const { rowIdx } = props
const row = state.rows[rowIdx]
if (!row) return
const { error } = state.rowService!.delete([row])
const { error } = await state.rowService!.delete([row])
if (error) {
if (state.onError) state.onError(error)
} else {
@@ -37,11 +37,7 @@ export class SqlRowService implements IRowService {
}
}
async create(row: SupaRow) {
return { error: { message: 'not implemented' } }
}
delete(rows: SupaRow[]) {
async delete(rows: SupaRow[]) {
const { primaryKeys, error } = this.getPrimaryKeys()
if (error) return { error }
@@ -53,14 +49,7 @@ export class SqlRowService implements IRowService {
})
const query = queryChains.toSql()
SupabaseGridQueue.add(async () => {
const { error } = await this.onSqlQuery(query)
if (error) throw error
}).catch((error) => {
this.onError(error)
})
return {}
return await this.onSqlQuery(query)
}
// For deleting all rows based on a given filter
@@ -3,8 +3,6 @@ import { Filter, ServiceError, Sort, SupaRow } from '../../types'
export interface IRowService {
count: (filters: Filter[]) => Promise<{ data?: number; error?: ServiceError }>
create: (row: SupaRow) => Promise<{ data?: SupaRow; error?: ServiceError }>
fetchPage: (
page: number,
rowsPerPage: number,
@@ -25,7 +23,7 @@ export interface IRowService {
* Right now, we remove rows immediately, so it's confused when delete operation fails
* and rows are already removed from the grid
*/
delete: (rows: SupaRow[]) => { error?: ServiceError }
delete: (rows: SupaRow[]) => Promise<{ error?: ServiceError }>
deleteAll: (filters: Filter[]) => Promise<{ error?: ServiceError }>
update: (
@@ -32,14 +32,16 @@ const PortalToast = () => (
{({ icon, message }) => (
<>
{icon}
{message}
{t.type !== 'loading' && (
<div className="ml-4">
<Button className="!p-1" type="text" onClick={() => toast.dismiss(t.id)}>
<IconX size={14} strokeWidth={2} />
</Button>
</div>
)}
<div className="flex items-center">
<div className="toast-message w-full">{message}</div>
{t.type !== 'loading' && (
<div className="ml-4">
<Button className="!p-1" type="text" onClick={() => toast.dismiss(t.id)}>
<IconX size={14} strokeWidth={2} />
</Button>
</div>
)}
</div>
</>
)}
</ToastBar>
@@ -1,12 +1,17 @@
import { DatabaseAddon } from './AddOns.types'
// e.g supabase-postgres-14.1.0.88 (There's 4 segments instead, so we can't use semver)
export const getSemanticVersion = (version: string) => {
if (!version) return 0
const segments = version.split('-')
// e.g supabase-postgres-14.1.0.88
// There's 4 segments instead so we can't use the semver package
const segments = version.split('supabase-postgres-')
const semver = segments[segments.length - 1]
return Number(semver.split('.').join(''))
// e.g supabase-postgres-14.1.0.99-vault-rc1
const formattedSemver = semver.split('-')[0]
return Number(formattedSemver.split('.').join(''))
}
export const formatComputeSizes = (addons: DatabaseAddon[]) => {
@@ -8,7 +8,6 @@ import { useStore } from 'hooks'
import { post, patch } from 'lib/common/fetch'
import { API_URL, PROJECT_STATUS } from 'lib/constants'
import { CANCELLATION_REASONS } from '../Billing.constants'
import { generateFeedbackMessage } from './ExitSurvey.utils'
import { UpdateSuccess } from '../'
import { SubscriptionPreview } from '../Billing.types'
import { StripeSubscription } from 'components/interfaces/Billing'
@@ -138,14 +137,11 @@ const ExitSurvey: FC<Props> = ({ freeTier, subscription, onSelectBack }) => {
setIsSuccessful(true)
}
}
// Submit exit survey to Hubspot
const feedbackRes = await post(`${API_URL}/feedback/send`, {
const feedbackRes = await post(`${API_URL}/feedback/downgrade`, {
projectRef,
subject: 'Subscription cancellation - Exit survey [Downgrade]',
tags: ['dashboard-exitsurvey'],
category: 'Billing',
message: generateFeedbackMessage(selectedReasons, downgradeMessage),
reasons: selectedReasons.reduce((a, b) => `${a}- ${b}\n`, ''),
additionalFeedback: downgradeMessage,
exitAction: 'downgrade',
})
if (feedbackRes.error) throw feedbackRes.error
} catch (error: any) {
@@ -1,11 +0,0 @@
export const generateFeedbackMessage = (reasons: string[], message: string) => {
return `
Exit survey \n
Reasons for leaving:
${reasons.reduce((a, b) => `${a}- ${b}\n`, '')}
Additional feedback:
${message}
`
}
@@ -71,12 +71,11 @@ const PaymentSummaryPanel: FC<Props> = ({
}) => {
const { ui } = useStore()
const projectRegion = ui.selectedProject?.region
const isOwner = ui.selectedOrganization?.is_owner
const enablePermissions = useFlag('enablePermissions')
const canUpdatePaymentMethods = enablePermissions
? checkPermissions(PermissionAction.BILLING_WRITE, 'stripe.payment_methods')
: isOwner
const canUpdatePaymentMethods = checkPermissions(
PermissionAction.BILLING_WRITE,
'stripe.payment_methods'
)
const [showConfirmModal, setShowConfirmModal] = useState(false)
@@ -311,9 +310,8 @@ const PaymentSummaryPanel: FC<Props> = ({
].join(' ')}
>
<span className="text-xs text-scale-1200">
{enablePermissions
? 'You need additional permissions to add new payment methods to this organization'
: 'Only organization owners can add new payment methods'}
You need additional permissions to add new payment methods to this
organization
</span>
</div>
</Tooltip.Content>
@@ -36,13 +36,13 @@ const Subscription: FC<Props> = ({
const router = useRouter()
const { ref } = router.query
const enablePermissions = useFlag('enablePermissions')
const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate')
const canReadSubscription = checkPermissions(PermissionAction.READ, 'subscriptions')
const canUpdateSubscription = enablePermissions
? checkPermissions(PermissionAction.BILLING_WRITE, 'stripe.subscriptions')
: ui.selectedOrganization?.is_owner
const canUpdateSubscription = checkPermissions(
PermissionAction.BILLING_WRITE,
'stripe.subscriptions'
)
const { usage, isLoading: loadingUsage } = useProjectUsage(ref as string)
@@ -112,10 +112,8 @@ const Subscription: FC<Props> = ({
<br />
Our engineers are working on a fix.
</>
) : !canUpdateSubscription && enablePermissions ? (
) : !canUpdateSubscription ? (
'You need additional permissions to amend subscriptions'
) : !canUpdateSubscription && !enablePermissions ? (
'Only the organization owner can amend subscriptions'
) : (
''
)}
@@ -51,9 +51,9 @@ const ProjectUsage: FC<Props> = ({}) => {
if (timestampDigits < 16) {
// pad unix timestamp with additional 0 and then forward
const paddedTimestamp = String(timestamp) + '0'.repeat(16 - timestampDigits)
router.push(`/project/${ref}/database/api-logs?te=${paddedTimestamp}`)
router.push(`/project/${ref}/logs/edge-logs?te=${paddedTimestamp}`)
} else {
router.push(`/project/${ref}/database/api-logs?te=${timestamp}`)
router.push(`/project/${ref}/logs/edge-logs?te=${timestamp}`)
}
}
@@ -4,7 +4,7 @@ import { Dictionary } from 'components/grid'
import { Form, Input, Button, Listbox } from 'ui'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { checkPermissions, useStore, useFlag } from 'hooks'
import { checkPermissions, useStore } from 'hooks'
import { patch } from 'lib/common/fetch'
import { API_URL } from 'lib/constants'
import { COUNTRIES } from './BillingAddress.constants'
@@ -22,11 +22,11 @@ const BillingAddress: FC<Props> = ({ loading, address, onAddressUpdated }) => {
const orgSlug = ui.selectedOrganization?.slug ?? ''
const { city, country, line1, line2, postal_code, state } = address
const enablePermissions = useFlag('enablePermissions')
const canReadBillingAddress = checkPermissions(PermissionAction.BILLING_READ, 'stripe.customer')
const canUpdateBillingAddress = enablePermissions
? checkPermissions(PermissionAction.BILLING_WRITE, 'stripe.customer')
: ui.selectedOrganization?.is_owner
const canUpdateBillingAddress = checkPermissions(
PermissionAction.BILLING_WRITE,
'stripe.customer'
)
const [isDirty, setIsDirty] = useState(false)
const initialValues = {
@@ -1,17 +1,11 @@
import { useContext, useEffect } from 'react'
import { Form, Input } from 'ui'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useFlag, useStore, checkPermissions } from 'hooks'
import { useStore, checkPermissions } from 'hooks'
import { API_URL } from 'lib/constants'
import { patch } from 'lib/common/fetch'
import {
FormActions,
FormPanel,
FormSection,
FormSectionContent,
FormSectionLabel,
} from 'components/ui/Forms'
import { FormActions, FormPanel, FormSection, FormSectionContent } from 'components/ui/Forms'
import { PageContext } from 'pages/org/[slug]/settings'
@@ -24,12 +18,7 @@ const BillingEmail = () => {
billing_email: PageState.organization?.billing_email ?? '',
}
const enablePermissions = useFlag('enablePermissions')
const canUpdateOrganization = enablePermissions
? checkPermissions(PermissionAction.UPDATE, 'organizations')
: ui.selectedOrganization?.is_owner
const canUpdateOrganization = checkPermissions(PermissionAction.UPDATE, 'organizations')
const canReadBillingEmail = checkPermissions(PermissionAction.READ, 'organizations')
const onUpdateOrganization = async (values: any, { setSubmitting, resetForm }: any) => {
@@ -1,6 +1,6 @@
import { FC, useState, useEffect } from 'react'
import { useFlag, useStore, checkPermissions } from 'hooks'
import { useStore } from 'hooks'
import { API_URL } from 'lib/constants'
import { get } from 'lib/common/fetch'
@@ -9,7 +9,6 @@ import CreditBalance from './CreditBalance'
import PaymentMethods from './PaymentMethods'
import BillingAddress from './BillingAddress/BillingAddress'
import TaxID from './TaxID/TaxID'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import BillingEmail from './BillingEmail'
interface Props {
@@ -39,14 +38,6 @@ const BillingSettings: FC<Props> = ({ organization, projects = [] }) => {
? customerBalance.toString().replace('-', '')
: customerBalance
const enablePermissions = useFlag('enablePermissions')
const canUpdateOrganization = enablePermissions
? checkPermissions(PermissionAction.UPDATE, 'organizations')
: ui.selectedOrganization?.is_owner
const canReadBillingEmail = checkPermissions(PermissionAction.READ, 'organizations')
useEffect(() => {
getCustomerProfile()
getPaymentMethods()
@@ -46,15 +46,14 @@ const PaymentMethods: FC<Props> = ({
const [showAddPaymentMethodModal, setShowAddPaymentMethodModal] = useState(false)
const [isUpdatingPaymentMethod, setIsUpdatingPaymentMethod] = useState(false)
const enablePermissions = useFlag('enablePermissions')
const canReadPaymentMethods = checkPermissions(
PermissionAction.BILLING_READ,
'stripe.payment_methods'
)
const canUpdatePaymentMethods = enablePermissions
? checkPermissions(PermissionAction.BILLING_WRITE, 'stripe.payment_methods')
: isOwner
const canUpdatePaymentMethods = checkPermissions(
PermissionAction.BILLING_WRITE,
'stripe.payment_methods'
)
const onConfirmMakeDefaultPaymentMethod = async () => {
try {
@@ -120,9 +119,7 @@ const PaymentMethods: FC<Props> = ({
<div className="flex w-full justify-between">
{!canUpdatePaymentMethods ? (
<p className="text-sm text-scale-1000">
{enablePermissions
? "You need additional permissions to manage this organization's payment methods"
: 'Only organization owners can update payment methods'}
You need additional permissions to manage this organization's payment methods
</p>
) : (
<div />
@@ -50,12 +50,8 @@ const TaxID: FC<Props> = ({ loading, taxIds, onTaxIdsUpdated }) => {
}, [taxIds])
const hasChanges = !isEqual(taxIdValues, formattedTaxIds)
const enablePermissions = useFlag('enablePermissions')
const canReadTaxIds = checkPermissions(PermissionAction.BILLING_READ, 'stripe.tax_ids')
const canUpdateTaxIds = enablePermissions
? checkPermissions(PermissionAction.BILLING_WRITE, 'stripe.tax_ids')
: ui.selectedOrganization?.is_owner
const canUpdateTaxIds = checkPermissions(PermissionAction.BILLING_WRITE, 'stripe.tax_ids')
const onUpdateTaxId = (id: string, key: string, value: string) => {
const updatedTaxIds = taxIdValues.map((taxId: any) => {
@@ -3,7 +3,7 @@ import { observer } from 'mobx-react-lite'
import { Form, Input } from 'ui'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useFlag, useStore, checkPermissions } from 'hooks'
import { useStore, checkPermissions } from 'hooks'
import { API_URL } from 'lib/constants'
import { patch } from 'lib/common/fetch'
@@ -24,16 +24,8 @@ const GeneralSettings = observer(() => {
const formId = 'org-general-settings'
const initialValues = { name: PageState.organization.name }
const enablePermissions = useFlag('enablePermissions')
const canUpdateOrganization = enablePermissions
? checkPermissions(PermissionAction.UPDATE, 'organizations')
: ui.selectedOrganization?.is_owner
const canDeleteOrganization = enablePermissions
? checkPermissions(PermissionAction.UPDATE, 'organizations')
: ui.selectedOrganization?.is_owner
const canUpdateOrganization = checkPermissions(PermissionAction.UPDATE, 'organizations')
const canDeleteOrganization = checkPermissions(PermissionAction.UPDATE, 'organizations')
const onUpdateOrganization = async (values: any, { setSubmitting, resetForm }: any) => {
if (!canUpdateOrganization) {
@@ -1,12 +1,8 @@
import { IconAlertCircle } from 'ui'
import { useFlag } from 'hooks'
import InformationBox from 'components/ui/InformationBox'
// [Joshen] This can just use NoPermission component i think
const NotOrganizationOwnerWarning = () => {
const enablePermissions = useFlag('enablePermissions')
return (
<div className="mt-4">
<InformationBox
@@ -17,9 +13,7 @@ const NotOrganizationOwnerWarning = () => {
description={
<div className="space-y-3">
<p className="text-sm leading-normal">
{enablePermissions
? 'Contact your organization owner or adminstrator to create a new project.'
: 'Only the organization owner can create new projects. Contact your organization owner to create a new project for this organization.'}
Contact your organization owner or adminstrator to create a new project.
</p>
</div>
}
@@ -6,7 +6,7 @@ import * as Tooltip from '@radix-ui/react-tooltip'
import { Button, Form, IconMail, Input, Modal, Select } from 'ui'
import { Member, User, Role } from 'types'
import { checkPermissions, useFlag, useOrganizationDetail, useStore } from 'hooks'
import { checkPermissions, useOrganizationDetail, useStore } from 'hooks'
import { post } from 'lib/common/fetch'
import { API_URL } from 'lib/constants'
import { PermissionAction } from '@supabase/shared-types/out/constants'
@@ -23,8 +23,6 @@ const InviteMemberButton: FC<Props> = ({ user, members = [], roles = [], rolesAd
const router = useRouter()
const { slug } = router.query
const enablePermissions = useFlag('enablePermissions')
const [isOpen, setIsOpen] = useState(false)
const { mutateOrgMembers } = useOrganizationDetail((slug as string) || '')
@@ -57,16 +55,14 @@ const InviteMemberButton: FC<Props> = ({ user, members = [], roles = [], rolesAd
}
}
const roleId = enablePermissions
? Number(values.role)
: roles.find((role) => role.name === 'Developer')?.id ?? roles[0].id
const roleId = Number(values.role)
setSubmitting(true)
const response = await post(`${API_URL}/organizations/${slug}/members/invite`, {
invited_email: values.email.toLowerCase(),
owner_id: user.id,
...(enablePermissions ? { role_id: roleId } : {}),
role_id: roleId,
})
if (response.error) {
@@ -141,9 +137,7 @@ const InviteMemberButton: FC<Props> = ({ user, members = [], roles = [], rolesAd
}, [roles])
const selectedRole = roles.find((role) => role.id === Number(values.role))
const invalidRoleSelected = enablePermissions
? values.role && !rolesAddable.includes(Number(values.role))
: false
const invalidRoleSelected = values.role && !rolesAddable.includes(Number(values.role))
return (
<>
@@ -151,7 +145,7 @@ const InviteMemberButton: FC<Props> = ({ user, members = [], roles = [], rolesAd
<div className="w-full py-4">
<div className="space-y-4">
<div className="space-y-2">
{roles && enablePermissions && (
{roles && (
<Select
name="role"
label="Member role"
@@ -5,7 +5,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Button, Dropdown, IconTrash, IconMoreHorizontal } from 'ui'
import { Member, Role } from 'types'
import { useStore, useOrganizationDetail, useFlag, checkPermissions } from 'hooks'
import { useStore, useOrganizationDetail, checkPermissions } from 'hooks'
import { delete_, post } from 'lib/common/fetch'
import { API_URL } from 'lib/constants'
import TextConfirmModal from 'components/ui/Modals/TextConfirmModal'
@@ -27,7 +27,6 @@ const MemberActions: FC<Props> = ({ members, member, roles }) => {
const { rolesRemovable } = getRolesManagementPermissions(roles)
const { app, ui } = useStore()
const enablePermissions = useFlag('enablePermissions')
const { mutateOrgMembers } = useOrganizationDetail(ui.selectedOrganization?.slug || '')
const [loading, setLoading] = useState(false)
@@ -37,9 +36,7 @@ const MemberActions: FC<Props> = ({ members, member, roles }) => {
const isPendingInviteAcceptance = member.invited_id
const roleId = member.role_ids?.[0] ?? -1
const canRemoveMember = enablePermissions
? rolesRemovable.includes((member?.role_ids ?? [-1])[0])
: true
const canRemoveMember = rolesRemovable.includes((member?.role_ids ?? [-1])[0])
const canResendInvite = checkPermissions(PermissionAction.CREATE, 'user_invites', {
resource: { role_id: roleId },
})
@@ -54,11 +51,9 @@ const MemberActions: FC<Props> = ({ members, member, roles }) => {
onAsyncConfirm: async () => {
setLoading(true)
const response = enablePermissions
? await delete_(`${API_URL}/organizations/${slug}/members/${member.gotrue_id}`)
: await delete_(`${API_URL}/organizations/${slug}/members/remove`, {
member_id: member.id,
})
const response = await delete_(
`${API_URL}/organizations/${slug}/members/${member.gotrue_id}`
)
if (response.error) {
ui.setNotification({
@@ -67,9 +62,7 @@ const MemberActions: FC<Props> = ({ members, member, roles }) => {
})
setLoading(false)
} else {
const updatedMembers = enablePermissions
? members.filter((m) => m.gotrue_id !== member.gotrue_id)
: members.filter((m) => m.id !== member.id)
const updatedMembers = members.filter((m) => m.gotrue_id !== member.gotrue_id)
mutateOrgMembers(updatedMembers)
ui.setNotification({
@@ -117,7 +110,7 @@ const MemberActions: FC<Props> = ({ members, member, roles }) => {
const response = await post(`${API_URL}/organizations/${slug}/members/invite`, {
invited_email: member.primary_email,
owner_id: member.invited_id,
...(enablePermissions ? { role_id: roleId } : {}),
role_id: roleId,
})
if (response.error) {
@@ -189,7 +182,7 @@ const MemberActions: FC<Props> = ({ members, member, roles }) => {
align="end"
overlay={
<>
{!enablePermissions && !isPendingInviteAcceptance && (
{!isPendingInviteAcceptance && (
<>
<Dropdown.Item onClick={() => setOwnerTransferIsVisible(!ownerTransferIsVisible)}>
<div className="flex flex-col">
@@ -5,7 +5,7 @@ import { observer } from 'mobx-react-lite'
import { Badge, Button, Loading, Listbox, IconUser, Modal } from 'ui'
import { Member, Role } from 'types'
import { useStore, useFlag, useOrganizationDetail } from 'hooks'
import { useStore, useOrganizationDetail } from 'hooks'
import { patch } from 'lib/common/fetch'
import { API_URL } from 'lib/constants'
import { isInviteExpired, getUserDisplayName } from '../Organization.utils'
@@ -28,17 +28,13 @@ const MembersView = () => {
const { ui } = useStore()
const slug = ui.selectedOrganization?.slug || ''
const isOwner = ui.selectedOrganization?.is_owner
const enablePermissions = useFlag('enablePermissions')
const { mutateOrgMembers } = useOrganizationDetail(slug)
const [loading, setLoading] = useState(false)
const [selectedMember, setSelectedMember] = useState<SelectedMember>()
const [userRoleChangeModalVisible, setUserRoleChangeModalVisible] = useState(false)
const hasAccessToMemberActions = enablePermissions ? true : isOwner
const getRoleNameById = (id: number | undefined) => {
if (!roles) return id
return roles.find((x: any) => x.id === id)?.name
@@ -87,7 +83,7 @@ const MembersView = () => {
<Table.th key="header-status"></Table.th>,
<Table.th key="header-role" className="flex items-center space-x-2">
<span>Role</span>
{enablePermissions && <RolesHelperModal />}
<RolesHelperModal />
</Table.th>,
<Table.th key="header-action"></Table.th>,
]}
@@ -157,71 +153,64 @@ const MembersView = () => {
</Table.td>
<Table.td>
{!role && !enablePermissions && <p>{x.is_owner ? 'Owner' : 'Developer'}</p>}
{!role && <p>{x.is_owner ? 'Owner' : 'Developer'}</p>}
{role && (
<>
{!enablePermissions ? (
<p>{role?.name ?? 'Developer'}</p>
) : (
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<Listbox
className={disableRoleEdit ? 'pointer-events-none' : ''}
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<Listbox
className={disableRoleEdit ? 'pointer-events-none' : ''}
disabled={disableRoleEdit}
value={role.id}
onChange={validateSelectedRoleToChange}
>
{roles.map((r: any) => (
<Listbox.Option
key={r.id}
value={r.id}
label={r.name}
disabled={disableRoleEdit}
value={role.id}
onChange={validateSelectedRoleToChange}
>
{roles.map((r: any) => (
<Listbox.Option
key={r.id}
value={r.id}
label={r.name}
disabled={disableRoleEdit}
>
{r.name}
</Listbox.Option>
))}
</Listbox>
</Tooltip.Trigger>
{memberIsPendingInvite ? (
<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">
Role can only be changed after the user has accepted the
invite
</span>
</div>
</Tooltip.Content>
) : !memberIsUser && !canRemoveRole ? (
<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">
You need additional permissions to manage this team member
</span>
</div>
</Tooltip.Content>
) : (
<></>
)}
</Tooltip.Root>
{r.name}
</Listbox.Option>
))}
</Listbox>
</Tooltip.Trigger>
{memberIsPendingInvite ? (
<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">
Role can only be changed after the user has accepted the invite
</span>
</div>
</Tooltip.Content>
) : !memberIsUser && !canRemoveRole ? (
<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">
You need additional permissions to manage this team member
</span>
</div>
</Tooltip.Content>
) : (
<></>
)}
</>
</Tooltip.Root>
)}
</Table.td>
<Table.td>
{hasAccessToMemberActions && !memberIsUser && (
{!memberIsUser && (
<MemberActions members={PageState.members} member={x} roles={roles} />
)}
</Table.td>
@@ -2,7 +2,7 @@ import { useState, useContext } from 'react'
import { observer } from 'mobx-react-lite'
import { Button, Input, IconSearch } from 'ui'
import { useFlag, useStore } from 'hooks'
import { useStore } from 'hooks'
import { post } from 'lib/common/fetch'
import { API_URL } from 'lib/constants'
import InviteMemberButton from './InviteMemberButton'
@@ -21,10 +21,8 @@ const TeamSettings = observer(() => {
const slug = ui.selectedOrganization?.slug ?? ''
const isOwner = ui.selectedOrganization?.is_owner
const enablePermissions = useFlag('enablePermissions')
const [isLeaving, setIsLeaving] = useState(false)
const canAddMembers = enablePermissions ? rolesAddable.length > 0 : isOwner
const canAddMembers = rolesAddable.length > 0
function onFilterMemberChange(e: any) {
PageState.membersFilterString = e.target.value
@@ -35,7 +35,7 @@ const CustomDomainConfig = () => {
return (
<section>
<FormHeader title="Custom Domains" description="" />
<FormHeader title="Custom Domains" description="Present a branded experience to your users" />
{data?.status === '0_no_hostname_configured' ? (
<CustomDomainsConfigureHostname projectRef={ref} />
) : (
@@ -83,9 +83,29 @@ const CustomDomainVerify = ({ projectRef, customDomain, settings }: CustomDomain
: 'Please note that it may take up to 24 hours for the DNS records to propagate.'
}
description={
isNotVerifiedYet
? 'Do check again in a bit as it may take up to 24 hours for changes in DNS records to propagate.'
: undefined
isNotVerifiedYet ? (
<div className="space-y-1">
<p>
Do check again in a bit as it may take up to 24 hours for changes in DNS
records to propagate.
</p>
<p>
You may also visit{' '}
<Link href={`https://whatsmydns.net/#TXT/${customDomain.hostname}`}>
<a className="text-brand-900">here</a>
</Link>{' '}
to check if your DNS has been propagated successfully before clicking verify.
</p>
</div>
) : (
<p>
You may also visit{' '}
<Link href={`https://whatsmydns.net/#TXT/${customDomain.hostname}`}>
<a className="text-brand-900">here</a>
</Link>{' '}
to check if your DNS has been propagated successfully before clicking verify.
</p>
)
}
/>
</div>
@@ -88,7 +88,7 @@ const CustomDomainsConfigureHostname = ({ projectRef }: CustomDomainsConfigureHo
</div>
}
>
<FormSection header={<FormSectionLabel>Add a Custom Domain</FormSectionLabel>}>
<FormSection header={<FormSectionLabel>Add a custom domain</FormSectionLabel>}>
<FormSectionContent loading={false}>
<Input
id="domain"
@@ -4,12 +4,11 @@ import { Button, Input } from 'ui'
import * as Tooltip from '@radix-ui/react-tooltip'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useStore, checkPermissions, useFlag } from 'hooks'
import { useStore, checkPermissions } from 'hooks'
import { API_URL, PRICING_TIER_PRODUCT_IDS } from 'lib/constants'
import { delete_, post } from 'lib/common/fetch'
import TextConfirmModal from 'components/ui/Modals/TextConfirmModal'
import { CANCELLATION_REASONS } from 'components/interfaces/Billing/Billing.constants'
import { generateFeedbackMessage } from 'components/interfaces/Billing/ExitSurvey/ExitSurvey.utils'
interface Props {
type?: 'danger' | 'default'
@@ -18,13 +17,10 @@ interface Props {
const DeleteProjectButton: FC<Props> = ({ type = 'danger' }) => {
const router = useRouter()
const { app, ui } = useStore()
const enablePermissions = useFlag('enablePermissions')
const project = ui.selectedProject
const projectRef = project?.ref
const projectTier = project?.subscription_tier ?? PRICING_TIER_PRODUCT_IDS.FREE
const isOwner = ui.selectedOrganization?.is_owner
const isFree = projectTier === PRICING_TIER_PRODUCT_IDS.FREE
const [isOpen, setIsOpen] = useState(false)
@@ -39,9 +35,7 @@ const DeleteProjectButton: FC<Props> = ({ type = 'danger' }) => {
}
}, [isOpen])
const canDeleteProject = enablePermissions
? checkPermissions(PermissionAction.UPDATE, 'projects')
: isOwner
const canDeleteProject = checkPermissions(PermissionAction.UPDATE, 'projects')
const toggle = () => {
if (loading) return
@@ -85,12 +79,11 @@ const DeleteProjectButton: FC<Props> = ({ type = 'danger' }) => {
// Submit exit survey to Hubspot for paid projects
if (!isFree) {
const feedbackRes = await post(`${API_URL}/feedback/send`, {
const feedbackRes = await post(`${API_URL}/feedback/downgrade`, {
projectRef,
subject: 'Subscription cancellation - Exit survey [Delete]',
tags: ['dashboard-exitsurvey'],
category: 'Billing',
message: generateFeedbackMessage(selectedReasons, cancellationMessage),
reasons: selectedReasons.reduce((a, b) => `${a}- ${b}\n`, ''),
additionalFeedback: cancellationMessage,
exitAction: 'delete',
})
if (feedbackRes.error) throw feedbackRes.error
}
@@ -2,7 +2,7 @@ import { FC } from 'react'
import { Form, Input } from 'ui'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useStore, checkPermissions, useFlag } from 'hooks'
import { useStore, checkPermissions } from 'hooks'
import { API_URL } from 'lib/constants'
import { post } from 'lib/common/fetch'
import {
@@ -20,16 +20,11 @@ const General: FC<Props> = ({}) => {
const { app, ui } = useStore()
const project = ui.selectedProject
const enablePermissions = useFlag('enablePermissions')
const isOwner = ui.selectedOrganization?.is_owner
const formId = 'project-general-settings'
const initialValues = { name: project?.name ?? '', ref: project?.ref ?? '' }
const canUpdateProject = enablePermissions
? checkPermissions(PermissionAction.UPDATE, 'projects')
: isOwner
const canUpdateProject = checkPermissions(PermissionAction.UPDATE, 'projects')
const onSubmit = async (values: any, { setSubmitting, resetForm }: any) => {
const onSubmit = async (values: any, { resetForm }: any) => {
const response = await post(`${API_URL}/projects/${project?.ref}/update`, { name: values.name })
if (response.error) {
ui.setNotification({
@@ -5,7 +5,7 @@ import * as Tooltip from '@radix-ui/react-tooltip'
import { Button, IconPause } from 'ui'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { checkPermissions, useStore, useFlag } from 'hooks'
import { checkPermissions, useStore } from 'hooks'
import { post } from 'lib/common/fetch'
import { API_URL, PROJECT_STATUS } from 'lib/constants'
import ConfirmModal from 'components/ui/Dialogs/ConfirmDialog'
@@ -21,15 +21,13 @@ const PauseProjectButton: FC<Props> = observer(({ projectRef, projectId }) => {
const [loading, setLoading] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false)
const enablePermissions = useFlag('enablePermissions')
const isOwner = ui.selectedOrganization?.is_owner
const openModal = () => setIsModalOpen(true)
const closeModal = () => setIsModalOpen(false)
const canPauseProject = enablePermissions
? checkPermissions(PermissionAction.INFRA_EXECUTE, 'queue_jobs.projects.pause')
: isOwner
const canPauseProject = checkPermissions(
PermissionAction.INFRA_EXECUTE,
'queue_jobs.projects.pause'
)
const requestPauseProject = async () => {
if (!canPauseProject) {
@@ -180,7 +180,9 @@ export const maybeShowUpgradePrompt = (
return (day > 1 && tierKey === 'FREE') || (day > 7 && tierKey === 'PRO') || day > 90 && tierKey === 'ENTERPRISE'
}
export const genCountQuery = (table: string): string => `SELECT count(*) as count FROM ${table}`
export const genCountQuery = (table: LogsTableName, filters: Filters): string => {
const where = _genWhereStatement(table, filters)
return `SELECT count(*) as count FROM ${table} ${where}`}
/** calculates how much the chart start datetime should be offset given the current datetime filter params */
export const calcChartStart = (params: Partial<LogsEndpointParams>): [Dayjs, string] => {
@@ -1,40 +0,0 @@
import { Tabs } from 'ui'
import { useFlag } from 'hooks'
import { useRouter } from 'next/router'
interface TabItem {
label: string
id: string
}
const LogsNavigation = () => {
const router = useRouter()
const activeRoute = router.pathname.split('/')[5]
const { ref } = router.query
const indexRoute = 'query'
const tabs = [
{ id: 'query', label: 'Query' },
{ id: 'saved', label: 'Saved' },
{ id: 'recent', label: 'Recent' },
{ id: 'templates', label: 'Templates' },
].filter(Boolean) as TabItem[]
return (
<Tabs
defaultActiveId="1"
type="underlined"
size="medium"
activeId={!activeRoute ? indexRoute : activeRoute}
onClick={(e: string) => {
router.push(`/project/${ref}/logs/explorer/${e === indexRoute ? '' : e}`)
}}
>
{tabs.map((item) => (
<Tabs.Panel key={item.id} id={item.id} label={item.label} />
))}
</Tabs>
)
}
export default LogsNavigation
@@ -44,6 +44,7 @@ const SignInSSOForm = () => {
window.location.href = data.url
}
} else {
setCaptchaToken(null)
captchaRef.current?.resetCaptcha()
ui.setNotification({
@@ -15,7 +15,8 @@ interface LogsLayoutProps {
const LogsLayout = ({ title, children }: PropsWithChildren<LogsLayoutProps>) => {
const { ui } = useStore()
const router = useRouter()
const page = router.pathname.split('/')[4]
const pathArr = router.pathname.split('/')
const page = pathArr[pathArr.length - 1]
const project = ui.selectedProject
@@ -12,16 +12,24 @@ export const generateLogsMenu = (project?: Project): ProductMenuGroup[] => {
return [
...(IS_PLATFORM
? [
{
title: 'Logs Explorer',
items: [
{ key: 'explorer', name: 'Query', root: true },
{ key: 'saved', name: 'Saved Queries' },
{ key: 'recent', name: 'Recent Queries' },
{ key: 'templates', name: 'Templates' },
].map(({ key, name, root }) => ({
name,
key,
url: `/project/${ref}/logs/explorer${root ? '' : '/' + key}`,
items: [],
})),
},
{
items: [
{
name: 'Logs Explorer',
key: 'explorer',
url: `/project/${ref}/logs/explorer`,
items: [],
},
{
name: 'Edge',
name: 'API Edge Network',
key: 'edge-logs',
url: `/project/${ref}/logs/edge-logs`,
items: [],
@@ -16,21 +16,19 @@ interface Props {
const ProjectPausedState: FC<Props> = ({ project }) => {
const { ui, app } = useStore()
const isOwner = ui.selectedOrganization?.is_owner
const orgSlug = ui.selectedOrganization?.slug
const kpsEnabled = useFlag('initWithKps')
const enablePermissions = useFlag('enablePermissions')
const { membersExceededLimit } = useFreeProjectLimitCheck(orgSlug)
const hasMembersExceedingFreeTierLimit = (membersExceededLimit || []).length > 0
const [showConfirmRestore, setShowConfirmRestore] = useState(false)
const [showFreeProjectLimitWarning, setShowFreeProjectLimitWarning] = useState(false)
const { membersExceededLimit } = useFreeProjectLimitCheck(orgSlug)
const hasMembersExceedingFreeTierLimit = (membersExceededLimit || []).length > 0
const canResumeProject = enablePermissions
? checkPermissions(PermissionAction.INFRA_EXECUTE, 'queue_jobs.projects.initialize_or_resume')
: isOwner
const canResumeProject = checkPermissions(
PermissionAction.INFRA_EXECUTE,
'queue_jobs.projects.initialize_or_resume'
)
const onSelectRestore = () => {
if (!canResumeProject) {
@@ -43,10 +43,15 @@ function ConfirmModal({
loading={loading}
customFooter={
<div className="flex items-center gap-2">
<Button type="default" onClick={() => onCancelClick()}>
<Button type="default" disabled={loading} onClick={() => onCancelClick()}>
Cancel
</Button>
<Button type="primary" onClick={() => onConfirmClick()} loading={loading}>
<Button
type="primary"
disabled={loading}
onClick={() => onConfirmClick()}
loading={loading}
>
{confirmText}
</Button>
</div>
@@ -1,23 +1,23 @@
import LogsNavigation from 'components/interfaces/Settings/Logs/LogsNavigation'
import { IconList } from 'ui'
const LogsExplorerHeader = () => {
return (
<div className={['flex flex-col gap-4 transition-all'].join(' ')}>
<div className="flex items-center gap-3">
<div
className="flex h-6 w-6 items-center justify-center rounded border
border-brand-600 bg-brand-300 text-brand-900
"
>
<IconList size={14} strokeWidth={3} />
</div>
<h1 className="text-2xl text-scale-1200">Logs Explorer</h1>
{/* {subtitle && <Badge color="scale">{subtitle}</Badge>} */}
</div>
<LogsNavigation />
</div>
)
interface Props {
subtitle?: string
}
const LogsExplorerHeader: React.FC<Props> = ({ subtitle }) => (
<div className={['flex flex-col gap-8 transition-all pb-6'].join(' ')}>
<div className="flex items-center gap-3">
<div
className="flex h-6 w-6 items-center justify-center rounded border
border-brand-600 bg-brand-300 text-brand-900
"
>
<IconList size={14} strokeWidth={3} />
</div>
<h1 className="text-2xl text-scale-1200">Logs Explorer</h1>
{subtitle && <span className="text-2xl text-scale-1100">{subtitle}</span>}
</div>
</div>
)
export default LogsExplorerHeader
+1 -1
View File
@@ -26,7 +26,7 @@ const UpgradeToPro: FC<Props> = ({ icon, primaryText, projectRef, secondaryText
>
<div className="flex space-x-3">
{icon && <div className="mt-1">{icon}</div>}
<div className="flex w-full items-center justify-between">
<div className="flex w-full items-center justify-between space-x-32">
<div className="space-y-1">
<p className="text-sm">{primaryText}</p>
<div>
+1 -1
View File
@@ -111,7 +111,7 @@ function useLogsPreview(
return `${API_URL}/projects/${projectRef}/analytics/endpoints/logs.all?${genQueryParams({
...params,
sql: genCountQuery(table),
sql: genCountQuery(table, filters),
iso_timestamp_start: latestRefresh,
} as any)}`
}
+3 -15
View File
@@ -3,25 +3,13 @@ import useSWR, { mutate } from 'swr'
import { Member } from 'types'
import { API_URL } from 'lib/constants'
import { get } from 'lib/common/fetch'
import { useFlag } from 'hooks'
export function useOrganizationDetail(slug: string) {
const enablePermissions = useFlag('enablePermissions')
// Get org members
const url = enablePermissions
? `${API_URL}/organizations/${slug}/members?member_roles`
: `${API_URL}/props/org/${slug}`
const url = `${API_URL}/organizations/${slug}/members?member_roles`
const { data, error } = useSWR<any>(slug ? url : null, get)
let members = enablePermissions
? data
: (data?.members ?? []).map((m: any) => ({
id: m.id,
is_owner: m.is_owner,
username: m.profile.username,
primary_email: m.profile.primary_email,
})) ?? []
let members = data
// Get pending invite users
const pendingInviteUrl = `${API_URL}/organizations/${slug}/members/invite`
@@ -37,7 +25,7 @@ export function useOrganizationDetail(slug: string) {
username: x.invited_email.slice(0, 1),
primary_email: x.invited_email,
}
return enablePermissions ? { ...member, role_ids: [x.role_id] } : member
return { ...member, role_ids: [x.role_id] }
})
members = [...members, ...invitedMembers]
+1 -4
View File
@@ -1,5 +1,5 @@
import jsonLogic from 'json-logic-js'
import { useFlag, useStore } from 'hooks'
import { useStore } from 'hooks'
import { get } from 'lib/common/fetch'
import { API_URL, IS_PLATFORM } from 'lib/constants'
import useSWR from 'swr'
@@ -40,9 +40,6 @@ export function checkPermissions(
) {
if (!IS_PLATFORM) return true
const enablePermissions = useFlag('enablePermissions')
if (!enablePermissions) return true
const { ui } = useStore()
const orgid = organizationId ?? ui?.selectedOrganization?.id
+5
View File
@@ -75,6 +75,11 @@ const nextConfig = {
destination: '/project/:ref/settings/billing/subscription',
permanent: true,
},
{
source: '/project/:ref/database/api-logs',
destination: '/project/:ref/logs/edge-logs',
permanent: true,
},
{
source: '/project/:ref/database/postgres-logs',
destination: '/project/:ref/logs/postgres-logs',
+1 -7
View File
@@ -45,7 +45,6 @@ const Wizard: NextPageWithLayout = () => {
const { slug } = router.query
const { app, ui } = useStore()
const enablePermissions = useFlag('enablePermissions')
const projectCreationDisabled = useFlag('disableProjectCreationAndUpdate')
const kpsEnabled = useFlag('initWithKps')
const subscriptionStats = useSubscriptionStats()
@@ -66,13 +65,8 @@ const Wizard: NextPageWithLayout = () => {
const currentOrg = organizations.find((o: any) => o.slug === slug)
const stripeCustomerId = currentOrg?.stripe_customer_id
const isOrganizationOwner = ui.selectedOrganization?.is_owner
const availableRegions = getAvailableRegions()
const isAdmin = enablePermissions
? checkPermissions(PermissionAction.CREATE, 'projects')
: isOrganizationOwner
const isAdmin = checkPermissions(PermissionAction.CREATE, 'projects')
const isInvalidSlug = isUndefined(currentOrg)
const isEmptyOrganizations = organizations.length <= 0 && app.organizations.isInitialized
const isEmptyPaymentMethod = paymentMethods ? !paymentMethods.length : false
@@ -5,7 +5,6 @@ import { Button, IconClock } from 'ui'
import { useStore } from 'hooks'
import RecentQueriesItem from 'components/interfaces/Settings/Logs/RecentQueriesItem'
import LogsLayout from 'components/layouts/LogsLayout/LogsLayout'
import Table from 'components/to-be-cleaned/Table'
import { useRouter } from 'next/router'
import { LogSqlSnippets, NextPageWithLayout } from 'types'
@@ -23,7 +22,7 @@ export const LogsSavedPage: NextPageWithLayout = () => {
return (
<div className="mx-auto w-full px-5 py-6 h-full">
<LogsExplorerHeader />
<LogsExplorerHeader subtitle="Recent Queries" />
{recent.length > 0 && (
<Table
head={
@@ -26,7 +26,7 @@ export const LogsSavedPage: NextPageWithLayout = () => {
const saved = content.savedLogSqlSnippets()
return (
<div className="mx-auto w-full px-5 py-6 h-full">
<LogsExplorerHeader />
<LogsExplorerHeader subtitle="Saved Queries" />
<div className="flex flex-col gap-3 py-6">
{saved.length > 0 && (
<Table
@@ -1,7 +1,7 @@
import React, { useState } from 'react'
import { useRouter } from 'next/router'
import { observer, useStaticRendering } from 'mobx-react-lite'
import { IconCode, Badge, Collapsible, Button, Popover } from 'ui'
import { observer } from 'mobx-react-lite'
import { IconCode, Button, Popover } from 'ui'
import { TEMPLATES } from 'components/interfaces/Settings/Logs'
import LogsLayout from 'components/layouts/LogsLayout/LogsLayout'
import CardButton from 'components/ui/CardButton'
@@ -14,7 +14,7 @@ export const LogsTemplatesPage: NextPageWithLayout = () => {
return (
<div className="mx-auto h-full w-full px-5 py-6">
<LogsExplorerHeader />
<LogsExplorerHeader subtitle="Templates" />
<div className="grid grid-cols-3 gap-6 mt-4">
{TEMPLATES.sort((a, b) => a.label!.localeCompare(b.label!))
.filter((template) => template.mode === 'custom')
@@ -42,10 +42,7 @@ const ResetDbPassword: FC<any> = () => {
const { ui } = useStore()
const projectRef = ui.selectedProject?.ref
const enablePermissions = useFlag('enablePermissions')
const canResetDbPassword = enablePermissions
? checkPermissions(PermissionAction.UPDATE, 'projects')
: ui.selectedOrganization?.is_owner
const canResetDbPassword = checkPermissions(PermissionAction.UPDATE, 'projects')
const [showResetDbPass, setShowResetDbPass] = useState<boolean>(false)
const [isUpdatingPassword, setIsUpdatingPassword] = useState<boolean>(false)
@@ -19,6 +19,8 @@ export const CONFIRMATION: FormSchema = {
type: 'code',
description: `
- \`{{ .ConfirmationURL }}\` : URL to confirm the message
- \`{{ .Token }}\` : The 6-digit numeric email OTP
- \`{{ .TokenHash }}\` : The hashed token used in the URL
`,
},
},
@@ -48,6 +50,8 @@ export const INVITE: FormSchema = {
type: 'code',
description: `
- \`{{ .ConfirmationURL }}\` : URL to confirm the message
- \`{{ .Token }}\` : The 6-digit numeric email OTP
- \`{{ .TokenHash }}\` : The hashed token used in the URL
`,
},
},
@@ -77,6 +81,8 @@ export const MAGIC_LINK: FormSchema = {
type: 'code',
description: `
- \`{{ .ConfirmationURL }}\` : URL to confirm the message
- \`{{ .Token }}\` : The 6-digit numeric email OTP
- \`{{ .TokenHash }}\` : The hashed token used in the URL
`,
},
},
@@ -106,6 +112,8 @@ export const EMAIL_CHANGE: FormSchema = {
type: 'code',
description: `
- \`{{ .ConfirmationURL }}\` : URL to confirm the email change
- \`{{ .Token }}\` : The 6-digit numeric email OTP
- \`{{ .TokenHash }}\` : The hashed token used in the URL
`,
},
},
@@ -135,6 +143,8 @@ export const RECOVERY: FormSchema = {
type: 'code',
description: `
- \`{{ .ConfirmationURL }}\` : URL to confirm the password reset
- \`{{ .Token }}\` : The 6-digit numeric email OTP
- \`{{ .TokenHash }}\` : The hashed token used in the URL
`,
},
},
+9 -1
View File
@@ -552,4 +552,12 @@
animation-name: Toastify__slideOutDown;
}
/*# sourceMappingURL=ReactToastify.css.map */
.toast-message {
max-width: 258px;
div[role='status'] {
@apply justify-start w-full break-words;
word-break: break-word;
}
}
/*# sourceMappingURL=ReactToastify.css.map */
@@ -369,6 +369,9 @@ test('filters alter generated query', async () => {
userEvent.click(await screen.findByText(/Save/))
await waitFor(() => {
// counts are adjusted
expect(get).toHaveBeenCalledWith(expect.stringMatching(/count.+\*.+as.count.+where.+500.+599/))
expect(get).toHaveBeenCalledWith(expect.stringContaining('500'))
expect(get).toHaveBeenCalledWith(expect.stringContaining('599'))
expect(get).toHaveBeenCalledWith(expect.stringContaining('200'))
@@ -387,6 +390,11 @@ test('filters alter generated query', async () => {
userEvent.click(await screen.findByText(/Save/))
await waitFor(() => {
// counts are adjusted
expect(get).not.toHaveBeenCalledWith(expect.stringMatching(/count.+\*.+as.count.+where.+500.+599/))
expect(get).toHaveBeenCalledWith(expect.stringMatching(/count.+\*.+as.count.+where.+400.+499/))
expect(get).not.toHaveBeenCalledWith(expect.stringContaining('500'))
expect(get).not.toHaveBeenCalledWith(expect.stringContaining('599'))
expect(get).not.toHaveBeenCalledWith(expect.stringContaining('200'))