Merge branch 'master' into feature/docs-walkthrough

This commit is contained in:
Saxon Fletcher committed 2025-07-29 12:39:36 +10:00
commit 4e6aac905d
235 files changed
+14487 -30404

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
/packages/ui/ @supabase/design
/packages/shared-data/pricing.ts @roryw10 @supabase/billing
/packages/shared-data/plans.ts @roryw10 @supabase/billing
/packages/common/telemetry-constants.ts @4L3k51 @supabase/growth-eng
/packages/common/telemetry-constants.ts @supabase/growth-eng
/apps/studio/ @supabase/Dashboard
+1 -1
View File
@@ -8,7 +8,7 @@ on:
permissions:
pull-requests: write
contents: read
contents: write
jobs:
update-docs:
-14
View File
@@ -1,14 +0,0 @@
StylesPath = './vale/styles'
MinAlertLevel = 'suggestion'
Vocab = Supabase
[formats]
mdx = md
[*]
BasedOnStyles = Vale, Custom
BlockIgnores = (?s) *(<StepHikeCompact>.*?</StepHikeCompact>), \
(?s) *(export\s+const\s+\S+\s*=\s*\[.*?\]), \
(?s) *(<Image.*?/>)
TokenIgnores = (href\s*([=:])\s*['"].*?\1), \
+8 -8
View File
@@ -65,7 +65,7 @@ To contribute code to [Supabase](https://supabase.com), you must fork the [Supab
### Install dependencies
1. Install the dependencies in the root of the repo.
1. Install the dependencies in the root of the repo:
```sh
pnpm install # install dependencies
@@ -77,7 +77,7 @@ To contribute code to [Supabase](https://supabase.com), you must fork the [Supab
cp apps/www/.env.local.example apps/www/.env.local
```
3. After that you can run the apps simultaneously with the following.
3. After that you can run the apps simultaneously with the following:
```sh
pnpm dev # start all the applications
```
@@ -117,8 +117,8 @@ Installing a package in a specific workspace requires you to move to the workspa
For example:
1. `cd apps/studio`: move to the `studio` workspace
2. `pnpm add react`: installs `react` into `studio` workspace
1. `cd apps/studio`: move to the `studio` workspace.
2. `pnpm add react`: installs `react` into `studio` workspace.
---
@@ -145,7 +145,7 @@ First, make sure you have the Docker installed on your device. You can download
cp .env.example .env
```
3. Run docker
3. Run docker:
```sh
docker compose up
@@ -186,9 +186,9 @@ Create a new entry in the [`redirects.js`](https://github.com/supabase/supabase/
We support "federating" docs, meaning doc content can come directly from external repos other than [`supabase/supabase`](https://github.com/supabase/supabase).
- It's great for things like client libs who have their own set of docs that we don't want to duplicate on the official Supabase docs (eg. [`supabase/vecs`](https://github.com/supabase/vecs)).
- No duplication or manual steps required - fetches and generates automatically as part of the docs build pipeline
- It's flexible - you can "embed" external docs nearly anywhere at any level in Supabase docs, but they will feel native
- If you are maintaining a repo containing docs that you think could also live in Supabase docs, feel free to create an issue and we can work together to integrate
- No duplication or manual steps required - fetches and generates automatically as part of the docs build pipeline.
- It's flexible - you can "embed" external docs nearly anywhere at any level in Supabase docs, but they will feel native.
- If you are maintaining a repo containing docs that you think could also live in Supabase docs, feel free to create an issue and we can work together to integrate.
Federated docs work using Next.js's build pipeline. We use `getStaticProps()` to fetch remote documentation (ie. markdown) at build time which is processed and passed to the respective page within the docs.
+1
View File
@@ -244,6 +244,7 @@ Our approach for client libraries is modular. Each sub-library is a standalone i
- [Bangla / বাংলা](/i18n/README.bn.md)
- [Bulgarian / Български](/i18n/README.bg.md)
- [Catalan / Català](/i18n/README.ca.md)
- [Croatian / Hrvatski](/i18n/README.hr.md)
- [Czech / čeština](/i18n/README.cs.md)
- [Danish / Dansk](/i18n/README.da.md)
- [Dutch / Nederlands](/i18n/README.nl.md)
@@ -1443,7 +1443,7 @@ export const functions: NavMenuConstant = {
url: undefined,
items: [
{ name: 'Background Tasks', url: '/guides/functions/background-tasks' },
{ name: 'Ephemeral Storage', url: '/guides/functions/ephemeral-storage' },
{ name: 'File Storage', url: '/guides/functions/ephemeral-storage' },
{ name: 'WebSockets', url: '/guides/functions/websockets' },
{ name: 'Custom Routing', url: '/guides/functions/routing' },
{ name: 'Wasm Modules', url: '/guides/functions/wasm' },
+10 -7
View File
@@ -1,16 +1,16 @@
## Project setup
Before we start building we're going to set up our Database and API. This is as simple as starting a new Project in Supabase and then creating a "schema" inside the database.
Before you start building you need to set up the Database and API. You can do this by starting a new Project in Supabase and then creating a "schema" inside the database.
### Create a project
1. [Create a new project](https://supabase.com/dashboard) in the Supabase Dashboard.
1. Enter your project details.
1. Wait for the new database to launch.
2. Enter your project details.
3. Wait for the new database to launch.
### Set up the database schema
Now we are going to set up the database schema. We can use the "User Management Starter" quickstart in the SQL Editor, or you can just copy/paste the SQL from below and run it yourself.
Now set up the database schema. You can use the "User Management Starter" quickstart in the SQL Editor, or you can copy/paste the SQL from below and run it.
<Tabs
scrollable
@@ -29,7 +29,7 @@ Now we are going to set up the database schema. We can use the "User Management
You can pull the database schema down to your local project by running the `db pull` command. Read the [local development docs](/docs/guides/cli/local-development#link-your-project) for detailed instructions.
</Admonition>
{/* TODO: discuss */}
```bash
supabase link --project-ref <project-id>
@@ -37,6 +37,8 @@ supabase link --project-ref <project-id>
supabase db pull
```
</Admonition>
</TabPanel>
<TabPanel id="sql" label="SQL">
@@ -58,7 +60,8 @@ supabase migration new user_management_starter
### Get the API keys
Now that you've created some database tables, you are ready to insert data using the auto-generated API.
We just need to get the Project URL and `anon` key from the API settings.
To do this, you need to get the Project URL and `anon` key from the API settings.
1. Go to the [API Settings](https://supabase.com/dashboard/project/_/settings/api) page in the Dashboard.
1. Find your Project `URL`, `anon`, and `service_role` keys on this page.
2. Find your Project `URL`, `anon`, and `service_role` keys on this page.
@@ -269,6 +269,7 @@ using ( true );
<Admonition type="note" label="Use restrictive policies">
RLS policies are permissive by default, which means that they are combined using an "OR" operator when multiple policies are applied. It is important to construct restrictive policies to ensure that the checks for an anonymous user are always enforced when combined with other policies.
Be aware that a single 'restrictive' RLS policy alone will fail unless combined with another policy that returns true, ensuring the combined condition is met.
</Admonition>
+4 -4
View File
@@ -629,7 +629,7 @@ The PKCE flow allows for server-side authentication. Unlike the implicit flow, w
Update your reset password email template to send the token hash. See [Email Templates](/docs/guides/auth/auth-email-templates) for how to configure your email templates.
Your signup email template should contain the following HTML:
Your reset password email template should contain the following HTML:
```html
<h2>Reset Password</h2>
@@ -960,11 +960,11 @@ Consider configuring a custom SMTP server for production.
See the [Custom SMTP guide](/docs/guides/auth/auth-smtp) for instructions.
#### Local development with Inbucket
#### Local development with Mailpit
You can test email flows on your local machine. The Supabase CLI automatically captures emails sent locally by using [Inbucket](https://github.com/inbucket/inbucket).
You can test email flows on your local machine. The Supabase CLI automatically captures emails sent locally by using [Mailpit](https://github.com/axllent/mailpit).
In your terminal, run `supabase status` to get the Inbucket URL. Go to this URL in your browser, and follow the instructions to find your emails.
In your terminal, run `supabase status` to get the Mailpit URL. Go to this URL in your browser, and follow the instructions to find your emails.
## With phone
@@ -71,7 +71,7 @@ hideToc: true
```ts name=lib/supabase.ts
import { AppState } from 'react-native'
import { AppState, Platform } from 'react-native'
import 'react-native-url-polyfill/auto'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { createClient, processLock } from '@supabase/supabase-js'
@@ -81,7 +81,7 @@ hideToc: true
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
storage: AsyncStorage,
...(Platform.OS !== "web" ? { storage: AsyncStorage } : {}),
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
@@ -94,13 +94,15 @@ hideToc: true
// to receive `onAuthStateChange` events with the `TOKEN_REFRESHED` or
// `SIGNED_OUT` event if the user's session is terminated. This should
// only be registered once.
AppState.addEventListener('change', (state) => {
if (state === 'active') {
supabase.auth.startAutoRefresh()
} else {
supabase.auth.stopAutoRefresh()
}
})
if (Platform.OS !== "web") {
AppState.addEventListener('change', (state) => {
if (state === 'active') {
supabase.auth.startAutoRefresh()
} else {
supabase.auth.stopAutoRefresh()
}
})
}
```
</StepHikeCompact.Code>
@@ -67,7 +67,7 @@ To access Supabase from your Next.js app, you need 2 types of Supabase clients:
1. **Client Component client** - To access Supabase from Client Components, which run in the browser.
1. **Server Component client** - To access Supabase from Server Components, Server Actions, and Route Handlers, which run only on the server.
Create a `utils/supabase` folder with a file for each type of client. Then copy the utility functions for each client type.
Create a `utils/supabase` folder at the root of your project, or inside the `./src` folder if you are using one, with a file for each type of client. Then copy the utility functions for each client type.
<Accordion
type="default"
+1 -1
View File
@@ -29,7 +29,7 @@ Under the hood, Supabase Cron uses the [`pg_cron`](https://github.com/citusdata/
The extension creates a `cron` schema in your database and all Jobs are stored on the `cron.job` table. Every Job's run and its status is recorded on the `cron.job_run_details` table.
The Supabase Dashboard provides and interface for you to schedule Jobs and monitor Job runs. You can also do the same with SQL.
The Supabase Dashboard provides an interface for you to schedule Jobs and monitor Job runs. You can also do the same with SQL.
## Resources
@@ -1,21 +1,52 @@
---
id: 'function-ephemeral-storage'
title: 'Ephemeral Storage'
description: 'Read and write from temporary directory'
subtitle: 'Read and write from temporary directory'
title: 'File Storage'
description: 'Use persistent and ephemeral file storage'
subtitle: 'Use persistent and ephemeral file storage'
---
Edge Functions provides ephemeral file storage. You can read and write files to the `/tmp` directory.
Edge Functions provides two flavors of file storage:
This allows you to:
- Persistent - backed by S3 protocol, can read/write from any S3 compatible bucket, including Supabase Storage
- Ephemeral - You can read and write files to the `/tmp` directory. Only suitable for temporary operations
You can use file storage to:
- Process uploaded files temporarily without permanent storage
- Handle complex file transformations and workflows
- Do data migrations between projects
- Process user uploaded files and store them
- Unzip archives and process contents before saving to database
---
## Overview
## Persistent Storage
The persistent storage option is built on top of the S3 protocol. It allows you to mount any S3-compatible bucket, including Supabase Storage Buckets, as a directory for your Edge Functions.
You can perform operations such as reading and writing files to the mounted buckets as you would in a POSIX file system.
To access an S3 bucket from Edge Functions, you must set the following for environment variables in Edge Function Secrets.
- `S3FS_ENDPOINT_URL`
- `S3FS_REGION`
- `S3FS_ACCESS_KEY_ID`
- `S3FS_SECRET_ACCESS_KEY`
[Follow this guide](https://supabase.com/docs/guides/storage/s3/authentication) to enable and create an access key for Supabase Storage S3.
To access a file path in your mounted bucket from your Edge Function, use the prefix `/s3/YOUR-BUCKET-NAME`.
```tsx
// read from S3 bucket
const data = await Deno.readFile('/s3/my-bucket/results.csv')
// make a directory
await Deno.mkdir('/s3/my-bucket/sub-dir')
// write to S3 bucket
await Deno.writeTextFile('/s3/my-bucket/demo.txt', 'hello world')
```
## Ephemeral storage
Ephemeral storage will reset on each function invocation. This means the files you write during an invocation can only be read within the same invocation.
@@ -128,30 +159,41 @@ Deno.serve(async (req) => {
---
## Limitations
## Using synchronous file APIs
Currently, the synchronous APIs for creating or writing files are not supported:
You can safely use the following synchronous Deno APIs (and their Node counterparts) _during initial script evaluation_:
- Deno.statSync
- Deno.removeSync
- Deno.writeFileSync
- Deno.writeTextFileSync
- Deno.readFileSync
- Deno.readTextFileSync
- Deno.mkdirSync
- Deno.makeTempDirSync
- Deno.readDirSync
**Keep in mind** that the sync APIs are available only during initial script evaluation and aren’t supported in callbacks like HTTP handlers or `setTimeout`.
```tsx
// ❌ Not supported
Deno.writeFileSync('/tmp/file.txt', data)
Deno.mkdirSync('/tmp/directory')
Deno.statSync('...') // ✅
// ✅ Supported
await Deno.writeFile('/tmp/file.txt', data)
await Deno.mkdir('/tmp/directory')
```
setTimeout(() => {
Deno.statSync('...') // 💣 ERROR! Deno.statSync is blocklisted on the current context
})
You can use sync variations of read APIs:
```tsx
// ✅ Supported
const data = Deno.readFileSync('/tmp/file.txt')
Deno.serve(() => {
Deno.statSync('...') // 💣 ERROR! Deno.statSync is blocklisted on the current context
})
```
---
## Limits
There are no limits on S3 buckets you mount for Persistent storage.
Ephemeral Storage:
- Free projects: Up to 256MB of ephemeral storage
- Paid projects: Up to 512MB of ephemeral storage
@@ -17,27 +17,27 @@ If you get stuck while working through this guide, refer to the [full example on
## Building the app
Let's start building the Angular app from scratch.
Start with building the Angular app from scratch.
### Initialize an Angular app
We can use the [Angular CLI](https://angular.io/cli) to initialize
an app called `supabase-angular`:
You can use the [Angular CLI](https://angular.io/cli) to initialize
an app called `supabase-angular`. The command sets some defaults, that you change to suit your needs:
```bash
npx ng new supabase-angular --routing false --style css --standalone false
npx ng new supabase-angular --routing false --style css --standalone false --zoneless true --ssr false
cd supabase-angular
```
Then let's install the only additional dependency: [supabase-js](https://github.com/supabase/supabase-js)
Then, install the only additional dependency: [supabase-js](https://github.com/supabase/supabase-js)
```bash
npm install @supabase/supabase-js
```
And finally we want to save the environment variables in the `src/environments/environment.ts` file.
All we need are the API URL and the `anon` key that you copied [earlier](#get-the-api-keys).
These variables will be exposed on the browser, and that's completely fine since we have [Row Level Security](/docs/guides/auth#row-level-security) enabled on our Database.
Finally, save the environment variables in the `src/environments/environment.ts` file.
All you need are the API URL and the `anon` key that you copied [earlier](#get-the-api-keys).
The application exposes these variables in the browser, and that's fine as you have [Row Level Security](/docs/guides/auth#row-level-security) enabled on the Database.
<$CodeTabs>
@@ -51,7 +51,7 @@ export const environment = {
</$CodeTabs>
Now that we have the API credentials in place, let's create a `SupabaseService` with `ng g s supabase` to initialize the Supabase client and implement functions to communicate with the Supabase API.
Now you have the API credentials in place, create a `SupabaseService` with `ng g s supabase` and add the following code to initialize the Supabase client and implement functions to communicate with the Supabase API.
<$CodeTabs>
@@ -133,37 +133,41 @@ export class SupabaseService {
</$CodeTabs>
Optionally, update [src/styles.css](https://raw.githubusercontent.com/supabase/supabase/master/examples/user-management/angular-user-management/src/styles.css) to style the app.
Optionally, update `src/styles.css` [with the following styles](https://raw.githubusercontent.com/supabase/supabase/master/examples/user-management/angular-user-management/src/styles.css) to style the app.
### Set up a login component
Let's set up an Angular component to manage logins and sign ups. We'll use Magic Links, so users can sign in with their email without using passwords.
Create an `AuthComponent` with `ng g c auth` Angular CLI command.
Next, set up an Angular component to manage logins and sign ups. The component uses [Magic Links](/docs/guides/auth/auth-email-passwordless#with-magic-link), so users can sign in with their email without using passwords.
Create an `AuthComponent` with the `ng g c auth` Angular CLI command and add the following code.
<$CodeTabs>
```ts name=src/app/auth/auth.component.ts
```ts name=src/app/auth/auth.ts
import { Component } from '@angular/core'
import { FormBuilder } from '@angular/forms'
import { FormBuilder, FormGroup } from '@angular/forms'
import { SupabaseService } from '../supabase.service'
@Component({
selector: 'app-auth',
templateUrl: './auth.component.html',
styleUrls: ['./auth.component.css'],
templateUrl: './auth.html',
styleUrls: ['./auth.css'],
standalone: false,
})
export class AuthComponent {
loading = false
signInForm = this.formBuilder.group({
email: '',
})
signInForm!: FormGroup
constructor(
private readonly supabase: SupabaseService,
private readonly formBuilder: FormBuilder
) {}
loading = false
ngOnInit() {
this.signInForm = this.formBuilder.group({
email: '',
})
}
async onSubmit(): Promise<void> {
try {
this.loading = true
@@ -183,7 +187,7 @@ export class AuthComponent {
}
```
```html name=src/app/auth/auth.component.html
```html name=src/app/auth/auth.html
<div class="row flex-center flex">
<div class="col-6 form-widget" aria-live="polite">
<h1 class="header">Supabase + Angular</h1>
@@ -201,7 +205,7 @@ export class AuthComponent {
</div>
<div>
<button type="submit" class="button block" [disabled]="loading">
{{ loading ? 'Loading' : 'Send magic link' }}
{{ loading ? "Loading" : "Send magic link" }}
</button>
</div>
</form>
@@ -214,38 +218,50 @@ export class AuthComponent {
### Account page
Users also need a way to edit their profile details and manage their accounts after signing in.
Create an `AccountComponent` with the `ng g c account` Angular CLI command.
Create an `AccountComponent` with the `ng g c account` Angular CLI command and add the following code.
<$CodeTabs>
```ts name=src/app/account/account.component.ts
```ts name=src/app/account/account.ts
import { Component, Input, OnInit } from '@angular/core'
import { FormBuilder } from '@angular/forms'
import { FormBuilder, FormGroup } from '@angular/forms'
import { AuthSession } from '@supabase/supabase-js'
import { Profile, SupabaseService } from '../supabase.service'
@Component({
selector: 'app-account',
templateUrl: './account.component.html',
styleUrls: ['./account.component.css'],
templateUrl: './account.html',
styleUrls: ['./account.css'],
standalone: false,
})
export class AccountComponent implements OnInit {
loading = false
profile!: Profile
updateProfileForm!: FormGroup
get avatarUrl() {
return this.updateProfileForm.value.avatar_url as string
}
async updateAvatar(event: string): Promise<void> {
this.updateProfileForm.patchValue({
avatar_url: event,
})
await this.updateProfile()
}
@Input()
session!: AuthSession
updateProfileForm = this.formBuilder.group({
username: '',
website: '',
avatar_url: '',
})
constructor(
private readonly supabase: SupabaseService,
private formBuilder: FormBuilder
) {}
) {
this.updateProfileForm = this.formBuilder.group({
username: '',
website: '',
avatar_url: '',
})
}
async ngOnInit(): Promise<void> {
await this.getProfile()
@@ -311,8 +327,9 @@ export class AccountComponent implements OnInit {
}
```
```html name=src/app/account/account.component.html
```html name=src/app/account/account.html
<form [formGroup]="updateProfileForm" (ngSubmit)="updateProfile()" class="form-widget">
<app-avatar [avatarUrl]="this.avatarUrl" (upload)="updateAvatar($event)"> </app-avatar>
<div>
<label for="email">Email</label>
<input id="email" type="text" [value]="session.user.email" disabled />
@@ -328,7 +345,7 @@ export class AccountComponent implements OnInit {
<div>
<button type="submit" class="button primary block" [disabled]="loading">
{{ loading ? 'Loading ...' : 'Update' }}
{{ loading ? "Loading ..." : "Update" }}
</button>
</div>
@@ -342,33 +359,34 @@ export class AccountComponent implements OnInit {
### Launch!
Now that we have all the components in place, let's update `AppComponent`:
Now you have all the components in place, update `AppComponent`:
<$CodeTabs>
```ts name=src/app/app.component.ts
```ts name=src/app/app.ts
import { Component, OnInit } from '@angular/core'
import { SupabaseService } from './supabase.service'
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
templateUrl: './app.html',
styleUrls: ['./app.css'],
standalone: false,
})
export class AppComponent implements OnInit {
title = 'angular-user-management'
session = this.supabase.session
constructor(private readonly supabase: SupabaseService) {}
title = 'angular-user-management'
session: any
ngOnInit() {
this.session = this.supabase.session
this.supabase.authChanges((_, session) => (this.session = session))
}
}
```
```html name=src/app/app.component.html
```html name=src/app/app.html
<div class="container" style="padding: 50px 0 100px 0">
<app-account *ngIf="session; else auth" [session]="session"></app-account>
<ng-template #auth>
@@ -379,7 +397,7 @@ export class AppComponent implements OnInit {
</$CodeTabs>
`app.module.ts` also needs to be modified to include the `ReactiveFormsModule` from the `@angular/forms` package.
You also need to change `app.module.ts` to include the `ReactiveFormsModule` from the `@angular/forms` package.
<$CodeTabs>
@@ -387,32 +405,33 @@ export class AppComponent implements OnInit {
import { NgModule } from '@angular/core'
import { BrowserModule } from '@angular/platform-browser'
import { AppComponent } from './app.component'
import { AuthComponent } from './auth/auth.component'
import { AccountComponent } from './account/account.component'
import { AppComponent } from './app'
import { AuthComponent } from './auth/auth'
import { AccountComponent } from './account/account'
import { ReactiveFormsModule } from '@angular/forms'
import { AvatarComponent } from './avatar/avatar.component'
import { AvatarComponent } from './avatar/avatar'
@NgModule({
declarations: [AppComponent, AuthComponent, AccountComponent, AvatarComponent],
imports: [BrowserModule, ReactiveFormsModule],
providers: [],
bootstrap: [AppComponent],
exports: [AppComponent, AuthComponent, AccountComponent, AvatarComponent],
})
export class AppModule {}
```
</$CodeTabs>
Once that's done, run this in a terminal window:
Once that's done, run the application in a terminal:
```bash
npm run start
```
And then open the browser to [localhost:4200](http://localhost:4200) and you should see the completed app.
Open the browser to [localhost:4200](http://localhost:4200) and you should see the completed app.
![Supabase Angular](/docs/img/supabase-angular-demo.png)
![Screenshot of the Supabase Angular application running in a browser](/docs/img/supabase-angular-demo.png)
## Bonus: Profile photos
@@ -420,20 +439,21 @@ Every Supabase project is configured with [Storage](/docs/guides/storage) for ma
### Create an upload widget
Let's create an avatar for the user so that they can upload a profile photo.
Create an `AvatarComponent` with `ng g c avatar` Angular CLI command.
Create an avatar for the user so that they can upload a profile photo.
Create an `AvatarComponent` with `ng g c avatar` Angular CLI command and add the following code.
<$CodeTabs>
```ts name=src/app/avatar/avatar.component.ts
```ts name=src/app/avatar/avatar.ts
import { Component, EventEmitter, Input, Output } from '@angular/core'
import { SafeResourceUrl, DomSanitizer } from '@angular/platform-browser'
import { SupabaseService } from '../supabase.service'
@Component({
selector: 'app-avatar',
templateUrl: './avatar.component.html',
styleUrls: ['./avatar.component.css'],
templateUrl: './avatar.html',
styleUrls: ['./avatar.css'],
standalone: false,
})
export class AvatarComponent {
_avatarUrl: SafeResourceUrl | undefined
@@ -490,7 +510,7 @@ export class AvatarComponent {
}
```
```html name=src/app/avatar/avatar.component.html
```html name=src/app/avatar/avatar.html
<div>
<img
*ngIf="_avatarUrl"
@@ -503,10 +523,10 @@ export class AvatarComponent {
<div *ngIf="!_avatarUrl" class="avatar no-image" style="height: 150px; width: 150px"></div>
<div style="width: 150px">
<label class="button primary block" for="single">
{{ uploading ? 'Uploading ...' : 'Upload' }}
{{ uploading ? "Uploading ..." : "Upload" }}
</label>
<input
style="visibility: hidden;position: absolute"
style="visibility: hidden; position: absolute"
type="file"
id="single"
accept="image/*"
@@ -524,9 +544,9 @@ And then we can add the widget on top of the `AccountComponent` HTML template:
<$CodeTabs>
```html name=src/app/account.component.html
```html name=src/app/account.html
<form [formGroup]="updateProfileForm" (ngSubmit)="updateProfile()" class="form-widget">
<app-avatar [avatarUrl]="this.avatarUrl" (upload)="updateAvatar($event)"> </app-avatar>
<app-avatar [avatarUrl]="this.avatarUrl" (upload)="updateAvatar($event)"></app-avatar>
<!-- input fields -->
</form>
```
@@ -537,11 +557,11 @@ And add an `updateAvatar` function along with an `avatarUrl` getter to the `Acco
<$CodeTabs>
```ts name=src/app/account.component.ts
```ts name=src/app/account.ts
@Component({
selector: 'app-account',
templateUrl: './account.component.html',
styleUrls: ['./account.component.css'],
templateUrl: './account.html',
styleUrls: ['./account.css'],
})
export class AccountComponent implements OnInit {
// ...
+11 -11
View File
@@ -5,6 +5,17 @@ subtitle: Learn how to develop locally and use the Supabase CLI
Develop locally while running the Supabase stack on your machine.
<Admonition type="note">
As a prerequisite, you must install a container runtime compatible with Docker APIs.
- [Docker Desktop](https://docs.docker.com/desktop/) (macOS, Windows, Linux)
- [Rancher Desktop](https://rancherdesktop.io/) (macOS, Windows, Linux)
- [Podman](https://podman.io/) (macOS, Windows, Linux)
- [OrbStack](https://orbstack.dev/) (macOS)
</Admonition>
## Quickstart
1. Install the Supabase CLI:
@@ -101,17 +112,6 @@ Develop locally while running the Supabase stack on your machine.
</Tabs>
<Admonition type="note">
As a prerequisite, you must install a container runtime compatible with Docker APIs.
- [Docker Desktop](https://docs.docker.com/desktop/) (macOS, Windows, Linux)
- [Rancher Desktop](https://rancherdesktop.io/) (macOS, Windows, Linux)
- [Podman](https://podman.io/) (macOS, Windows, Linux)
- [OrbStack](https://orbstack.dev/) (macOS)
</Admonition>
4. View your local Supabase instance at [http://localhost:54323](http://localhost:54323).
## Local development
@@ -217,7 +217,7 @@ Started supabase local development setup.
API URL: http://localhost:54321
DB URL: postgresql://postgres:postgres@localhost:54322/postgres
Studio URL: http://localhost:54323
Inbucket URL: http://localhost:54324
Mailpit URL: http://localhost:54324
anon key: eyJh......
service_role key: eyJh......
@@ -36,11 +36,11 @@ Edge Functions are powered by Deno, which provides a [native set of testing tool
## Testing Auth emails
The Supabase CLI uses [Inbucket](https://github.com/inbucket/inbucket) to capture emails sent from your local machine. This is useful for testing emails sent from Supabase Auth.
The Supabase CLI uses [Mailpit](https://github.com/axllent/mailpit) to capture emails sent from your local machine. This is useful for testing emails sent from Supabase Auth.
### Accessing Inbucket
### Accessing Mailpit
By default, Inbucket is available at [localhost:54324](http://localhost:54324) when you run `supabase start`. Open this URL in your browser to view the emails.
By default, Mailpit is available at [localhost:54324](http://localhost:54324) when you run `supabase start`. Open this URL in your browser to view the emails.
### Going into production
@@ -260,20 +260,13 @@ The table below shows the actions each role can take on the resources belonging
| | View | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
| | List | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
| | Run | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
| Events Collections | Create | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> | <IconX size={14} /> |
| | Update | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> | <IconX size={14} /> |
| | Delete | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> | <IconX size={14} /> |
| | View | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
| | List | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
| Warehouse Access Tokens | Create | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> | <IconX size={14} /> |
| | Revoke | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> | <IconX size={14} /> |
| | List | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> |
| <a href="#branching-permissions" id="branching-permissions">**Branching**</a> | | | | | |
| Enable branching | - | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> | <IconX size={14} /> |
| Disable branching | - | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> | <IconX size={14} /> |
| Production Branch | Read | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
| | Write | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> |
| Development Branches | List | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
| | Create | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> |
| | Update | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> |
| | Delete | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> |
| | List | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
[^1]: Available on the Team and Enterprise Plans.
[^2]: Sending anonymous data to OpenAI is opt in and can improve Studio AI Assistant's responses.
@@ -27,7 +27,7 @@ As an example, if you start a Pro Plan subscription on January 1 and downgrade t
## Credit top-ups
You can top up credits at any time, with a maximum of <Price price="999" /> per top-up. These credits do not expire and are non-refundable.
You can top up credits at any time, with a maximum of <Price price="2000" /> per top-up. These credits do not expire and are non-refundable.
If you have any outstanding invoices, we’ll automatically use your credits to pay them off. Any remaining credits will be applied to future invoices.
@@ -1,6 +1,6 @@
---
id: 'firestore-data'
title: 'Migrated from Firebase Firestore to Supabase'
title: 'Migrate from Firebase Firestore to Supabase'
description: 'Migrate your Firebase Firestore database to a Supabase Postgres database.'
subtitle: 'Migrate your Firebase Firestore database to a Supabase Postgres database.'
---
@@ -227,7 +227,7 @@ supabase db diff --linked --schema auth,storage > changes.sql
The new project has the old project's Storage buckets, but the Storage objects need to be migrated manually. Use this script to move storage objects from one project to another.
```js
// npm install @supabase/supabase-js@1
// npm install @supabase/supabase-js@2
const { createClient } = require('@supabase/supabase-js')
const OLD_PROJECT_URL = 'https://xxx.supabase.co'
@@ -238,7 +238,9 @@ const NEW_PROJECT_SERVICE_KEY = 'new-project-service-key-yyy'
;(async () => {
const oldSupabaseRestClient = createClient(OLD_PROJECT_URL, OLD_PROJECT_SERVICE_KEY, {
schema: 'storage',
db: {
schema: 'storage',
},
})
const oldSupabaseClient = createClient(OLD_PROJECT_URL, OLD_PROJECT_SERVICE_KEY)
const newSupabaseClient = createClient(NEW_PROJECT_URL, NEW_PROJECT_SERVICE_KEY)
+1 -1
View File
@@ -207,7 +207,7 @@ select * from pgmq.send_batch(
#### `read`
Read 1 or more messages from a queue. The VT specifies the delay in seconds between reading and the message becoming invisible to other consumers.
Read 1 or more messages from a queue. The VT specifies the duration of time in seconds that the message is invisible to other consumers. At the end of that duration, the message is visible again and could be read by other consumers.
{/* prettier-ignore */}
```sql
@@ -25,6 +25,14 @@ This is all the information you need to connect to Supabase Storage using any S3
<img alt="Storage S3 Access keys" src="/docs/img/storage/s3-credentials.png" width="100%" />
<Admonition type="note">
For optimal performance when uploading large files you should always use the direct storage hostname. This provides several performance enhancements that will greatly improve performance when uploading large files.
Instead of `https://project-id.supabase.co` use `https://project-id.storage.supabase.co`
</Admonition>
<Tabs
scrollable
size="small"
@@ -39,7 +47,7 @@ This is all the information you need to connect to Supabase Storage using any S3
const client = new S3Client({
forcePathStyle: true,
region: 'project_region',
endpoint: 'https://project_ref.supabase.co/storage/v1/s3',
endpoint: 'https://project_ref.storage.supabase.co/storage/v1/s3',
credentials: {
accessKeyId: 'your_access_key_id',
secretAccessKey: 'your_secret_access_key',
@@ -55,7 +63,7 @@ This is all the information you need to connect to Supabase Storage using any S3
[supabase]
aws_access_key_id = your_access_key_id
aws_secret_access_key = your_secret_access_key
endpoint_url = https://project_ref.supabase.co/storage/v1/s3
endpoint_url = https://project_ref.storage.supabase.co/storage/v1/s3
region = project_region
```
</TabPanel>
@@ -86,7 +94,7 @@ const {
const client = new S3Client({
forcePathStyle: true,
region: 'project_region',
endpoint: 'https://project_ref.supabase.co/storage/v1/s3',
endpoint: 'https://project_ref.storage.supabase.co/storage/v1/s3',
credentials: {
accessKeyId: 'project_ref',
secretAccessKey: 'anonKey',
@@ -8,13 +8,13 @@ sidebar_label: 'Limits'
## Global file size
You can set the max file size across all your buckets by setting this global value in the dashboard [here](https://supabase.com/dashboard/project/_/settings/storage). For Free projects, the limit can't exceed 50 MB. On the Pro Plan and up, you can set this value to up to 50 GB. If you need more than 50 GB, [contact us](https://supabase.com/dashboard/support/new).
You can set the max file size across all your buckets by setting this global value in the dashboard [here](https://supabase.com/dashboard/project/_/settings/storage). For Free projects, the limit can't exceed 50 MB. On the Pro Plan and up, you can set this value to up to 500 GB. If you need more than 500 GB, [contact us](https://supabase.com/dashboard/support/new).
| Plan | Max File Size Limit |
| ---------- | ------------------- |
| Free | 50 MB |
| Pro | 50 GB |
| Team | 50 GB |
| Pro | 500 GB |
| Team | 500 GB |
| Enterprise | Custom |
<Admonition type="note">
@@ -14,6 +14,14 @@ The resumable upload method is recommended when:
Supabase Storage implements the [TUS protocol](https://tus.io/) to enable resumable uploads. TUS stands for The Upload Server and is an open protocol for supporting resumable uploads. The protocol allows the upload process to be resumed from where it left off in case of interruptions. This method can be implemented using the [`tus-js-client`](https://github.com/tus/tus-js-client) library, or other client-side libraries like [Uppy](https://uppy.io/docs/tus/) that support the TUS protocol.
<Admonition type="note">
For optimal performance when uploading large files you should always use the direct storage hostname. This provides several performance enhancements that will greatly improve performance when uploading large files.
Instead of `https://project-id.supabase.co` use `https://project-id.storage.supabase.co`
</Admonition>
<Tabs
scrollable
size="small"
@@ -35,7 +43,8 @@ Supabase Storage implements the [TUS protocol](https://tus.io/) to enable resuma
return new Promise((resolve, reject) => {
var upload = new tus.Upload(file, {
endpoint: `https://${projectId}.supabase.co/storage/v1/upload/resumable`,
// Supabase TUS endpoint (with direct storage hostname)
endpoint: `https://${projectId}.storage.supabase.co/storage/v1/upload/resumable`,
retryDelays: [0, 3000, 5000, 10000, 20000],
headers: {
authorization: `Bearer ${session.access_token}`,
@@ -125,7 +134,7 @@ Supabase Storage implements the [TUS protocol](https://tus.io/) to enable resuma
// Initialize Uppy instance only once
const [uppy] = useState(() => new Uppy());
// Initialize Supabase client with project URL and anon key
const supabase = createClient(projectURL, anonKey);
const supabase = createClient(`https://${projectId}.supabase.co`, anonKey);
useEffect(() => {
const initializeUppy = async () => {
@@ -135,7 +144,8 @@ Supabase Storage implements the [TUS protocol](https://tus.io/) to enable resuma
} = await supabase.auth.getSession();
uppy.use(Tus, {
endpoint: `${projectURL}/storage/v1/upload/resumable`, // Supabase TUS endpoint
// Supabase TUS endpoint (with direct storage hostname)
endpoint: `https://${projectId}.storage.supabase.co/storage/v1/upload/resumable`,
retryDelays: [0, 3000, 5000, 10000, 20000], // Retry delays for resumable uploads
headers: {
authorization: `Bearer ${session?.access_token}`, // User session access token
@@ -17,7 +17,7 @@ The S3 protocol supports file upload using:
The `PutObject` action uploads the file in a single request. This matches the behavior of the Supabase SDK [Standard Upload](/docs/guides/storage/uploads/standard-uploads).
Use `PutObject` to upload smaller files, where retrying the entire upload won't be an issue. The maximum file size on paid plans is 50 GB.
Use `PutObject` to upload smaller files, where retrying the entire upload won't be an issue. The maximum file size on paid plans is 500 GB.
For example, using JavaScript and the `aws-sdk` client:
@@ -42,7 +42,7 @@ await s3Client.send(uploadCommand)
Multipart Uploads split the file into smaller parts and upload them in parallel, maximizing the upload speed on a fast network. When uploading large files, this allows you to retry the upload of individual parts in case of network issues.
This method is preferable over [Resumable Upload](/docs/guides/storage/uploads/resumable-uploads) for server-side uploads, when you want to maximize upload speed at the cost of resumability. The maximum file size on paid plans is 50 GB.
This method is preferable over [Resumable Upload](/docs/guides/storage/uploads/resumable-uploads) for server-side uploads, when you want to maximize upload speed at the cost of resumability. The maximum file size on paid plans is 500 GB.
### Upload a file in parts
+2 -1
View File
@@ -9,7 +9,7 @@
"build:llms": "tsx ./scripts/llms.ts",
"build:sitemap": "tsx ./internals/generate-sitemap.ts",
"clean": "rimraf .next .turbo node_modules features/docs/generated examples __generated__",
"codegen:examples": "cp -r ../../examples ./examples",
"codegen:examples": "shx cp -r ../../examples ./examples",
"codegen:graphql": "tsx --conditions=react-server ./scripts/graphqlSchema.ts && graphql-codegen --config codegen.ts",
"codegen:references": "tsx features/docs/Reference.generated.script.ts",
"codemod:frontmatter": "node ./scripts/codemod/mdx-meta.mjs && prettier --cache --write \"content/**/*.mdx\"",
@@ -149,6 +149,7 @@
"openapi-types": "^12.1.3",
"postcss": "^8.5.3",
"shiki": "^3.2.1",
"shx": "^0.4.0",
"simple-git": "^3.24.0",
"slugify": "^1.6.6",
"smol-toml": "^1.3.1",
+6
View File
@@ -26,7 +26,9 @@ Chris Caruso
Chris Chandler
Chris Copplestone
Chris Gwilliams
Chris Martin
Chris Stockton
Chris Ward
Craig Cannon
Danny White
Darren Cunningham
@@ -39,6 +41,7 @@ Divit D
Eduardo Gurgel
Egor Romanov
Eleftheria Trivyzaki
Emmett Folger
Etienne Stalmans
Fabrizio Fenoglio
Felipe Stival
@@ -54,6 +57,7 @@ Inian P
Ivan Vasilov
Jenny Kibiri
Jess Shears
Jim Chanco Jr
John Pena
Jon M
Jonny Summers-Muir
@@ -81,6 +85,7 @@ Manan Gupta
Margarita Sandomirskaia
Mark Burggraf
Monica Khoury
Mykhailo Mischa Lieibenson
Nyannyacha
Oli R
Pamela Chia
@@ -104,6 +109,7 @@ Stanislav M
Stephen Morgan
Steve Chavez
Stojan Dimitrovski
Sugu Sougoumarane
Supun Sudaraka Kalidasa
Taryn King
Terry Sutton
File diff suppressed because it is too large. Load diff
+267 -3
View File
@@ -6,6 +6,48 @@
"type": "markdown",
"items": []
},
{
"type": "category",
"title": "Advisors",
"items": [
{
"id": "v1-get-performance-advisors",
"title": "Get performance advisors",
"slug": "v1-get-performance-advisors",
"type": "operation"
},
{
"id": "v1-get-security-advisors",
"title": "Get security advisors",
"slug": "v1-get-security-advisors",
"type": "operation"
}
]
},
{
"type": "category",
"title": "Analytics",
"items": [
{
"id": "v1-get-project-logs",
"title": "Get project logs",
"slug": "v1-get-project-logs",
"type": "operation"
},
{
"id": "v1-get-project-usage-api-count",
"title": "Get project usage api count",
"slug": "v1-get-project-usage-api-count",
"type": "operation"
},
{
"id": "v1-get-project-usage-request-count",
"title": "Get project usage request count",
"slug": "v1-get-project-usage-request-count",
"type": "operation"
}
]
},
{
"type": "category",
"title": "Auth",
@@ -16,12 +58,36 @@
"slug": "v1-create-a-sso-provider",
"type": "operation"
},
{
"id": "v1-create-legacy-signing-key",
"title": "Create legacy signing key",
"slug": "v1-create-legacy-signing-key",
"type": "operation"
},
{
"id": "v1-create-project-signing-key",
"title": "Create project signing key",
"slug": "v1-create-project-signing-key",
"type": "operation"
},
{
"id": "v1-create-project-tpa-integration",
"title": "Create project tpa integration",
"slug": "v1-create-project-tpa-integration",
"type": "operation"
},
{
"id": "v1-delete-a-sso-provider",
"title": "Delete a sso provider",
"slug": "v1-delete-a-sso-provider",
"type": "operation"
},
{
"id": "v1-delete-project-tpa-integration",
"title": "Delete project tpa integration",
"slug": "v1-delete-project-tpa-integration",
"type": "operation"
},
{
"id": "v1-get-a-sso-provider",
"title": "Get a sso provider",
@@ -34,12 +100,48 @@
"slug": "v1-get-auth-service-config",
"type": "operation"
},
{
"id": "v1-get-legacy-signing-key",
"title": "Get legacy signing key",
"slug": "v1-get-legacy-signing-key",
"type": "operation"
},
{
"id": "v1-get-project-signing-key",
"title": "Get project signing key",
"slug": "v1-get-project-signing-key",
"type": "operation"
},
{
"id": "v1-get-project-signing-keys",
"title": "Get project signing keys",
"slug": "v1-get-project-signing-keys",
"type": "operation"
},
{
"id": "v1-get-project-tpa-integration",
"title": "Get project tpa integration",
"slug": "v1-get-project-tpa-integration",
"type": "operation"
},
{
"id": "v1-list-all-sso-provider",
"title": "List all sso provider",
"slug": "v1-list-all-sso-provider",
"type": "operation"
},
{
"id": "v1-list-project-tpa-integrations",
"title": "List project tpa integrations",
"slug": "v1-list-project-tpa-integrations",
"type": "operation"
},
{
"id": "v1-remove-project-signing-key",
"title": "Remove project signing key",
"slug": "v1-remove-project-signing-key",
"type": "operation"
},
{
"id": "v1-update-a-sso-provider",
"title": "Update a sso provider",
@@ -51,6 +153,36 @@
"title": "Update auth service config",
"slug": "v1-update-auth-service-config",
"type": "operation"
},
{
"id": "v1-update-project-signing-key",
"title": "Update project signing key",
"slug": "v1-update-project-signing-key",
"type": "operation"
}
]
},
{
"type": "category",
"title": "Billing",
"items": [
{
"id": "v1-apply-project-addon",
"title": "Apply project addon",
"slug": "v1-apply-project-addon",
"type": "operation"
},
{
"id": "v1-list-project-addons",
"title": "List project addons",
"slug": "v1-list-project-addons",
"type": "operation"
},
{
"id": "v1-remove-project-addon",
"title": "Remove project addon",
"slug": "v1-remove-project-addon",
"type": "operation"
}
]
},
@@ -64,6 +196,12 @@
"slug": "v1-apply-a-migration",
"type": "operation"
},
{
"id": "v1-create-restore-point",
"title": "Create restore point",
"slug": "v1-create-restore-point",
"type": "operation"
},
{
"id": "v1-disable-readonly-mode-temporarily",
"title": "Disable readonly mode temporarily",
@@ -88,6 +226,18 @@
"slug": "v1-get-a-snippet",
"type": "operation"
},
{
"id": "v1-get-database-metadata",
"title": "Get database metadata",
"slug": "v1-get-database-metadata",
"type": "operation"
},
{
"id": "v1-get-pooler-config",
"title": "Get pooler config",
"slug": "v1-get-pooler-config",
"type": "operation"
},
{
"id": "v1-get-postgres-config",
"title": "Get postgres config",
@@ -106,6 +256,12 @@
"slug": "v1-get-readonly-mode-status",
"type": "operation"
},
{
"id": "v1-get-restore-point",
"title": "Get restore point",
"slug": "v1-get-restore-point",
"type": "operation"
},
{
"id": "v1-get-ssl-enforcement-config",
"title": "Get ssl enforcement config",
@@ -125,9 +281,9 @@
"type": "operation"
},
{
"id": "v1-list-migrations",
"title": "List migrations",
"slug": "v1-list-migrations",
"id": "v1-list-migration-history",
"title": "List migration history",
"slug": "v1-list-migration-history",
"type": "operation"
},
{
@@ -154,6 +310,18 @@
"slug": "v1-setup-a-read-replica",
"type": "operation"
},
{
"id": "v1-undo",
"title": "Undo",
"slug": "v1-undo",
"type": "operation"
},
{
"id": "v1-update-pooler-config",
"title": "Update pooler config",
"slug": "v1-update-pooler-config",
"type": "operation"
},
{
"id": "v1-update-postgres-config",
"title": "Update postgres config",
@@ -165,6 +333,12 @@
"title": "Update ssl enforcement config",
"slug": "v1-update-ssl-enforcement-config",
"type": "operation"
},
{
"id": "v1-upsert-a-migration",
"title": "Upsert a migration",
"slug": "v1-upsert-a-migration",
"type": "operation"
}
]
},
@@ -292,12 +466,24 @@
"slug": "v1-delete-a-branch",
"type": "operation"
},
{
"id": "v1-diff-a-branch",
"title": "Diff a branch",
"slug": "v1-diff-a-branch",
"type": "operation"
},
{
"id": "v1-disable-preview-branching",
"title": "Disable preview branching",
"slug": "v1-disable-preview-branching",
"type": "operation"
},
{
"id": "v1-get-a-branch",
"title": "Get a branch",
"slug": "v1-get-a-branch",
"type": "operation"
},
{
"id": "v1-get-a-branch-config",
"title": "Get a branch config",
@@ -352,6 +538,12 @@
"slug": "v1-exchange-oauth-token",
"type": "operation"
},
{
"id": "v1-oauth-authorize-project-claim",
"title": "Oauth authorize project claim",
"slug": "v1-oauth-authorize-project-claim",
"type": "operation"
},
{
"id": "v1-revoke-token",
"title": "Revoke token",
@@ -364,6 +556,12 @@
"type": "category",
"title": "Organizations",
"items": [
{
"id": "v1-claim-project-for-organization",
"title": "Claim project for organization",
"slug": "v1-claim-project-for-organization",
"type": "operation"
},
{
"id": "v1-create-an-organization",
"title": "Create an organization",
@@ -376,6 +574,12 @@
"slug": "v1-get-an-organization",
"type": "operation"
},
{
"id": "v1-get-organization-project-claim",
"title": "Get organization project claim",
"slug": "v1-get-organization-project-claim",
"type": "operation"
},
{
"id": "v1-list-all-organizations",
"title": "List all organizations",
@@ -406,6 +610,12 @@
"slug": "v1-create-a-project",
"type": "operation"
},
{
"id": "v1-create-project-claim-token",
"title": "Create project claim token",
"slug": "v1-create-project-claim-token",
"type": "operation"
},
{
"id": "v1-delete-a-project",
"title": "Delete a project",
@@ -418,6 +628,12 @@
"slug": "v1-delete-network-bans",
"type": "operation"
},
{
"id": "v1-delete-project-claim-token",
"title": "Delete project claim token",
"slug": "v1-delete-project-claim-token",
"type": "operation"
},
{
"id": "v1-get-network-restrictions",
"title": "Get network restrictions",
@@ -442,6 +658,12 @@
"slug": "v1-get-project",
"type": "operation"
},
{
"id": "v1-get-project-claim-token",
"title": "Get project claim token",
"slug": "v1-get-project-claim-token",
"type": "operation"
},
{
"id": "v1-get-services-health",
"title": "Get services health",
@@ -454,6 +676,12 @@
"slug": "v1-list-all-network-bans",
"type": "operation"
},
{
"id": "v1-list-all-network-bans-enriched",
"title": "List all network bans enriched",
"slug": "v1-list-all-network-bans-enriched",
"type": "operation"
},
{
"id": "v1-list-all-projects",
"title": "List all projects",
@@ -526,18 +754,42 @@
"slug": "v1-bulk-delete-secrets",
"type": "operation"
},
{
"id": "v1-create-project-api-key",
"title": "Create project api key",
"slug": "v1-create-project-api-key",
"type": "operation"
},
{
"id": "v1-delete-project-api-key",
"title": "Delete project api key",
"slug": "v1-delete-project-api-key",
"type": "operation"
},
{
"id": "v1-get-pgsodium-config",
"title": "Get pgsodium config",
"slug": "v1-get-pgsodium-config",
"type": "operation"
},
{
"id": "v1-get-project-api-key",
"title": "Get project api key",
"slug": "v1-get-project-api-key",
"type": "operation"
},
{
"id": "v1-get-project-api-keys",
"title": "Get project api keys",
"slug": "v1-get-project-api-keys",
"type": "operation"
},
{
"id": "v1-get-project-legacy-api-keys",
"title": "Get project legacy api keys",
"slug": "v1-get-project-legacy-api-keys",
"type": "operation"
},
{
"id": "v1-list-all-secrets",
"title": "List all secrets",
@@ -549,6 +801,18 @@
"title": "Update pgsodium config",
"slug": "v1-update-pgsodium-config",
"type": "operation"
},
{
"id": "v1-update-project-api-key",
"title": "Update project api key",
"slug": "v1-update-project-api-key",
"type": "operation"
},
{
"id": "v1-update-project-legacy-api-keys",
"title": "Update project legacy api keys",
"slug": "v1-update-project-legacy-api-keys",
"type": "operation"
}
]
},
File diff suppressed because it is too large. Load diff
@@ -3,7 +3,6 @@ import { getConnectionStrings } from 'components/interfaces/Connect/DatabaseSett
import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
import { pluckObjectFields } from 'lib/helpers'
import { useState } from 'react'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import {
Button,
cn,
@@ -23,13 +22,14 @@ import {
import { Admonition } from 'ui-patterns'
interface ExportDialogProps {
table?: { name: string; schema: string }
open: boolean
onOpenChange: (open: boolean) => void
}
export const ExportDialog = ({ open, onOpenChange }: ExportDialogProps) => {
export const ExportDialog = ({ table, open, onOpenChange }: ExportDialogProps) => {
const { ref: projectRef } = useParams()
const snap = useTableEditorTableStateSnapshot()
const [selectedTab, setSelectedTab] = useState<string>('csv')
const { data: databases } = useReadReplicasQuery({ projectRef })
@@ -47,14 +47,14 @@ export const ExportDialog = ({ open, onOpenChange }: ExportDialogProps) => {
poolingInfo: { connectionString: '', db_host: '', db_name: '', db_port: 0, db_user: '' },
})
const outputName = `${snap.table.name}_rows`
const outputName = `${table?.name}_rows`
const csvExportCommand = `
${connectionStrings.direct.psql} -c "COPY (SELECT * FROM "${snap.table.schema}"."${snap.table.name}") TO STDOUT WITH CSV HEADER DELIMITER ',';" > ${outputName}.csv
${connectionStrings.direct.psql} -c "COPY (SELECT * FROM "${table?.schema}"."${table?.name}") TO STDOUT WITH CSV HEADER DELIMITER ',';" > ${outputName}.csv
`.trim()
const sqlExportCommand = `
pg_dump -h ${db_host} -p ${db_port} -d ${db_name} -U ${db_user} --table="${snap.table.schema}.${snap.table.name}" --data-only --column-inserts > ${outputName}.sql
pg_dump -h ${db_host} -p ${db_port} -d ${db_name} -U ${db_user} --table="${table?.schema}.${table?.name}" --data-only --column-inserts > ${outputName}.sql
`.trim()
return (
@@ -17,7 +17,7 @@ import { useTableRowsCountQuery } from 'data/table-rows/table-rows-count-query'
import { fetchAllTableRows, useTableRowsQuery } from 'data/table-rows/table-rows-query'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { RoleImpersonationState } from 'lib/role-impersonation'
import {
useRoleImpersonationStateSnapshot,
@@ -80,10 +80,12 @@ export default Header
const DefaultHeader = () => {
const { ref: projectRef } = useParams()
const tableEditorSnap = useTableEditorStateSnapshot()
const { data: org } = useSelectedOrganizationQuery()
const snap = useTableEditorTableStateSnapshot()
const org = useSelectedOrganization()
const tableEditorSnap = useTableEditorStateSnapshot()
const canCreateColumns = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns')
const { mutate: sendEvent } = useSendEventMutation()
const onAddRow =
@@ -277,7 +279,7 @@ const RowHeader = () => {
})
}
async function onRowsExportCSV() {
const onRowsExportCSV = async () => {
setIsExporting(true)
if (snap.allRowsSelected && totalRows > MAX_EXPORT_ROW_COUNT) {
@@ -349,7 +351,7 @@ const RowHeader = () => {
columns: snap.table!.columns.map((column) => column.name),
})
const csvData = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
toast.success(`Downloaded ${rows.length} rows to CSV`, {
toast.success(`Downloaded ${rows.length} rows as CSV`, {
id: toastId,
closeButton: true,
duration: 4000,
@@ -358,7 +360,7 @@ const RowHeader = () => {
setIsExporting(false)
}
async function onRowsExportSQL() {
const onRowsExportSQL = async () => {
setIsExporting(true)
if (snap.allRowsSelected && totalRows > MAX_EXPORT_ROW_COUNT) {
@@ -423,7 +425,7 @@ const RowHeader = () => {
const sqlStatements = formatTableRowsToSQL(snap.table, rows)
const sqlData = new Blob([sqlStatements], { type: 'text/sql;charset=utf-8;' })
toast.success(`Downloading ${rows.length} rows to SQL`, {
toast.success(`Downloading ${rows.length} rows as SQL`, {
id: toastId,
closeButton: true,
duration: 4000,
@@ -432,6 +434,27 @@ const RowHeader = () => {
setIsExporting(false)
}
const onRowsExportJSON = async () => {
if (!project) {
return toast.error('Project is required')
}
setIsExporting(true)
const toastId = toast.loading(
`Exporting ${snap.selectedRows.size} row${snap.selectedRows.size > 1 ? 's' : ''} from ${snap.table.name}`
)
const rows = allRows.filter((x) => snap.selectedRows.has(x.idx))
const sqlData = new Blob([JSON.stringify(rows)], { type: 'text/sql;charset=utf-8;' })
toast.success(`Downloading ${rows.length} rows as JSON`, {
id: toastId,
closeButton: true,
duration: 4000,
})
saveAs(sqlData, `${snap.table!.name}_rows.json`)
setIsExporting(false)
}
function deselectRows() {
snap.setSelectedRows(new Set())
}
@@ -486,13 +509,15 @@ const RowHeader = () => {
<DropdownMenuItem onClick={onRowsExportSQL}>Export as SQL</DropdownMenuItem>
{/* [Joshen] Should make this available for all cases, but that'll involve updating
the Dialog's SQL output to be dynamic based on any filters applied */}
{snap.allRowsSelected && (
{snap.allRowsSelected ? (
<DropdownMenuItem className="group" onClick={() => setShowExportModal(true)}>
<div>
<p className="group-hover:text-foreground">Export via CLI</p>
<p className="text-foreground-lighter">Recommended for large tables</p>
</div>
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={onRowsExportJSON}>Export as JSON</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
@@ -509,7 +534,11 @@ const RowHeader = () => {
)}
</div>
<ExportDialog open={showExportModal} onOpenChange={() => setShowExportModal(false)} />
<ExportDialog
table={{ name: snap.table.name, schema: snap.table.schema ?? '' }}
open={showExportModal}
onOpenChange={() => setShowExportModal(false)}
/>
</>
)
}
@@ -0,0 +1,97 @@
import { faker } from '@faker-js/faker'
import { screen, waitFor } from '@testing-library/dom'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render } from 'tests/helpers'
import { addAPIMock } from 'tests/lib/msw'
import NewAccessTokenButton from './NewAccessTokenButton'
describe(`NewAccessTokenButton`, () => {
beforeEach(() => {
addAPIMock({
method: `post`,
path: `/platform/profile/access-tokens`,
response: {
name: faker.lorem.word(),
scope: faker.helpers.arrayElement(['V0', undefined]),
created_at: faker.date.past().toISOString(),
id: faker.number.int(),
token_alias: faker.lorem.words(),
token: faker.lorem.words(),
},
})
})
it(`generates regular tokens`, async () => {
const onCreateToken = vi.fn()
render(<NewAccessTokenButton onCreateToken={onCreateToken} />)
const dialogTrigger = screen.getByRole(`button`, { name: `Generate new token` })
await userEvent.click(dialogTrigger)
const nameInput = screen.getByLabelText(`Name`)
await userEvent.type(nameInput, `test{enter}`)
await waitFor(() => expect(onCreateToken).toHaveBeenCalledTimes(1))
})
it(`generates experimental tokens`, async () => {
const onCreateToken = vi.fn()
render(<NewAccessTokenButton onCreateToken={onCreateToken} />)
const dropdownTrigger = screen.getByTitle(`Choose token scope`)
await userEvent.click(dropdownTrigger)
const experimentalMenuItem = await screen.findByRole(`menuitem`, {
name: `Generate token for experimental API`,
})
await userEvent.click(experimentalMenuItem)
await waitFor(async () => {
await expect(
screen.findByRole(`heading`, { name: `Generate token for experimental API` })
).resolves.toBeInTheDocument()
await expect(screen.findByRole(`alert`)).resolves.toBeInTheDocument()
})
const nameInput = screen.getByLabelText(`Name`)
await userEvent.type(nameInput, `test{enter}`)
await waitFor(() => expect(onCreateToken).toHaveBeenCalledTimes(1))
})
it(`resets the form on close/cancel`, async () => {
render(<NewAccessTokenButton onCreateToken={vi.fn()} />)
// pass 1: open dialog and fill form
const dialogTrigger = screen.getByRole(`button`, { name: `Generate new token` })
await userEvent.click(dialogTrigger)
let nameInput = screen.getByLabelText(`Name`)
await userEvent.type(nameInput, `cancel button test`)
expect(nameInput).toHaveValue(`cancel button test`)
// reset the form by pressing the cancel button
const cancelButton = screen.getByRole(`button`, { name: `Cancel` })
await userEvent.click(cancelButton)
// pass 2: check that the form is reset, then fill it again
await userEvent.click(dialogTrigger)
nameInput = screen.getByLabelText(`Name`)
expect(nameInput).not.toHaveValue(`cancel button test`)
await userEvent.type(nameInput, `close modal test`)
expect(nameInput).toHaveValue(`close modal test`)
// reset the form by closing the dialog
await userEvent.keyboard(`{Escape}`)
// pass 3: check that the form has been rest again
await userEvent.click(dialogTrigger)
nameInput = screen.getByLabelText(`Name`)
expect(nameInput).not.toHaveValue(`close modal test`)
})
})
@@ -1,157 +1,187 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { ChevronDown, ExternalLink } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { type SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'
import { useAccessTokenCreateMutation } from 'data/access-tokens/access-tokens-create-mutation'
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTitle,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Form,
Input,
Modal,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input_Shadcn_,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
export interface NewAccessTokenButtonProps {
onCreateToken: (token: any) => void
}
const TokenSchema = z.object({
tokenName: z.string().min(1, 'Please enter a name for the token'),
})
const formId = 'new-access-token-form'
const NewAccessTokenButton = ({ onCreateToken }: NewAccessTokenButtonProps) => {
const [isOpen, setIsOpen] = useState(false)
const [visible, setVisible] = useState(false)
const [tokenScope, setTokenScope] = useState<'V0' | undefined>(undefined)
const validate = (values: any) => {
const errors: any = {}
if (!values.tokenName) errors.tokenName = 'Please enter a name for the token'
return errors
}
const { mutate: createAccessToken, isLoading } = useAccessTokenCreateMutation({
onSuccess: (res) => {
onCreateToken(res)
setIsOpen(false)
},
const form = useForm<z.infer<typeof TokenSchema>>({
resolver: zodResolver(TokenSchema),
defaultValues: { tokenName: '' },
mode: 'onSubmit',
})
const { mutate: createAccessToken, isLoading } = useAccessTokenCreateMutation()
const onFormSubmit = async (values: any) => {
createAccessToken({ name: values.tokenName, scope: tokenScope })
const onSubmit: SubmitHandler<z.infer<typeof TokenSchema>> = async (values) => {
createAccessToken(
{ name: values.tokenName, scope: tokenScope },
{
onSuccess: (data) => {
toast.success(`Your access token "${data.name}" is ready.`)
form.reset()
onCreateToken(data)
setVisible(false)
},
}
)
}
return (
<>
<div className="container max-w-7xl">
<div className="flex justify-between">
<div className="flex items-center">
<div className="flex items-center">
<Button
className="rounded-r-none px-3"
onClick={() => {
setTokenScope(undefined)
setVisible(true)
}}
>
Generate new token
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="rounded-r-none px-3"
type="primary"
title="Choose token scope"
className="rounded-l-none px-[4px] py-[5px]"
icon={<ChevronDown />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom">
<DropdownMenuItem
key="experimental-token"
onClick={() => {
setTokenScope(undefined)
setIsOpen(true)
setTokenScope('V0')
setVisible(true)
}}
>
Generate new token
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="primary"
className="rounded-l-none px-[4px] py-[5px]"
icon={<ChevronDown />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom">
<DropdownMenuItem
key="experimental-token"
onClick={() => {
setTokenScope('V0')
setIsOpen(true)
}}
>
<div className="space-y-1">
<p className="block text-foreground">Generate token for experimental API</p>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="space-y-1">
<p className="block text-foreground">Generate token for experimental API</p>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Modal
hideFooter
size="small"
visible={isOpen}
onCancel={() => setIsOpen(!isOpen)}
header={
<div className="flex items-baseline gap-2">
<h5 className="text-sm text-foreground">
{tokenScope === 'V0' ? 'Generate token for experimental API' : 'Generate New Token'}
</h5>
</div>
}
<Dialog
open={visible}
onOpenChange={(open) => {
if (!open) form.reset()
setVisible(open)
}}
>
<Form
validateOnBlur
initialValues={{ tokenName: '' }}
onSubmit={onFormSubmit}
validate={validate}
>
{() => (
<>
{tokenScope === 'V0' && (
<Modal.Content>
<Admonition
type="warning"
title="The experimental API provides additional endpoints which allows you to manage
your organizations and projects."
description={
<>
<p>
These include deleting organizations and projects which cannot be undone.
As such, be very careful when using this API.
</p>
<div className="mt-4">
<Button asChild type="default" icon={<ExternalLink />}>
<Link
href="https://api.supabase.com/api/v0"
target="_blank"
rel="noreferrer"
>
Experimental API documentation
</Link>
</Button>
</div>
</>
}
/>
</Modal.Content>
)}
<Modal.Content>
<Input
id="tokenName"
label="Name"
placeholder="Provide a name for your token"
className="w-full"
<DialogContent>
<DialogHeader>
<DialogTitle>
{tokenScope === 'V0' ? 'Generate token for experimental API' : 'Generate New Token'}
</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
<DialogSection className="flex flex-col gap-4">
{tokenScope === 'V0' && (
<Admonition
type="warning"
title="The experimental API provides additional endpoints which allows you to manage your organizations and projects."
description={
<>
<p>
These include deleting organizations and projects which cannot be undone. As
such, be very careful when using this API.
</p>
<div className="mt-4">
<Button asChild type="default" icon={<ExternalLink />}>
<Link
href="https://api.supabase.com/api/v0"
target="_blank"
rel="noreferrer"
>
Experimental API documentation
</Link>
</Button>
</div>
</>
}
/>
)}
<Form_Shadcn_ {...form}>
<form
id={formId}
className="flex flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField_Shadcn_
key="tokenName"
name="tokenName"
control={form.control}
render={({ field }) => (
<FormItemLayout name="tokenName" label="Name">
<FormControl_Shadcn_>
<Input_Shadcn_
id="tokenName"
{...field}
placeholder="Provide a name for your token"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</Modal.Content>
<DialogFooter>
<div className="flex items-center space-x-2 justify-end">
<Button type="default" disabled={isLoading} onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button htmlType="submit" loading={isLoading} disabled={isLoading}>
Generate token
</Button>
</div>
</DialogFooter>
</>
)}
</Form>
</Modal>
</form>
</Form_Shadcn_>
</DialogSection>
<DialogFooter>
<Button
type="default"
disabled={isLoading}
onClick={() => {
form.reset()
setVisible(false)
}}
>
Cancel
</Button>
<Button form={formId} htmlType="submit" loading={isLoading}>
Generate token
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
@@ -9,7 +9,6 @@ import { Modal } from 'ui'
import { useOrganizationPaymentMethodSetupIntent } from 'data/organizations/organization-payment-method-setup-intent-mutation'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { STRIPE_PUBLIC_KEY } from 'lib/constants'
import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store'
import AddPaymentMethodForm from './AddPaymentMethodForm'
import { getStripeElementsAppearanceOptions } from './Payment.utils'
@@ -18,8 +17,6 @@ interface AddNewPaymentMethodModalProps {
returnUrl: string
onCancel: () => void
onConfirm: () => void
showSetDefaultCheckbox?: boolean
autoMarkAsDefaultPaymentMethod?: boolean
}
const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
@@ -29,14 +26,11 @@ const AddNewPaymentMethodModal = ({
returnUrl,
onCancel,
onConfirm,
showSetDefaultCheckbox,
autoMarkAsDefaultPaymentMethod,
}: AddNewPaymentMethodModalProps) => {
const { resolvedTheme } = useTheme()
const [intent, setIntent] = useState<any>()
const selectedOrganization = useSelectedOrganization()
const captchaLoaded = useIsHCaptchaLoaded()
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
const [captchaRef, setCaptchaRef] = useState<HCaptcha | null>(null)
@@ -64,7 +58,7 @@ const AddNewPaymentMethodModal = ({
}
const loadPaymentForm = async () => {
if (visible && captchaRef && captchaLoaded) {
if (visible && captchaRef) {
let token = captchaToken
try {
@@ -82,7 +76,7 @@ const AddNewPaymentMethodModal = ({
}
loadPaymentForm()
}, [visible, captchaRef, captchaLoaded])
}, [visible, captchaRef])
const resetCaptcha = () => {
setCaptchaToken(null)
@@ -142,8 +136,6 @@ const AddNewPaymentMethodModal = ({
returnUrl={returnUrl}
onCancel={onLocalCancel}
onConfirm={onLocalConfirm}
showSetDefaultCheckbox={showSetDefaultCheckbox}
autoMarkAsDefaultPaymentMethod={autoMarkAsDefaultPaymentMethod}
/>
</Elements>
</Modal>
@@ -1,49 +1,56 @@
import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js'
import { useQueryClient } from '@tanstack/react-query'
import {
NewPaymentMethodElement,
type PaymentMethodElementRef,
} from 'components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement'
import { organizationKeys } from 'data/organizations/keys'
import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query'
import { useOrganizationCustomerProfileUpdateMutation } from 'data/organizations/organization-customer-profile-update-mutation'
import { useOrganizationPaymentMethodMarkAsDefaultMutation } from 'data/organizations/organization-payment-method-default-mutation'
import { useOrganizationTaxIdQuery } from 'data/organizations/organization-tax-id-query'
import { useOrganizationTaxIdUpdateMutation } from 'data/organizations/organization-tax-id-update-mutation'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useState } from 'react'
import { isEqual } from 'lodash'
import { useRef, useState } from 'react'
import { toast } from 'sonner'
import { Button, Checkbox_Shadcn_, Label_Shadcn_, Modal } from 'ui'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
interface AddPaymentMethodFormProps {
returnUrl: string
onCancel: () => void
onConfirm: () => void
showSetDefaultCheckbox?: boolean
autoMarkAsDefaultPaymentMethod?: boolean
}
// Stripe docs recommend to use the new SetupIntent flow over
// manually creating and attaching payment methods via the API
// Small UX annoyance here, that the page will be refreshed
const AddPaymentMethodForm = ({
returnUrl,
onCancel,
onConfirm,
showSetDefaultCheckbox = false,
autoMarkAsDefaultPaymentMethod = false,
}: AddPaymentMethodFormProps) => {
const stripe = useStripe()
const elements = useElements()
const AddPaymentMethodForm = ({ onCancel, onConfirm }: AddPaymentMethodFormProps) => {
const selectedOrganization = useSelectedOrganization()
const { data: customerProfile, isLoading: customerProfileLoading } =
useOrganizationCustomerProfileQuery({
slug: selectedOrganization?.slug,
})
const [isSaving, setIsSaving] = useState(false)
const [isDefault, setIsDefault] = useState(showSetDefaultCheckbox)
const [isDefaultPaymentMethod, setIsDefaultPaymentMethod] = useState(true)
const [isPrimaryBillingAddress, setIsPrimaryBillingAddress] = useState(true)
const queryClient = useQueryClient()
const { mutateAsync: markAsDefault } = useOrganizationPaymentMethodMarkAsDefaultMutation()
const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation()
const { mutateAsync: updateTaxId } = useOrganizationTaxIdUpdateMutation()
const { data: taxId, isLoading: isCustomerTaxIdLoading } = useOrganizationTaxIdQuery({
slug: selectedOrganization?.slug,
})
const paymentRef = useRef<PaymentMethodElementRef | null>(null)
const handleSubmit = async (event: any) => {
event.preventDefault()
if (!stripe || !elements) {
console.error('Stripe.js has not loaded')
return
}
setIsSaving(true)
if (document !== undefined) {
@@ -51,25 +58,20 @@ const AddPaymentMethodForm = ({
document.body.classList.add('!pointer-events-auto')
}
const { error, setupIntent } = await stripe.confirmSetup({
elements,
redirect: 'if_required',
confirmParams: { return_url: returnUrl },
})
const result = await paymentRef.current?.confirmSetup()
if (error) {
if (!result) {
setIsSaving(false)
toast.error(error?.message ?? ' Failed to save card details')
} else {
if (
(isDefault || autoMarkAsDefaultPaymentMethod) &&
isDefaultPaymentMethod &&
selectedOrganization &&
typeof setupIntent?.payment_method === 'string'
typeof result.setupIntent?.payment_method === 'string'
) {
try {
await markAsDefault({
slug: selectedOrganization.slug,
paymentMethodId: setupIntent.payment_method,
paymentMethodId: result.setupIntent.payment_method,
})
await queryClient.invalidateQueries(
@@ -82,10 +84,10 @@ const AddPaymentMethodForm = ({
if (!prev) return prev
return {
...prev,
defaultPaymentMethodId: setupIntent.payment_method,
defaultPaymentMethodId: result.setupIntent.payment_method,
data: prev.data.map((pm: any) => ({
...pm,
is_default: pm.id === setupIntent.payment_method,
is_default: pm.id === result.setupIntent.payment_method,
})),
}
}
@@ -101,6 +103,28 @@ const AddPaymentMethodForm = ({
}
}
if (isPrimaryBillingAddress) {
try {
if (
result.address &&
(!isEqual(result.address, customerProfile?.address) ||
customerProfile?.billing_name !== result.customerName)
) {
await updateCustomerProfile({
slug: selectedOrganization?.slug,
billing_name: result.customerName,
address: result.address,
})
}
if (result.taxId && !isEqual(result.taxId, taxId)) {
await updateTaxId({ taxId: result.taxId, slug: selectedOrganization?.slug })
}
} catch (error) {
toast.error('Failed to update billing address')
}
}
setIsSaving(false)
onConfirm()
}
@@ -110,33 +134,64 @@ const AddPaymentMethodForm = ({
}
}
if (customerProfileLoading || isCustomerTaxIdLoading) {
return (
<Modal.Content>
<div className="space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
<ShimmeringLoader />
<ShimmeringLoader />
<ShimmeringLoader />
</div>
</Modal.Content>
)
}
return (
<div>
<Modal.Content
className={`transition ${isSaving ? 'pointer-events-none opacity-75' : 'opacity-100'}`}
>
<PaymentElement
className="[.p-LinkAutofillPrompt]:pt-0"
options={{
defaultValues: { billingDetails: { email: selectedOrganization?.billing_email ?? '' } },
}}
<NewPaymentMethodElement
readOnly={isSaving}
email={selectedOrganization?.billing_email}
currentAddress={customerProfile?.address}
customerName={customerProfile?.billing_name}
currentTaxId={taxId}
ref={paymentRef}
/>
{showSetDefaultCheckbox && (
<div className="flex items-center gap-x-2 mt-4 mb-2">
<Checkbox_Shadcn_
id="save-as-default"
checked={isDefault}
onCheckedChange={(checked) => {
if (typeof checked === 'boolean') {
setIsDefault(checked)
}
}}
/>
<Label_Shadcn_ htmlFor="save-as-default" className="text-foreground-light">
Save as default payment method
</Label_Shadcn_>
</div>
)}
<div className="flex items-center gap-x-2 mt-4 mb-2">
<Checkbox_Shadcn_
id="save-as-default"
checked={isDefaultPaymentMethod}
onCheckedChange={(checked) => {
if (typeof checked === 'boolean') {
setIsDefaultPaymentMethod(checked)
}
}}
/>
<Label_Shadcn_ htmlFor="save-as-default" className="text-foreground-light">
Save as default payment method
</Label_Shadcn_>
</div>
<div className="flex items-center gap-x-2 mt-4 mb-2">
<Checkbox_Shadcn_
id="is-primary-billing-address"
checked={isPrimaryBillingAddress}
onCheckedChange={(checked) => {
if (typeof checked === 'boolean') {
setIsPrimaryBillingAddress(checked)
}
}}
/>
<Label_Shadcn_ htmlFor="is-primary-billing-address" className="text-foreground-light">
Use the billing address as my organization's primary address
</Label_Shadcn_>
</div>
</Modal.Content>
<Modal.Separator />
<Modal.Content className="flex items-center space-x-2">
@@ -4,6 +4,7 @@ export const getStripeElementsAppearanceOptions = (
resolvedTheme: string | undefined
): Appearance => {
return {
labels: 'floating',
theme: (resolvedTheme?.includes('dark') ? 'night' : 'flat') as 'night' | 'flat',
variables: {
fontSizeBase: '14px',
@@ -12,18 +13,27 @@ export const getStripeElementsAppearanceOptions = (
: 'hsl(0deg 0% 95.3%)',
fontFamily:
'var(--font-custom, Circular, custom-font, Helvetica Neue, Helvetica, Arial, sans-serif)',
spacingUnit: '4px',
borderRadius: '.375rem',
gridRowSpacing: '4px',
},
rules: {
'.Label': {
// Hide labels - it is obvious enough what the fields are for
fontSize: '0',
},
'.TermsText': {
fontSize: '12px',
},
'.Label--floating': {
fontSize: '14px',
},
'.Label--resting': {
fontSize: '14px',
color: 'rgb(137, 137, 137)',
},
'.Input': {
boxShadow: 'none',
height: '34px',
lineHeight: '16px',
padding: '8px 12px',
},
'.AccordionItem': {
boxShadow: 'none',
},
},
}
}
@@ -42,13 +42,16 @@ import {
Form_Shadcn_,
Input_Shadcn_,
Label_Shadcn_ as Label,
Switch,
cn,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useFlag } from 'hooks/ui/useFlag'
export const CreateBranchModal = () => {
const allowDataBranching = useFlag('allowDataBranching')
const { ref } = useParams()
const router = useRouter()
const queryClient = useQueryClient()
@@ -141,6 +144,7 @@ export const CreateBranchModal = () => {
(val) => gitlessBranching || !githubConnection || (val && val.length > 0),
'Git branch name is required when GitHub is connected'
),
withData: z.boolean().default(false).optional(),
})
.superRefine(async (val, ctx) => {
if (val.gitBranchName && val.gitBranchName.length > 0 && githubConnection?.repository.id) {
@@ -164,7 +168,7 @@ export const CreateBranchModal = () => {
mode: 'onSubmit',
reValidateMode: 'onBlur',
resolver: zodResolver(FormSchema),
defaultValues: { branchName: '', gitBranchName: '' },
defaultValues: { branchName: '', gitBranchName: '', withData: false },
})
const canSubmit = !isCreating && !isChecking
@@ -182,7 +186,9 @@ export const CreateBranchModal = () => {
createBranch({
projectRef,
branchName: data.branchName,
is_default: false,
...(data.gitBranchName ? { gitBranch: data.gitBranchName } : {}),
...(allowDataBranching ? { withData: data.withData } : {}),
})
}
@@ -294,6 +300,7 @@ export const CreateBranchModal = () => {
)}
/>
)}
{isLoadingConnections && <GenericSkeletonLoader />}
{isErrorConnections && (
<AlertError
@@ -314,7 +321,7 @@ export const CreateBranchModal = () => {
</Badge>
)}
</div>
<p className="text-sm text-foreground-light">
<p className="text-sm text-foreground-lighter">
Keep this preview branch in sync with a chosen GitHub branch
</p>
</div>
@@ -325,6 +332,23 @@ export const CreateBranchModal = () => {
)}
</>
)}
{allowDataBranching && (
<FormField_Shadcn_
control={form.control}
name="withData"
render={({ field }) => (
<FormItemLayout
label="Include data"
layout="flex-row-reverse"
description="Clone production data into this branch"
>
<FormControl_Shadcn_>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
)}
</DialogSection>
<DialogSectionSeparator />
@@ -1,154 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import {
Button,
FormControl_Shadcn_,
FormField_Shadcn_,
FormMessage_Shadcn_,
Form_Shadcn_,
Input_Shadcn_,
Modal,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan'
import { Admonition } from 'ui-patterns'
import { useRouter } from 'next/router'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
export interface CollectionFormValues {
name: string
retention_days: number
}
interface CollectionFormProps {
initialValues?: Partial<CollectionFormValues>
onSubmit: (values: CollectionFormValues) => void
isLoading?: boolean
onCancelClick: () => void
}
export const CollectionForm = ({
initialValues,
onSubmit,
isLoading,
onCancelClick,
}: CollectionFormProps) => {
const { plan, isLoading: planLoading } = useCurrentOrgPlan()
const router = useRouter()
const currentOrg = useSelectedOrganization()
function getMaxRetentionDays() {
if (planLoading) return 1
if (plan?.id === 'free') return 3
else return 90
}
const maxRetentionDays = getMaxRetentionDays()
const FormSchema = z.object({
name: z.string().min(1),
retention_days: z.coerce.number().min(1).max(maxRetentionDays).multipleOf(1).positive().int(),
})
const form = useForm<CollectionFormValues>({
resolver: zodResolver(FormSchema),
defaultValues: {
name: initialValues?.name || '',
retention_days: initialValues?.retention_days || maxRetentionDays,
},
})
const handleSubmit = form.handleSubmit(onSubmit)
const formatDays = (days: number) => {
return days === 1 ? `1 day` : `${days} days`
}
const retentionDescription = (days: number) => {
if (!Boolean(days) || days < 1)
return <>Your plan allows for a maximum of {formatDays(maxRetentionDays)}.</>
return (
<>
Your logs will be removed after {formatDays(days)}. <br />
Your plan allows for a maximum of {formatDays(maxRetentionDays)}.
</>
)
}
return (
<Form_Shadcn_ {...form}>
<form onSubmit={handleSubmit}>
<Modal.Content className="space-y-3">
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout label="Collection name" layout="horizontal">
<FormControl_Shadcn_>
<Input_Shadcn_ autoFocus placeholder="Events" {...field} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="retention_days"
render={({ field }) => (
<FormItemLayout
label="Retention"
layout="horizontal"
description={retentionDescription(Number(field.value))}
>
<FormControl_Shadcn_>
<div className="relative flex items-center gap-2">
<Input_Shadcn_ type="number" {...field} />
<span className="absolute right-3 text-foreground-lighter text-sm pointer-events-none">
days
</span>
</div>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</Modal.Content>
{plan?.id === 'free' && (
<>
<Modal.Separator />
<Admonition
className="border-none bg-transparent"
type="default"
title="Upgrade your plan to increase retention"
description={
<div>
Upgrade to the pro plan to increase retention to 90 days.
<Button
className="mt-2"
type="primary"
onClick={() =>
router.push(
`/org/${currentOrg?.slug}/billing?panel=subscriptionPlan&source=dataWarehouseIncreaseRetention`
)
}
>
Upgrade plan
</Button>
</div>
}
/>
</>
)}
<FormMessage_Shadcn_ />
<Modal.Content className="py-4 border-t flex items-center justify-end gap-2">
<Button size="tiny" type="default" onClick={onCancelClick}>
Cancel
</Button>
<Button size="tiny" loading={isLoading} disabled={isLoading} htmlType="submit">
Create collection
</Button>
</Modal.Content>
</form>
</Form_Shadcn_>
)
}
@@ -1,54 +0,0 @@
import { useRouter } from 'next/router'
import { toast } from 'sonner'
import { useParams } from 'common'
import { useCreateCollection } from 'data/analytics/warehouse-collections-create-mutation'
import { Modal } from 'ui'
import { CollectionForm } from './CollectionForm'
export const CreateWarehouseCollectionModal = ({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) => {
const router = useRouter()
const { ref } = useParams()
const { mutate: createCollection, isLoading } = useCreateCollection({
onSuccess: (data) => {
onOpenChange(false)
toast.success('Collection created successfully')
router.push(`/project/${ref}/logs/collections/${data.token}`)
},
onError: (error) => {
toast.error(error.message)
},
})
return (
<Modal
size="medium"
onCancel={() => onOpenChange(false)}
header="Create collection"
visible={open}
hideFooter
>
<CollectionForm
onCancelClick={() => onOpenChange(false)}
isLoading={isLoading}
onSubmit={(vals) => {
if (!ref) {
return
}
createCollection({
projectRef: ref,
name: vals.name,
retention_days: vals.retention_days,
})
}}
/>
</Modal>
)
}
@@ -1,186 +0,0 @@
import { Select, SelectContent, SelectItem, SelectTrigger } from '@ui/components/shadcn/ui/select'
import CopyButton from 'components/ui/CopyButton'
import type { WarehouseAccessTokensData } from 'data/analytics/warehouse-access-tokens-query'
import type { WarehouseCollectionsData } from 'data/analytics/warehouse-collections-query'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import {
Button,
Checkbox_Shadcn_,
CodeBlock,
Input_Shadcn_,
Label_Shadcn_,
Separator,
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetSection,
SheetTitle,
SheetTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
export function TestCollectionDialog({
accessTokens,
collectionToken,
collections,
open,
onOpenChange,
onSubmit,
}: {
accessTokens: WarehouseAccessTokensData['data']
collections: WarehouseCollectionsData
collectionToken: string
projectRef: string
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: () => void
}) {
const router = useRouter()
const BASE_WAREHOUSE_URL = `https://api.warehouse.tech/api/events`
const [testAccessToken, setTestAccessToken] = useState('')
const [selectedCollection, setSelectedCollection] = useState(collectionToken || '')
const [showAccessToken, setShowAccessToken] = useState(false)
useEffect(() => {
setSelectedCollection(collectionToken)
}, [collectionToken])
useEffect(() => {
if (accessTokens.length > 0) {
setTestAccessToken(accessTokens[0].token)
}
}, [accessTokens])
const selectedAccessToken = accessTokens.find((token) => token.token === testAccessToken)
const selectedCollectionName = collections?.find((col) => col.token === selectedCollection)?.name
function getcURL(accessToken: string) {
return `curl -X "POST" "${BASE_WAREHOUSE_URL}?source=${selectedCollection}" \\
-H 'Content-Type: application/json' \\
-H 'Authorization: Bearer ${accessToken}' \\
-d $'{
"event_message": "Test event message",
"metadata": {
"ip_address": "100.100.100.100",
"request_method": "POST",
"custom_user_data": {
"foo": "bar"
},
"datacenter": "aws",
"request_headers": {
"connection": "close",
"user_agent": "chrome"
}
}
}'
`
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<Tooltip>
<TooltipTrigger asChild>
<SheetTrigger asChild>
<Button disabled={accessTokens.length === 0} type="outline">
Connect
</Button>
</SheetTrigger>
</TooltipTrigger>
{accessTokens.length === 0 && (
<TooltipContent>Create an access token to connect to your collection</TooltipContent>
)}
</Tooltip>
<SheetContent size="lg" className="h-screen overflow-auto flex flex-col">
<SheetHeader>
<SheetTitle>Send events to this collection</SheetTitle>
<SheetDescription>
Use the following cURL command to send events to this collection
</SheetDescription>
</SheetHeader>
<SheetSection className="flex flex-col gap-4 overflow-auto">
<FormItemLayout label="Collection" isReactForm={false} layout="horizontal">
<Select value={selectedCollection} onValueChange={setSelectedCollection}>
<SelectTrigger size="tiny">
<span className="truncate">{selectedCollectionName || 'Collection'}</span>
</SelectTrigger>
<SelectContent className="max-h-[260px]">
{collections?.map((col) => (
<SelectItem key={col.id} value={col.token}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormItemLayout>
<FormItemLayout label="Token" isReactForm={false} layout="horizontal">
<Select value={testAccessToken} onValueChange={setTestAccessToken}>
<SelectTrigger size="tiny">
<span className="truncate">
{selectedAccessToken?.description || 'Access token'}
</span>
</SelectTrigger>
<SelectContent className="max-h-[260px]">
{accessTokens?.map((token: any) => (
<SelectItem key={token.id + '-token'} value={token.token}>
{token.description || 'No description'}
</SelectItem>
))}
</SelectContent>
</Select>
</FormItemLayout>
<FormItemLayout layout="horizontal" label="Ingest API URL" isReactForm={false}>
<Input_Shadcn_ size="tiny" className="font-mono" value={BASE_WAREHOUSE_URL} readOnly />
</FormItemLayout>
<FormItemLayout layout="horizontal" label="Collection ID" isReactForm={false}>
<Input_Shadcn_ size="tiny" className="font-mono" value={selectedCollection} readOnly />
</FormItemLayout>
</SheetSection>
<Separator />
<SheetSection className="flex-1">
<div className="flex justify-between items-center">
<div className="flex gap-2 items-center">
<Checkbox_Shadcn_
name="showAccessToken"
id="showAccessToken"
checked={showAccessToken}
onCheckedChange={() => setShowAccessToken(!showAccessToken)}
/>
<Label_Shadcn_ htmlFor="showAccessToken">Show access token</Label_Shadcn_>
</div>
<CopyButton type="default" text={getcURL(testAccessToken)} />
</div>
<div className="mt-4 relative">
<CodeBlock
hideCopy
className={'p-1 language-bash prose max-w-full transition-colors'}
language="bash"
>
{getcURL(showAccessToken ? testAccessToken : '********************')}
</CodeBlock>
</div>
</SheetSection>
<SheetFooter className="mt-auto">
<p className="text-sm text-foreground-light mr-auto">
Once you send an event, refresh the page to see it in the collection.
</p>
<div className="flex justify-end">
<Button onClick={onSubmit}>Refresh results</Button>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
@@ -1,323 +0,0 @@
import { useKeyboardShortcuts } from 'components/grid/components/common/Hooks'
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
import AlertError from 'components/ui/AlertError'
import LoadingOpacity from 'components/ui/LoadingOpacity'
import ShimmerLine from 'components/ui/ShimmerLine'
import { useWarehouseAccessTokensQuery } from 'data/analytics/warehouse-access-tokens-query'
import { useWarehouseLogDetailQuery } from 'data/analytics/warehouse-collection-log-detail-query'
import { useWarehouseCollectionsQuery } from 'data/analytics/warehouse-collections-query'
import { useWarehouseQueryQuery } from 'data/analytics/warehouse-query'
import dayjs from 'dayjs'
import { useSelectedLog } from 'hooks/analytics/useSelectedLog'
import { useFlag } from 'hooks/ui/useFlag'
import { RefreshCcw, Rewind, Terminal } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { LogsDatePicker } from '../Settings/Logs/Logs.DatePickers'
import { DatetimeHelper } from '../Settings/Logs/Logs.types'
import LogTable from '../Settings/Logs/LogTable'
import { TestCollectionDialog } from './TestCollectionDialog'
const INTERVALS: DatetimeHelper[] = [
{
text: 'Last hour',
calcFrom: () => dayjs().subtract(1, 'hour').toISOString(),
calcTo: () => dayjs().toISOString(),
default: true,
},
{
text: 'Last 12 hours',
calcFrom: () => dayjs().subtract(12, 'hour').toISOString(),
calcTo: () => dayjs().toISOString(),
},
{
text: 'Last day',
calcFrom: () => dayjs().subtract(1, 'day').toISOString(),
calcTo: () => dayjs().toISOString(),
},
{
text: 'Last 7 days',
calcFrom: () => dayjs().subtract(7, 'day').toISOString(),
calcTo: () => dayjs().toISOString(),
},
]
export const WarehouseCollectionDetail = () => {
const router = useRouter()
const collectionToken = router.query.collectionToken as string
const projectRef = router.query.ref as string
const accessTokens = useWarehouseAccessTokensQuery({ projectRef })
const [testDialogOpen, setTestDialogOpen] = useState(false)
const hasWarehouse = useFlag('warehouse')
const { data: collections, isLoading: collectionsLoading } = useWarehouseCollectionsQuery(
{ projectRef },
{ enabled: hasWarehouse }
)
const collection = collections?.find((c) => c.token === collectionToken)
const [search, setSearch] = useState('')
const [filters, setFilters] = useState({
limit: 100,
offset: 0,
search: '',
interval: {
to: INTERVALS[0].calcTo,
from: INTERVALS[0].calcFrom,
},
})
const [sql, setSQL] = useState('')
useEffect(() => {
if (collection) {
const from = filters.interval.from()
const to = filters.interval.to()
const sql = `select id, timestamp from \`${collection.name}\`
where timestamp >= TIMESTAMP('${from}')
and timestamp <= TIMESTAMP('${to}')
order by timestamp desc limit ${filters.limit} offset ${filters.offset}
`
setSQL(sql)
}
}, [filters.interval, filters.limit, filters.offset, collection])
const {
isLoading: queryLoading,
data: queryData,
isError,
isRefetching,
} = useWarehouseQueryQuery(
{ ref: projectRef, sql },
{
enabled: !!sql && hasWarehouse,
}
)
const formatResults = (results: any) => {
if (!results || !results.length) {
return []
}
const r = results.map(({ timestamp, ...r }: any) => {
return {
timestamp: new Date(timestamp / 1000).toLocaleString(),
...r,
}
})
return r
}
const results = formatResults(queryData?.result)
function loadMore() {
setFilters({ ...filters, offset: filters.offset + filters.limit })
}
const isLoading = queryLoading || collectionsLoading || isRefetching
function refreshResults(e?: React.FormEvent<HTMLFormElement>) {
e?.preventDefault()
setFilters({
...filters,
search,
interval: {
to: () => dayjs().toISOString(),
from: () => filters.interval.from() || dayjs().subtract(1, 'hour').toISOString(),
},
})
}
const queryUrl = `/project/${projectRef}/logs/explorer?q=${encodeURIComponent(
sql || ''
)}&source=warehouse`
useKeyboardShortcuts(
{
r: () => {
document.getElementById('refresh-button')?.focus()
},
s: () => {
document.getElementById('search-input')?.focus()
},
},
['INPUT', 'BUTTON', 'TEXTAREA']
)
function KeyboardShortcut({ children }: { children: React.ReactNode }) {
return (
<span className="flex items-center gap-2 font-medium text-xs font-mono text-foreground-light border rounded-md px-1.5 py-0.5 border-b-2">
{children}
</span>
)
}
// Selected Log Detail
const [selectedLogId, setSelectedLogId] = useSelectedLog()
const {
data: selectedLogDetail,
isLoading: selectedLogDetailLoading,
error: selectedLogDetailError,
} = useWarehouseLogDetailQuery({
ref: projectRef,
collectionName: collection?.name,
logId: selectedLogId ?? undefined,
})
return (
<div className="relative flex flex-col flex-grow h-full">
<ShimmerLine active={isLoading} />
<LoadingOpacity active={isLoading}>
<div className="flex flex-col w-full">
<div className="flex justify-between items-center h-12 px-5">
<form onSubmit={refreshResults} className="flex items-center gap-2">
<h2 className="text-foreground-light max-w-60 truncate" title={collection?.name}>
{collection?.name}
</h2>
<Tooltip>
<TooltipTrigger asChild>
<Button
id="refresh-button"
htmlType="submit"
icon={<RefreshCcw />}
type="text"
loading={isLoading}
disabled={isLoading}
className="px-1.5"
/>
</TooltipTrigger>
<TooltipContent className="flex items-center gap-2">
Refresh <KeyboardShortcut>R</KeyboardShortcut>
</TooltipContent>
</Tooltip>
{/* <Input
type="search"
id="search-input"
className="w-52"
size="tiny"
placeholder="Search by event message"
value={search}
onChange={(e) => setSearch(e.target.value)}
/> */}
<LogsDatePicker
helpers={INTERVALS}
value={{
from: filters.interval.from(),
to: filters.interval.to(),
}}
onSubmit={(e) =>
setFilters({
...filters,
interval: {
to: () => e.to || '',
from: () => e.from || '',
},
})
}
/>
</form>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button asChild className="px-1.5" type="outline" icon={<Terminal />}>
<Link href={queryUrl} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Open query in Collections Explorer
</TooltipContent>
</Tooltip>
<TestCollectionDialog
accessTokens={accessTokens.data?.data || []}
collectionToken={collectionToken}
projectRef={projectRef}
collections={collections || []}
open={testDialogOpen}
onOpenChange={setTestDialogOpen}
onSubmit={() => {
refreshResults()
setTestDialogOpen(false)
}}
/>
</div>
</div>
{isError && (
<div className="p-4">
<AlertError subject="Failed to load query results" />
</div>
)}
{!isError && (
<LogTable
collectionName={collection?.name}
queryType="warehouse"
hasEditorValue={false}
projectRef={projectRef}
isLoading={isLoading}
onSelectedLogChange={(log) => setSelectedLogId(log?.id ?? null)}
selectedLog={selectedLogDetail?.result[0] ?? undefined}
selectedLogError={selectedLogDetailError ?? undefined}
isSelectedLogLoading={!!selectedLogId && selectedLogDetailLoading}
data={results}
showHeader={false}
EmptyState={
<ProductEmptyState title="No events found" size="large">
<div className="space-y-4">
<p>Try adjusting your filters, send a test event or refresh the results.</p>
<div className="flex items-center gap-2">
<Button
onClick={() => {
setTestDialogOpen(true)
}}
>
Send test event
</Button>
<Button type="outline" onClick={() => refreshResults()}>
Refresh
</Button>
</div>
</div>
</ProductEmptyState>
}
/>
)}
</div>
</LoadingOpacity>
{!isError && (
<div className="border-t flex flex-row justify-between">
<div className="flex items-center gap-2 p-2">
{results.length > 0 && (
<Button
onClick={loadMore}
icon={<Rewind />}
type="default"
loading={isLoading}
disabled={isLoading}
>
Load more
</Button>
)}
{filters.offset !== 0 && (
<>
<Button
onClick={() => setFilters({ ...filters, offset: 0 })}
type="default"
loading={isLoading}
disabled={isLoading}
>
Load latest
</Button>
</>
)}
</div>
</div>
)}
</div>
)
}
@@ -1,154 +0,0 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Database, EditIcon, TrashIcon } from 'lucide-react'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import { useDeleteCollectionMutation } from 'data/analytics/warehouse-collections-delete-mutation'
import { useUpdateCollection } from 'data/analytics/warehouse-collections-update-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import {
DropdownMenuItem,
DropdownMenuSeparator,
Modal,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { LogsSidebarItem } from '../Settings/Logs/SidebarV2/SidebarItem'
import { CollectionForm } from './CollectionForm'
type Props = {
item: {
id: number
token: string
name: string
retention_days: number
}
}
export const WarehouseMenuItem = ({ item }: Props) => {
const router = useRouter()
const { ref, collectionToken } = useParams()
const projectRef = ref || 'default'
const [showUpdateDialog, setShowUpdateDialog] = useState(false)
const [showDeleteDialog, setDeleteDialog] = useState(false)
const canUpdateCollection = useCheckPermissions(PermissionAction.ANALYTICS_WRITE, 'logflare')
const updateCollection = useUpdateCollection({
onSuccess: () => {
toast.success('Collection updated successfully')
setShowUpdateDialog(false)
},
})
const deleteCollection = useDeleteCollectionMutation({
onSuccess: () => {
// If the current collection is deleted, redirect to default logs view
if (item.token === collectionToken) {
router.push(`/project/${projectRef}/logs/explorer`)
}
setDeleteDialog(false)
toast.success('Collection deleted successfully')
},
})
const isLoading = updateCollection.isLoading || deleteCollection.isLoading
return (
<>
<LogsSidebarItem
isActive={item.token === collectionToken}
label={item.name}
icon={<Database size="14" className="text-foreground-light" />}
href={`/project/${projectRef}/logs/collections/${item.token}`}
dropdownItems={
<>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuItem
disabled={!canUpdateCollection}
onClick={(e) => {
e.preventDefault()
setShowUpdateDialog(true)
}}
>
<EditIcon className="mr-2" size={14} />
<div>Update collection</div>
</DropdownMenuItem>
</TooltipTrigger>
{!canUpdateCollection && (
<TooltipContent side="right">
You need additional permissions to update collections
</TooltipContent>
)}
</Tooltip>
<DropdownMenuSeparator />
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuItem
disabled={!canUpdateCollection}
onClick={(e) => {
e.preventDefault()
setDeleteDialog(true)
}}
>
<TrashIcon className="mr-2" size={14} />
<div>Delete collection</div>
</DropdownMenuItem>
</TooltipTrigger>
{!canUpdateCollection && (
<TooltipContent side="right">
You need additional permissions to delete collections
</TooltipContent>
)}
</Tooltip>
</>
}
></LogsSidebarItem>
<Modal
size="medium"
visible={showUpdateDialog}
onCancel={() => setShowUpdateDialog(false)}
hideFooter
header={`Update collection "${item.name}"`}
>
<CollectionForm
onCancelClick={() => setShowUpdateDialog(false)}
isLoading={isLoading}
initialValues={{ name: item.name, retention_days: item.retention_days }}
onSubmit={({ name, retention_days }) => {
updateCollection.mutate({
projectRef,
collectionToken: item.token,
name,
retention_days,
})
}}
/>
</Modal>
<ConfirmationModal
variant="destructive"
visible={showDeleteDialog}
onCancel={() => setDeleteDialog(false)}
confirmLabel="Delete collection"
title={`Delete collection "${item.name}"`}
size="small"
onConfirm={() => {
deleteCollection.mutate({ projectRef, collectionToken: item.token })
}}
>
<p className="text-sm text-foreground-light">
All data in this collection will be deleted.
<br />
<div className="mt-2">This action cannot be undone.</div>
</p>
</ConfirmationModal>
</>
)
}
@@ -1,13 +1,15 @@
import Table from 'components/to-be-cleaned/Table'
import { motion } from 'framer-motion'
import { BASE_PATH } from 'lib/constants'
import { ArrowUpRight, Circle, Database, MoreVertical, Plus, Search } from 'lucide-react'
import { useTheme } from 'next-themes'
import Link from 'next/link'
import { useMemo } from 'react'
import ReactFlow, { Background, Handle, Position, ReactFlowProvider } from 'reactflow'
import 'reactflow/dist/style.css'
import { Button, Input } from 'ui'
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import Table from 'components/to-be-cleaned/Table'
import { BASE_PATH } from 'lib/constants'
import { Button, Input_Shadcn_ } from 'ui'
import { NODE_WIDTH } from '../../Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants'
const STATIC_NODES = [
@@ -62,6 +64,14 @@ const STATIC_EDGES = [
{ id: 'e1-4', source: '1', target: '4', type: 'smoothstep', animated: true },
]
export const ReplicationComingSoon = () => {
return (
<ReactFlowProvider>
<ReplicationStaticMockup />
</ReactFlowProvider>
)
}
const ReplicationStaticMockup = () => {
const nodes = useMemo(() => STATIC_NODES, [])
const edges = useMemo(() => STATIC_EDGES, [])
@@ -108,16 +118,6 @@ const ReplicationStaticMockup = () => {
)
}
const ReplicationComingSoon = () => {
return (
<ReactFlowProvider>
<ReplicationStaticMockup />
</ReactFlowProvider>
)
}
export default ReplicationComingSoon
const PrimaryNode = ({
data,
}: {
@@ -251,58 +251,68 @@ const StaticDestinations = () => {
return (
<>
<div className="flex flex-col bg-surface-100 px-6 pt-6 border-t relative h-full">
<div className="bg-surface-300 w-full h-full absolute top-0 left-0 opacity-30"></div>
<div className="flex items-center justify-between">
<Input
size="small"
className="w-52 bg-transparent"
iconContainerClassName="pl-2"
icon={<Search size={14} className="text-foreground-lighter" />}
placeholder="Search..."
/>
<Button
icon={<Plus size={16} />}
type="primary"
className="flex items-center"
onClick={() => {}}
>
New destination
</Button>
</div>
<Table
head={[
<Table.th key="name">Name</Table.th>,
<Table.th key="publication">Publication</Table.th>,
<Table.th key="lag">Lag</Table.th>,
<Table.th key="status">Status</Table.th>,
<Table.th key="actions"></Table.th>,
]}
className="mt-4"
body={mockRows.map((row, i) => (
<Table.tr key={i}>
<Table.td>{row.name}</Table.td>
<Table.td>
<span className="flex items-center gap-2">
<span className="font-bold">All</span>
<span className="text-sm text-foreground-lighter">{row.tables} tables</span>
</span>
</Table.td>
<Table.td>{row.lag}</Table.td>
<Table.td>
<span className="flex items-center gap-3">
<Circle size={10} className="bg-brand-500 stroke-none rounded-full" />
{row.status}
</span>
</Table.td>
<Table.td className="text-right">
<button className="p-1">
<MoreVertical size={18} />
</button>
</Table.td>
</Table.tr>
))}
/>
<div className="flex flex-col bg-surface-100 px-6 py-6 border-t relative ">
<div className="z-10 bg-surface-300 w-full h-full absolute top-0 left-0 opacity-30" />
<ScaffoldContainer>
<ScaffoldSection className="!py-0">
<div className="col-span-12">
<div className="flex items-center justify-between">
<div className="relative w-52">
<Search
size={14}
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-foreground-lighter"
/>
<Input_Shadcn_
className="pl-9 bg-transparent h-8 pointer-events-none"
placeholder="Search..."
/>
</div>
<Button
disabled
type="primary"
icon={<Plus size={16} />}
className="flex items-center pointer-events-none"
>
New destination
</Button>
</div>
<Table
head={[
<Table.th key="name">Name</Table.th>,
<Table.th key="publication">Publication</Table.th>,
<Table.th key="lag">Lag</Table.th>,
<Table.th key="status">Status</Table.th>,
<Table.th key="actions"></Table.th>,
]}
className="mt-4"
body={mockRows.map((row, i) => (
<Table.tr key={i}>
<Table.td>{row.name}</Table.td>
<Table.td>
<span className="flex items-center gap-2">
<span className="font-bold">All</span>
<span className="text-sm text-foreground-lighter">{row.tables} tables</span>
</span>
</Table.td>
<Table.td>{row.lag}</Table.td>
<Table.td>
<span className="flex items-center gap-3">
<Circle size={10} className="bg-brand-500 stroke-none rounded-full" />
{row.status}
</span>
</Table.td>
<Table.td className="text-right">
<button className="p-1">
<MoreVertical size={18} />
</button>
</Table.td>
</Table.tr>
))}
/>
</div>
</ScaffoldSection>
</ScaffoldContainer>
</div>
</>
)
@@ -1,26 +1,28 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import Table from 'components/to-be-cleaned/Table'
import AlertError from 'components/ui/AlertError'
import { useDeleteDestinationMutation } from 'data/replication/delete-destination-mutation'
import { useReplicationPipelineStatusQuery } from 'data/replication/pipeline-status-query'
import { ReplicationPipelinesData } from 'data/replication/pipelines-query'
import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation'
import {
PipelineStatusRequestStatus,
usePipelineRequestStatus,
} from 'state/replication-pipeline-request-status'
import { ResponseError } from 'types'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
import RowMenu from './RowMenu'
import PipelineStatus, { PipelineStatusRequestStatus, PipelineStatusName } from './PipelineStatus'
import { useParams } from 'common'
import {
ReplicationPipelineStatusData,
useReplicationPipelineStatusQuery,
} from 'data/replication/pipeline-status-query'
import { useState } from 'react'
import { toast } from 'sonner'
import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation'
import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation'
import { useDeleteDestinationMutation } from 'data/replication/delete-destination-mutation'
import DeleteDestination from './DeleteDestination'
import DestinationPanel from './DestinationPanel'
import { getStatusName, PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
import { PipelineStatus, PipelineStatusName } from './PipelineStatus'
import { RowMenu } from './RowMenu'
export type Pipeline = ReplicationPipelinesData['pipelines'][0]
const refreshFrequencyMs: number = 5000
const refreshFrequencyMs: number = 2000
interface DestinationRowProps {
sourceId: number | undefined
@@ -32,9 +34,10 @@ interface DestinationRowProps {
isLoading: boolean
isError: boolean
isSuccess: boolean
onSelectPipeline?: (pipelineId: number, destinationName: string) => void
}
const DestinationRow = ({
export const DestinationRow = ({
sourceId,
destinationId,
destinationName,
@@ -44,6 +47,7 @@ const DestinationRow = ({
isLoading: isPipelineLoading,
isError: isPipelineError,
isSuccess: isPipelineSuccess,
onSelectPipeline,
}: DestinationRowProps) => {
const { ref: projectRef } = useParams()
const [showDeleteDestinationForm, setShowDeleteDestinationForm] = useState(false)
@@ -62,76 +66,23 @@ const DestinationRow = ({
},
{ refetchInterval: refreshFrequencyMs }
)
const [requestStatus, setRequestStatus] = useState<PipelineStatusRequestStatus>(
PipelineStatusRequestStatus.None
)
const { mutateAsync: startPipeline } = useStartPipelineMutation()
const { getRequestStatus, updatePipelineStatus } = usePipelineRequestStatus()
const requestStatus = pipeline?.id
? getRequestStatus(pipeline.id)
: PipelineStatusRequestStatus.None
const { mutateAsync: stopPipeline } = useStopPipelineMutation()
const pipelineStatus = pipelineStatusData?.status
const getStatusName = (
status: ReplicationPipelineStatusData['status'] | undefined
): string | undefined => {
if (status && typeof status === 'object' && 'name' in status) {
return status.name
}
return undefined
}
const statusName = getStatusName(pipelineStatus)
if (
(requestStatus === PipelineStatusRequestStatus.EnableRequested &&
(statusName === PipelineStatusName.STARTED || statusName === PipelineStatusName.FAILED)) ||
(requestStatus === PipelineStatusRequestStatus.DisableRequested &&
(statusName === PipelineStatusName.STOPPED || statusName === PipelineStatusName.FAILED))
) {
setRequestStatus(PipelineStatusRequestStatus.None)
}
const onEnableClick = async () => {
if (!projectRef) {
console.error('Project ref is required')
return
}
if (!pipeline) {
toast.error('No pipeline found')
return
}
try {
await startPipeline({ projectRef, pipelineId: pipeline.id })
} catch (error) {
toast.error('Failed to enable destination')
}
setRequestStatus(PipelineStatusRequestStatus.EnableRequested)
}
const onDisableClick = async () => {
if (!projectRef) {
console.error('Project ref is required')
return
}
if (!pipeline) {
toast.error('No pipeline found')
return
}
try {
await stopPipeline({ projectRef, pipelineId: pipeline.id })
} catch (error) {
toast.error('Failed to disable destination')
}
setRequestStatus(PipelineStatusRequestStatus.DisableRequested)
}
const { mutateAsync: deleteDestination } = useDeleteDestinationMutation({})
const pipelineStatus = pipelineStatusData?.status
const statusName = getStatusName(pipelineStatus)
const onDeleteClick = async () => {
if (!projectRef) {
console.error('Project ref is required')
return
return console.error('Project ref is required')
}
if (!pipeline) {
toast.error('No pipeline found')
return
return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND)
}
try {
@@ -140,24 +91,33 @@ const DestinationRow = ({
// so we don't need to call deletePipeline explicitly
await deleteDestination({ projectRef, destinationId: destinationId })
} catch (error) {
toast.error('Failed to delete destination')
toast.error(PIPELINE_ERROR_MESSAGES.DELETE_DESTINATION)
}
}
useEffect(() => {
if (pipeline?.id) {
updatePipelineStatus(pipeline.id, statusName)
}
}, [pipeline?.id, statusName, updatePipelineStatus])
return (
<>
{isPipelineError && (
<AlertError error={pipelineError} subject="Failed to retrieve pipeline" />
<AlertError error={pipelineError} subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_PIPELINE} />
)}
{isPipelineSuccess && (
<Table.tr>
<Table.td>
{isPipelineLoading ? <ShimmeringLoader></ShimmeringLoader> : destinationName}
</Table.td>
<Table.td>{isPipelineLoading ? <ShimmeringLoader></ShimmeringLoader> : type}</Table.td>
<Table.tr
className="hover:!bg-surface-200 transition"
onClick={() => {
if (pipeline) onSelectPipeline?.(pipeline.id, destinationName)
}}
>
<Table.td>{isPipelineLoading ? <ShimmeringLoader /> : destinationName}</Table.td>
<Table.td>{isPipelineLoading ? <ShimmeringLoader /> : type}</Table.td>
<Table.td>
{isPipelineLoading || !pipeline ? (
<ShimmeringLoader></ShimmeringLoader>
<ShimmeringLoader />
) : (
<PipelineStatus
pipelineStatus={pipelineStatusData?.status}
@@ -166,27 +126,28 @@ const DestinationRow = ({
isError={isPipelineStatusError}
isSuccess={isPipelineStatusSuccess}
requestStatus={requestStatus}
></PipelineStatus>
/>
)}
</Table.td>
<Table.td>
{isPipelineLoading || !pipeline ? (
<ShimmeringLoader></ShimmeringLoader>
<ShimmeringLoader />
) : (
pipeline.config.publication_name
)}
</Table.td>
<Table.td>
<RowMenu
pipelineStatus={pipelineStatusData?.status}
error={pipelineStatusError}
isLoading={isPipelineStatusLoading}
isError={isPipelineStatusError}
onEnableClick={onEnableClick}
onDisableClick={onDisableClick}
onDeleteClick={() => setShowDeleteDestinationForm(true)}
onEditClick={() => setShowEditDestinationPanel(true)}
></RowMenu>
<div className="flex items-center justify-end gap-x-2">
<RowMenu
pipeline={pipeline}
pipelineStatus={pipelineStatusData?.status}
error={pipelineStatusError}
isLoading={isPipelineStatusLoading}
isError={isPipelineStatusError}
onDeleteClick={() => setShowDeleteDestinationForm(true)}
onEditClick={() => setShowEditDestinationPanel(true)}
/>
</div>
</Table.td>
</Table.tr>
)}
@@ -211,5 +172,3 @@ const DestinationRow = ({
</>
)
}
export default DestinationRow
@@ -1,19 +1,26 @@
import { noop } from 'lodash'
import { Plus, Search } from 'lucide-react'
import { useState } from 'react'
import { useParams } from 'common'
import Table from 'components/to-be-cleaned/Table'
import AlertError from 'components/ui/AlertError'
import { useReplicationDestinationsQuery } from 'data/replication/destinations-query'
import { Plus } from 'lucide-react'
import { Button, cn } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns'
import DestinationRow from './DestinationRow'
import { useReplicationPipelinesQuery } from 'data/replication/pipelines-query'
import { useState } from 'react'
import NewDestinationPanel from './DestinationPanel'
import { useReplicationSourcesQuery } from 'data/replication/sources-query'
import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold'
import { Button, cn, Input_Shadcn_ } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns'
import NewDestinationPanel from './DestinationPanel'
import { DestinationRow } from './DestinationRow'
import { PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
const Destinations = () => {
interface DestinationsProps {
onSelectPipeline?: (pipelineId: number, destinationName: string) => void
}
export const Destinations = ({ onSelectPipeline = noop }: DestinationsProps) => {
const [showNewDestinationPanel, setShowNewDestinationPanel] = useState(false)
const [filterString, setFilterString] = useState<string>('')
const { ref: projectRef } = useParams()
const {
@@ -21,12 +28,11 @@ const Destinations = () => {
error: sourcesError,
isLoading: isSourcesLoading,
isError: isSourcesError,
isSuccess: isSourcesSuccess,
} = useReplicationSourcesQuery({
projectRef,
})
let sourceId = sourcesData?.sources.find((s) => s.name === projectRef)?.id
const sourceId = sourcesData?.sources.find((s) => s.name === projectRef)?.id
const {
data: destinationsData,
@@ -50,21 +56,44 @@ const Destinations = () => {
const anyDestinations = isDestinationsSuccess && destinationsData.destinations.length > 0
const filteredDestinations =
filterString.length === 0
? destinationsData?.destinations ?? []
: (destinationsData?.destinations ?? []).filter((destination) =>
destination.name.toLowerCase().includes(filterString.toLowerCase())
)
return (
<>
<ScaffoldSection isFullWidth>
<div className="flex justify-between items-center mb-4">
<ScaffoldSectionTitle>Destinations</ScaffoldSectionTitle>
<div className="mb-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className="relative">
<Search
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-foreground-lighter"
size={14}
/>
<Input_Shadcn_
className="pl-9 h-7"
placeholder={'Filter destinations'}
value={filterString}
onChange={(e) => setFilterString(e.target.value)}
/>
</div>
</div>
<Button type="default" icon={<Plus />} onClick={() => setShowNewDestinationPanel(true)}>
Add destination
</Button>
</div>
</div>
<div className="w-full overflow-hidden overflow-x-auto">
{(isSourcesLoading || isDestinationsLoading) && <GenericSkeletonLoader />}
{(isSourcesError || isDestinationsError) && (
<AlertError
error={sourcesError || destinationsError}
subject="Failed to retrieve destinations"
subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_DESTINATIONS}
/>
)}
@@ -77,7 +106,7 @@ const Destinations = () => {
<Table.th key="publication">Publication</Table.th>,
<Table.th key="actions"></Table.th>,
]}
body={destinationsData.destinations.map((destination) => {
body={filteredDestinations.map((destination) => {
const pipeline = pipelinesData?.pipelines.find(
(p) => p.destination_id === destination.id
)
@@ -93,7 +122,8 @@ const Destinations = () => {
isLoading={isPipelinesLoading}
isError={isPipelinesError}
isSuccess={isPipelinesSuccess}
></DestinationRow>
onSelectPipeline={onSelectPipeline}
/>
)
})}
></Table>
@@ -124,15 +154,22 @@ const Destinations = () => {
</div>
)
)}
</ScaffoldSection>
</div>
{!isSourcesLoading &&
!isDestinationsLoading &&
filteredDestinations.length === 0 &&
anyDestinations && (
<div className="text-center py-8 text-foreground-light">
<p>No destinations match "{filterString}"</p>
</div>
)}
<NewDestinationPanel
visible={showNewDestinationPanel}
sourceId={sourceId}
onClose={() => setShowNewDestinationPanel(false)}
></NewDestinationPanel>
/>
</>
)
}
export default Destinations
@@ -0,0 +1,96 @@
import { ReplicationPipelineStatusData } from 'data/replication/pipeline-status-query'
import { PipelineStatusRequestStatus } from 'state/replication-pipeline-request-status'
export const PIPELINE_ERROR_MESSAGES = {
RETRIEVE_PIPELINE: 'Failed to retrieve pipeline information',
RETRIEVE_PIPELINE_STATUS: 'Failed to retrieve pipeline status',
RETRIEVE_REPLICATION_STATUS: 'Failed to retrieve table replication status',
RETRIEVE_DESTINATIONS: 'Failed to retrieve destinations',
ENABLE_DESTINATION: 'Failed to enable destination',
DISABLE_DESTINATION: 'Failed to disable destination',
DELETE_DESTINATION: 'Failed to delete destination',
NO_PIPELINE_FOUND: 'No pipeline found',
COPY_TABLE_STATUS: 'Failed to copy table status',
} as const
export const getStatusName = (
status: ReplicationPipelineStatusData['status'] | undefined
): ReplicationPipelineStatusData['status']['name'] | undefined => {
if (status && typeof status === 'object' && 'name' in status) {
return status.name
}
return undefined
}
export const PIPELINE_STATE_MESSAGES = {
enabling: {
title: 'Pipeline Enabling',
message: 'Starting the pipeline. Table replication will resume once enabled.',
badge: 'Enabling',
},
disabling: {
title: 'Pipeline Disabling',
message: 'Stopping the pipeline. Table replication will be paused once disabled.',
badge: 'Disabling',
},
failed: {
title: 'Pipeline Failed',
message: 'Replication has encountered an error. Check the logs for details.',
badge: 'Failed',
},
stopped: {
title: 'Pipeline Stopped',
message: 'Replication is paused. Enable the pipeline to resume data synchronization.',
badge: 'Stopped',
},
starting: {
title: 'Pipeline Starting',
message: 'Initializing replication. Table status will be available once running.',
badge: 'Starting',
},
running: {
title: 'Pipeline Running',
message: 'Replication is active and processing data',
badge: 'Running',
},
unknown: {
title: 'Pipeline Status Unknown',
message: 'Unable to determine replication status. Check the logs for more information.',
badge: 'Unknown',
},
notRunning: {
title: 'Pipeline Not Running',
message: 'Replication is not active. Enable the pipeline to start data synchronization.',
badge: 'Disabled',
},
} as const
export const getPipelineStateMessages = (
requestStatus: PipelineStatusRequestStatus | undefined,
statusName: string | undefined
) => {
// Always prioritize request status (enabling/disabling) over pipeline status
if (requestStatus === PipelineStatusRequestStatus.EnableRequested) {
return PIPELINE_STATE_MESSAGES.enabling
}
if (requestStatus === PipelineStatusRequestStatus.DisableRequested) {
return PIPELINE_STATE_MESSAGES.disabling
}
// Only check pipeline status if no request is in progress
switch (statusName) {
case 'failed':
return PIPELINE_STATE_MESSAGES.failed
case 'stopped':
return PIPELINE_STATE_MESSAGES.stopped
case 'starting':
return PIPELINE_STATE_MESSAGES.starting
case 'started':
return PIPELINE_STATE_MESSAGES.running
case 'unknown':
return PIPELINE_STATE_MESSAGES.unknown
default:
return PIPELINE_STATE_MESSAGES.notRunning
}
}
@@ -1,28 +1,11 @@
import AlertError from 'components/ui/AlertError'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
import { cn } from 'ui'
import { ResponseError } from 'types'
import { Loader2, ChevronDown, ChevronRight, Copy, AlertTriangle } from 'lucide-react'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
Button,
Collapsible_Shadcn_ as Collapsible,
CollapsibleContent_Shadcn_ as CollapsibleContent,
CollapsibleTrigger_Shadcn_ as CollapsibleTrigger,
} from 'ui'
import { useState, useEffect, useRef } from 'react'
import { copyToClipboard } from 'ui'
import { toast } from 'sonner'
import { ReplicationPipelineStatusData } from 'data/replication/pipeline-status-query'
export enum PipelineStatusRequestStatus {
None = 'None',
EnableRequested = 'EnableRequested',
DisableRequested = 'DisableRequested',
}
import { AlertTriangle, Loader2 } from 'lucide-react'
import { PipelineStatusRequestStatus } from 'state/replication-pipeline-request-status'
import { ResponseError } from 'types'
import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
import { getPipelineStateMessages, PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
export enum PipelineStatusName {
FAILED = 'failed',
@@ -41,10 +24,10 @@ interface PipelineStatusProps {
isLoading: boolean
isError: boolean
isSuccess: boolean
requestStatus: PipelineStatusRequestStatus
requestStatus?: PipelineStatusRequestStatus
}
const PipelineStatus = ({
export const PipelineStatus = ({
pipelineStatus,
error,
isLoading,
@@ -52,34 +35,6 @@ const PipelineStatus = ({
isSuccess,
requestStatus,
}: PipelineStatusProps) => {
const [isErrorDetailsOpen, setIsErrorDetailsOpen] = useState(false)
const errorDetailsRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (errorDetailsRef.current && !errorDetailsRef.current.contains(event.target as Node)) {
setIsErrorDetailsOpen(false)
}
}
if (isErrorDetailsOpen) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [isErrorDetailsOpen])
const handleCopyToClipboard = async (text: string) => {
try {
await copyToClipboard(text)
toast.success('Error details copied to clipboard')
} catch (error) {
toast.error('Failed to copy error details')
}
}
const isFailedStatus = (
status: ReplicationPipelineStatusData['status'] | undefined
): status is FailedStatus => {
@@ -91,144 +46,37 @@ const PipelineStatus = ({
)
}
const isLogLoadingState = (failedStatus: FailedStatus): boolean => {
// Right now we hardcode the error message which is returned when k8s is not able to find logs, which seems
// to be a transient error. In case we find a way to properly handle this in the backend, this hack will not
// be needed anymore.
const renderFailedStatus = () => {
return (
failedStatus.message?.startsWith('unable to retrieve container logs for containerd://') ===
true
)
}
const renderFailedStatus = (failedStatus: FailedStatus) => {
const hasDetails =
failedStatus.message || failedStatus.reason || failedStatus.exit_code !== undefined
const isLoadingLogs = isLogLoadingState(failedStatus)
return (
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2 text-sm text-destructive">
{isLoadingLogs ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<AlertTriangle className="w-3 h-3" />
)}
<span>Failed</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{isLoadingLogs
? 'Pipeline failed - logs are being retrieved from container'
: 'Pipeline has failed - expand for error details'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{hasDetails && (
<Collapsible open={isErrorDetailsOpen} onOpenChange={setIsErrorDetailsOpen}>
<CollapsibleTrigger asChild>
<Button type="text" size="tiny" className="h-auto py-0 px-1 text-xs">
{isErrorDetailsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="absolute z-10 mt-1 left-0 min-w-[400px] max-w-[600px]">
<div
ref={errorDetailsRef}
className="rounded-lg border border-border bg-surface-100 p-3 shadow-md"
>
{isLoadingLogs ? (
<div className="flex items-center gap-3">
<Loader2 className="w-4 h-4 animate-spin text-warning-600" />
<div>
<div className="text-sm font-medium text-foreground mb-1">
Pipeline Failed - Loading logs...
</div>
<div className="text-xs text-foreground-light">
Error logs are being retrieved from the container. This may take a few
moments.
</div>
</div>
</div>
) : (
<>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-foreground">
Pipeline Error Details
</span>
{(failedStatus.message || failedStatus.reason) && (
<Button
type="text"
size="tiny"
onClick={async () => {
await handleCopyToClipboard(
[
failedStatus.exit_code !== undefined
? `Exit Code: ${failedStatus.exit_code}`
: null,
failedStatus.reason ? `Reason: ${failedStatus.reason}` : null,
failedStatus.message ? `Message: ${failedStatus.message}` : null,
]
.filter(Boolean)
.join('\n')
)
}}
>
<Copy className="w-3 h-3" />
</Button>
)}
</div>
<div className="space-y-2">
{failedStatus.exit_code !== undefined && (
<div>
<span className="text-xs font-medium text-foreground-light">
Exit Code:
</span>
<div className="font-mono text-sm text-foreground mt-1">
{failedStatus.exit_code}
</div>
</div>
)}
{failedStatus.reason && (
<div>
<span className="text-xs font-medium text-foreground-light">Reason:</span>
<pre className="font-mono text-sm text-foreground whitespace-pre-wrap break-words mt-1 max-h-32 overflow-y-auto">
{failedStatus.reason}
</pre>
</div>
)}
{failedStatus.message && (
<div>
<span className="text-xs font-medium text-foreground-light">
Message:
</span>
<pre className="font-mono text-sm text-foreground whitespace-pre-wrap break-words mt-1 max-h-32 overflow-y-auto">
{failedStatus.message}
</pre>
</div>
)}
</div>
</>
)}
</div>
</CollapsibleContent>
</Collapsible>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertTriangle className="w-3 h-3" />
<span>Failed</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
{getPipelineStateMessages(requestStatus, 'failed').message}
</TooltipContent>
</Tooltip>
)
}
// Map backend statuses to UX-friendly display
const getStatusConfig = () => {
const statusName =
pipelineStatus && typeof pipelineStatus === 'object' && 'name' in pipelineStatus
? pipelineStatus.name
: undefined
// Get consistent tooltip message using the same logic as other components
const stateMessages = getPipelineStateMessages(requestStatus, statusName)
if (requestStatus === PipelineStatusRequestStatus.EnableRequested) {
return {
label: 'Enabling...',
dot: <Loader2 className="animate-spin w-3 h-3 text-brand-600" />,
color: 'text-brand-600',
tooltip: 'Pipeline is being enabled and will start shortly',
tooltip: stateMessages.message,
}
}
@@ -237,7 +85,7 @@ const PipelineStatus = ({
label: 'Disabling...',
dot: <Loader2 className="animate-spin w-3 h-3 text-warning-600" />,
color: 'text-warning-600',
tooltip: 'Pipeline is being disabled and will stop shortly',
tooltip: stateMessages.message,
}
}
@@ -255,35 +103,35 @@ const PipelineStatus = ({
label: 'Starting',
dot: <Loader2 className="animate-spin w-3 h-3 text-warning-600" />,
color: 'text-warning-600',
tooltip: 'Pipeline is initializing and will be ready soon',
tooltip: stateMessages.message,
}
case PipelineStatusName.STARTED:
return {
label: 'Running',
dot: <div className="w-2 h-2 bg-brand-600 rounded-full" />,
color: 'text-brand-600',
tooltip: 'Pipeline is active and processing data',
tooltip: stateMessages.message,
}
case PipelineStatusName.STOPPED:
return {
label: 'Stopped',
dot: <div className="w-2 h-2 bg-foreground-lighter rounded-full" />,
color: 'text-foreground-light',
tooltip: 'Pipeline is not running - enable to start processing',
tooltip: stateMessages.message,
}
case PipelineStatusName.UNKNOWN:
return {
label: 'Unknown',
dot: <div className="w-2 h-2 bg-warning-600 rounded-full" />,
color: 'text-warning-600',
tooltip: 'Pipeline status could not be determined',
tooltip: stateMessages.message,
}
default:
return {
label: 'Unknown',
dot: <div className="w-2 h-2 bg-destructive-600 rounded-full" />,
color: 'text-destructive-600',
tooltip: 'Pipeline status is unclear - check logs for details',
tooltip: stateMessages.message,
}
}
}
@@ -301,31 +149,27 @@ const PipelineStatus = ({
return (
<>
{isLoading && <ShimmeringLoader></ShimmeringLoader>}
{isError && <AlertError error={error} subject="Failed to retrieve pipeline status" />}
{isLoading && <ShimmeringLoader />}
{isError && (
<AlertError error={error} subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_PIPELINE_STATUS} />
)}
{isSuccess && (
<>
{statusConfig.isFailedStatus ? (
<div className="relative">{renderFailedStatus(pipelineStatus as FailedStatus)}</div>
<div className="relative">{renderFailedStatus()}</div>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className={cn('flex items-center gap-2 text-sm', statusConfig.color)}>
{statusConfig.dot}
<span>{statusConfig.label}</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{statusConfig.tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className={cn('flex items-center gap-2 text-sm w-min', statusConfig.color)}>
{statusConfig.dot}
<span>{statusConfig.label}</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">{statusConfig.tooltip}</TooltipContent>
</Tooltip>
)}
</>
)}
</>
)
}
export default PipelineStatus
@@ -0,0 +1,396 @@
import { Activity, AlertTriangle, ChevronLeft, Copy, ExternalLink, Search } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import Table from 'components/to-be-cleaned/Table'
import AlertError from 'components/ui/AlertError'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useReplicationPipelineByIdQuery } from 'data/replication/pipeline-by-id-query'
import { useReplicationPipelineReplicationStatusQuery } from 'data/replication/pipeline-replication-status-query'
import { useReplicationPipelineStatusQuery } from 'data/replication/pipeline-status-query'
import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation'
import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation'
import {
PipelineStatusRequestStatus,
usePipelineRequestStatus,
} from 'state/replication-pipeline-request-status'
import { Badge, Button, cn, copyToClipboard, Input_Shadcn_ } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns'
import { getStatusName, PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
import { PipelineStatus } from './PipelineStatus'
import { TableState } from './ReplicationPipelineStatus.types'
import { getDisabledStateConfig, getStatusConfig } from './ReplicationPipelineStatus.utils'
interface ReplicationPipelineStatusProps {
pipelineId: number
destinationName?: string
onSelectBack: () => void
}
export const ReplicationPipelineStatus = ({
pipelineId,
destinationName,
onSelectBack,
}: ReplicationPipelineStatusProps) => {
const { ref: projectRef } = useParams()
const [filterString, setFilterString] = useState<string>('')
const { getRequestStatus, updatePipelineStatus, setRequestStatus } = usePipelineRequestStatus()
const requestStatus = getRequestStatus(pipelineId)
const {
data: pipeline,
error: pipelineError,
isLoading: isPipelineLoading,
isError: isPipelineError,
} = useReplicationPipelineByIdQuery({
projectRef,
pipelineId,
})
const {
data: pipelineStatusData,
error: pipelineStatusError,
isLoading: isPipelineStatusLoading,
isError: isPipelineStatusError,
isSuccess: isPipelineStatusSuccess,
} = useReplicationPipelineStatusQuery(
{ projectRef, pipelineId },
{
enabled: !!pipelineId,
refetchInterval: 2000, // Poll every 2 seconds
}
)
const {
data: replicationStatusData,
error: statusError,
isLoading: isStatusLoading,
isError: isStatusError,
} = useReplicationPipelineReplicationStatusQuery(
{ projectRef, pipelineId },
{
enabled: !!pipelineId,
refetchInterval: 2000, // Poll every 2 seconds
}
)
const { mutateAsync: startPipeline, isLoading: isStartingPipeline } = useStartPipelineMutation()
const { mutateAsync: stopPipeline, isLoading: isStoppingPipeline } = useStopPipelineMutation()
const statusName = getStatusName(pipelineStatusData?.status)
const config = getDisabledStateConfig({ requestStatus, statusName })
const tableStatuses = replicationStatusData?.table_statuses || []
const filteredTableStatuses =
filterString.length === 0
? tableStatuses
: tableStatuses.filter((table: TableState) =>
table.table_name.toLowerCase().includes(filterString.toLowerCase())
)
const errorTables = tableStatuses.filter((table: TableState) => table.state.name === 'error')
const hasErrors = errorTables.length > 0
const isPipelineRunning = statusName === 'started'
const hasTableData = tableStatuses.length > 0
const isEnablingDisabling =
requestStatus === PipelineStatusRequestStatus.EnableRequested ||
requestStatus === PipelineStatusRequestStatus.DisableRequested
const showDisabledState = !isPipelineRunning || isEnablingDisabling
const handleCopyTableStatus = async (tableName: string, state: TableState['state']) => {
const statusText = `Table: ${tableName}\nStatus: ${state.name}${
'message' in state ? `\nError: ${state.message}` : ''
}${'lag' in state ? `\nLag: ${state.lag}ms` : ''}`
try {
await copyToClipboard(statusText)
toast.success('Table status copied to clipboard')
} catch {
toast.error(PIPELINE_ERROR_MESSAGES.COPY_TABLE_STATUS)
}
}
const onTogglePipeline = async () => {
if (!projectRef) {
return console.error('Project ref is required')
}
if (!pipeline) {
return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND)
}
try {
if (statusName === 'stopped') {
await startPipeline({ projectRef, pipelineId: pipeline.id })
setRequestStatus(pipeline.id, PipelineStatusRequestStatus.EnableRequested)
} else if (statusName === 'started') {
await stopPipeline({ projectRef, pipelineId: pipeline.id })
setRequestStatus(pipeline.id, PipelineStatusRequestStatus.DisableRequested)
}
} catch (error) {
toast.error(PIPELINE_ERROR_MESSAGES.ENABLE_DESTINATION)
}
}
useEffect(() => {
updatePipelineStatus(pipelineId, statusName)
}, [pipelineId, statusName, updatePipelineStatus])
if (isPipelineError) {
return (
<div className="flex flex-col gap-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-x-3">
<Button
type="outline"
onClick={onSelectBack}
icon={<ChevronLeft />}
style={{ padding: '5px' }}
/>
<h3 className="text-xl font-semibold">Pipeline Status</h3>
</div>
</div>
<AlertError error={pipelineError} subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_PIPELINE} />
</div>
)
}
return (
<div className="space-y-4">
{/* Header with back button and filters */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-x-3">
<Button
type="outline"
onClick={onSelectBack}
icon={<ChevronLeft />}
style={{ padding: '5px' }}
/>
<div>
<div className="flex items-center gap-x-3">
<h3 className="text-xl font-semibold">{destinationName || 'Pipeline'}</h3>
<PipelineStatus
pipelineStatus={pipelineStatusData?.status}
error={pipelineStatusError}
isLoading={isPipelineStatusLoading}
isError={isPipelineStatusError}
isSuccess={isPipelineStatusSuccess}
requestStatus={requestStatus}
/>
</div>
</div>
</div>
<div className="flex items-center gap-x-2">
<div className="relative">
<Search
className="absolute left-2 top-1/2 transform -translate-y-1/2 text-foreground-lighter"
size={14}
/>
<Input_Shadcn_
className="pl-7 h-[26px] text-xs"
placeholder="Search for tables"
value={filterString}
onChange={(e) => setFilterString(e.target.value)}
/>
</div>
<Button
type={statusName === 'stopped' ? 'primary' : 'default'}
onClick={() => onTogglePipeline()}
loading={isStartingPipeline || isStoppingPipeline}
disabled={!['failed', 'started', 'stopped'].includes(statusName ?? '')}
>
{statusName === 'stopped' ? 'Enable' : 'Disable'} pipeline
</Button>
</div>
</div>
{(isPipelineLoading || isStatusLoading) && <GenericSkeletonLoader />}
{isStatusError && (
<AlertError
error={statusError}
subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_REPLICATION_STATUS}
/>
)}
{hasErrors && (
<div className="p-4 border border-destructive-300 bg-destructive-100 rounded-lg">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-destructive-600 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<h4 className="font-medium text-destructive-900 mb-1">
{errorTables.length} table{errorTables.length > 1 ? 's' : ''} failed
</h4>
<p className="text-sm text-destructive-700 mb-3">
Some tables encountered replication errors. Check the logs for detailed error
information.
</p>
<Button
asChild
type="outline"
size="tiny"
icon={<ExternalLink className="w-3 h-3" />}
className="text-destructive-600 border-destructive-300 hover:bg-destructive-50"
>
<Link
target="_blank"
rel="noreferrer noopener"
href={`/project/${projectRef}/logs/postgres-logs`}
>
View Logs
</Link>
</Button>
</div>
</div>
</div>
)}
{hasTableData && (
<div className="flex flex-col gap-y-4">
{showDisabledState && (
<div
className={cn(
'p-4 border border-default rounded-lg flex items-center justify-between',
config.colors.bg
)}
>
<div className="flex items-center gap-x-3">
<div
className={cn(
'w-10 h-10 rounded-full flex items-center justify-center',
config.colors.iconBg
)}
>
<div className={config.colors.icon}>{config.icon}</div>
</div>
<div className="flex-1">
<h4 className={`text-sm font-medium ${config.colors.text}`}>{config.title}</h4>
<p className={`text-sm ${config.colors.subtext}`}>{config.message}</p>
</div>
</div>
</div>
)}
<div className="w-full overflow-hidden overflow-x-auto">
<Table
head={[
<Table.th key="table">Table</Table.th>,
<Table.th key="status">Status</Table.th>,
<Table.th key="details">Details</Table.th>,
<Table.th key="actions" className="w-16 text-center">
Actions
</Table.th>,
]}
body={filteredTableStatuses.map((table: TableState, index: number) => {
const statusConfig = getStatusConfig(table.state)
return (
<Table.tr key={`${table.table_name}-${index}`} className="border-t">
<Table.td>
<div className="flex items-center gap-x-2">
<p>{table.table_name}</p>
<ButtonTooltip
asChild
type="text"
className="px-1.5"
icon={<ExternalLink />}
tooltip={{ content: { side: 'bottom', text: 'Open in Table Editor' } }}
>
<a
target="_blank"
rel="noopener noreferrer"
href={`/project/${projectRef}/editor/${table.table_id}`}
/>
</ButtonTooltip>
</div>
</Table.td>
<Table.td>
{showDisabledState ? (
<Badge variant="default">Not Available</Badge>
) : (
statusConfig.badge
)}
</Table.td>
<Table.td>
{showDisabledState ? (
<p className="text-sm text-foreground-lighter">
Status unavailable while pipeline is {config.badge.toLowerCase()}
</p>
) : (
<div className="space-y-1">
<div className="text-sm text-foreground">{statusConfig.description}</div>
{'lag' in table.state && (
<div className="text-xs text-foreground-light">
Lag: {table.state.lag}ms
</div>
)}
</div>
)}
</Table.td>
<Table.td className="text-center">
<ButtonTooltip
type="text"
size="tiny"
icon={<Copy className="w-3 h-3" />}
className="px-1.5"
disabled={showDisabledState}
onClick={() => handleCopyTableStatus(table.table_name, table.state)}
tooltip={{
content: {
side: 'bottom',
text: showDisabledState
? `Copy unavailable while pipeline is ${config.badge.toLowerCase()}`
: 'Copy status details',
},
}}
/>
</Table.td>
</Table.tr>
)
})}
/>
</div>
</div>
)}
{filteredTableStatuses.length === 0 && hasTableData && (
<Table.tr>
<Table.td colSpan={4}>
<div className="space-y-1">
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-light">
Your search for "{filterString}" did not return any results
</p>
</div>
</Table.td>
</Table.tr>
)}
{!isStatusLoading && tableStatuses.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 px-4">
<div className="w-full max-w-sm mx-auto text-center space-y-4">
<div className="w-16 h-16 bg-surface-200 rounded-full flex items-center justify-center mx-auto">
<Activity className="w-8 h-8 text-foreground-lighter" />
</div>
<div className="space-y-2">
<h4 className="text-lg font-semibold text-foreground">
{showDisabledState ? 'Pipeline Not Running' : 'No table status information'}
</h4>
<p className="text-sm text-foreground-light leading-relaxed">
{showDisabledState
? `The replication pipeline is currently ${statusName || 'not active'}. Table status
information is not available while the pipeline is in this state.`
: `This pipeline doesn't have any table replication status data available yet. The status will appear here once replication begins.`}
</p>
</div>
<p className="text-xs text-foreground-lighter">
Data refreshes automatically every 2 seconds
</p>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,10 @@
export type TableState = {
table_id: number
table_name: string
state:
| { name: 'queued' }
| { name: 'copying_table' }
| { name: 'copied_table' }
| { name: 'following_wal'; lag: number }
| { name: 'error'; message: string }
}
@@ -0,0 +1,108 @@
import { ReplicationPipelineStatusData } from 'data/replication/pipeline-status-query'
import { Activity, Clock, HelpCircle, Loader2, XCircle } from 'lucide-react'
import { PipelineStatusRequestStatus } from 'state/replication-pipeline-request-status'
import { Badge } from 'ui'
import { getPipelineStateMessages } from './Pipeline.utils'
import { TableState } from './ReplicationPipelineStatus.types'
export const getStatusConfig = (state: TableState['state']) => {
switch (state.name) {
case 'queued':
return {
badge: <Badge variant="warning">Queued</Badge>,
description: 'Waiting to start replication',
color: 'text-warning-600',
}
case 'copying_table':
return {
badge: <Badge variant="brand">Copying</Badge>,
description: 'Initial data copy in progress',
color: 'text-brand-600',
}
case 'copied_table':
return {
badge: <Badge variant="success">Copied</Badge>,
description: 'Initial copy completed',
color: 'text-success-600',
}
case 'following_wal':
return {
badge: <Badge variant="success">Live</Badge>,
description: `Replicating live changes`,
color: 'text-success-600',
}
case 'error':
return {
badge: <Badge variant="destructive">Error</Badge>,
description: state.message,
color: 'text-destructive-600',
}
default:
return {
badge: <Badge variant="warning">Unknown</Badge>,
description: 'Unknown status',
color: 'text-warning-600',
}
}
}
export const getDisabledStateConfig = ({
requestStatus,
statusName,
}: {
requestStatus: PipelineStatusRequestStatus
statusName?: ReplicationPipelineStatusData['status']['name']
}) => {
const { title, message, badge } = getPipelineStateMessages(requestStatus, statusName)
// Get icon and colors based on current state
const isEnabling = requestStatus === PipelineStatusRequestStatus.EnableRequested
const isDisabling = requestStatus === PipelineStatusRequestStatus.DisableRequested
const isTransitioning = isEnabling || isDisabling
const icon = isTransitioning ? (
<Loader2 className="w-6 h-6 animate-spin" />
) : statusName === 'failed' ? (
<XCircle className="w-6 h-6" />
) : statusName === 'starting' ? (
<Clock className="w-6 h-6" />
) : statusName === 'unknown' ? (
<HelpCircle className="w-6 h-6" />
) : (
<Activity className="w-6 h-6" />
)
const colors = isEnabling
? {
bg: 'bg-brand-50',
text: 'text-brand-900',
subtext: 'text-brand-700',
iconBg: 'bg-brand-600',
icon: 'text-white dark:text-black',
}
: isDisabling || statusName === 'starting' || statusName === 'unknown'
? {
bg: 'bg-warning-50',
text: 'text-warning-900',
subtext: 'text-warning-700',
iconBg: 'bg-warning-600',
icon: 'text-white dark:text-black',
}
: statusName === 'failed'
? {
bg: 'bg-destructive-50',
text: 'text-destructive-900',
subtext: 'text-destructive-700',
iconBg: 'bg-destructive-600',
icon: 'text-white dark:text-black',
}
: {
bg: 'bg-surface-100',
text: 'text-foreground',
subtext: 'text-foreground-light',
iconBg: 'bg-foreground-lighter',
icon: 'text-white dark:text-black',
}
return { title, message, badge, icon, colors }
}
@@ -1,5 +1,14 @@
import AlertError from 'components/ui/AlertError'
import { Edit, MoreVertical, Pause, Play, Trash } from 'lucide-react'
import { toast } from 'sonner'
import { useParams } from 'common'
import AlertError from 'components/ui/AlertError'
import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation'
import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation'
import {
PipelineStatusRequestStatus,
usePipelineRequestStatus,
} from 'state/replication-pipeline-request-status'
import { ResponseError } from 'types'
import {
Button,
@@ -10,29 +19,31 @@ import {
DropdownMenuTrigger,
} from 'ui'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
import { Pipeline } from './DestinationRow'
import { PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
import { PipelineStatusName } from './PipelineStatus'
interface RowMenuProps {
pipeline: Pipeline | undefined
pipelineStatus: any
error: ResponseError | null
isLoading: boolean
isError: boolean
onEnableClick: () => void
onDisableClick: () => void
onEditClick: () => void
onDeleteClick: () => void
}
const RowMenu = ({
export const RowMenu = ({
pipeline,
pipelineStatus,
error,
isLoading,
isError,
onEnableClick,
onDisableClick,
onEditClick,
onDeleteClick,
}: RowMenuProps) => {
const { ref: projectRef } = useParams()
const getStatusName = (status: any) => {
if (status && typeof status === 'object' && 'name' in status) {
return status.name
@@ -43,33 +54,104 @@ const RowMenu = ({
const statusName = getStatusName(pipelineStatus)
const pipelineEnabled = statusName !== PipelineStatusName.STOPPED
const { mutateAsync: startPipeline } = useStartPipelineMutation()
const { mutateAsync: stopPipeline } = useStopPipelineMutation()
const { setRequestStatus: setGlobalRequestStatus } = usePipelineRequestStatus()
const onEnablePipeline = async () => {
if (!projectRef) {
return console.error('Project ref is required')
}
if (!pipeline) {
return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND)
}
try {
await startPipeline({ projectRef, pipelineId: pipeline.id })
toast(`Enabling pipeline ${pipeline.destination_name}`)
setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.EnableRequested)
} catch (error) {
toast.error(PIPELINE_ERROR_MESSAGES.ENABLE_DESTINATION)
}
}
const onDisablePipeline = async () => {
if (!projectRef) {
console.error('Project ref is required')
return
}
if (!pipeline) {
toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND)
return
}
try {
await stopPipeline({ projectRef, pipelineId: pipeline.id })
toast(`Disabling pipeline ${pipeline.destination_name}`)
setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.DisableRequested)
} catch (error) {
toast.error(PIPELINE_ERROR_MESSAGES.DISABLE_DESTINATION)
}
}
return (
<div className="flex justify-end items-center space-x-2">
{isLoading && <ShimmeringLoader></ShimmeringLoader>}
{isError && <AlertError error={error} subject="Failed to retrieve pipeline status" />}
{isLoading && <ShimmeringLoader />}
{isError && (
<AlertError error={error} subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_PIPELINE_STATUS} />
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" className="px-1.5" icon={<MoreVertical />} />
<Button
type="default"
className="px-1.5"
icon={<MoreVertical />}
onClick={(e) => e.stopPropagation()}
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-52">
{pipelineEnabled ? (
<DropdownMenuItem className="space-x-2" onClick={onDisableClick}>
<DropdownMenuItem
className="space-x-2"
onClick={(e) => {
e.stopPropagation()
onDisablePipeline()
}}
>
<Pause size={14} />
<p>Disable</p>
<p>Disable pipeline</p>
</DropdownMenuItem>
) : (
<DropdownMenuItem className="space-x-2" onClick={onEnableClick}>
<DropdownMenuItem
className="space-x-2"
onClick={(e) => {
e.stopPropagation()
onEnablePipeline()
}}
>
<Play size={14} />
<p>Enable</p>
<p>Enable pipeline</p>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem className="space-x-2" onClick={onEditClick}>
<DropdownMenuItem
className="space-x-2"
onClick={(e) => {
e.stopPropagation()
onEditClick()
}}
>
<Edit size={14} />
<p>Edit destination</p>
</DropdownMenuItem>
<DropdownMenuItem className="space-x-2" onClick={onDeleteClick}>
<DropdownMenuItem
className="space-x-2"
onClick={(e) => {
e.stopPropagation()
onDeleteClick()
}}
>
<Trash stroke="red" size={14} />
<p>Delete destination</p>
</DropdownMenuItem>
@@ -78,5 +160,3 @@ const RowMenu = ({
</div>
)
}
export default RowMenu
@@ -42,7 +42,7 @@ export const MarkdownContent = ({ integrationId }: { integrationId: string }) =>
/>
)}
{supportExpanding && (
<div className={cn('bottom-0 z-10', !isExpanded ? 'absolute' : 'relative mt-3')}>
<div className={cn('bottom-0', !isExpanded ? 'absolute' : 'relative mt-3')}>
<button
className="text-foreground-light hover:text-foreground underline text-sm"
onClick={() => setIsExpanded(!isExpanded)}
@@ -2,18 +2,17 @@ import { isEmpty } from 'lodash'
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import SchemaEditor from 'components/interfaces/TableGridEditor/SidePanelEditor/SchemaEditor'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
import SchemaSelector from 'components/ui/SchemaSelector'
import { useSchemaCreateMutation } from 'data/database/schema-create-mutation'
import { useSchemasQuery } from 'data/database/schemas-query'
import { useFDWCreateMutation } from 'data/fdw/fdw-create-mutation'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import {
Button,
Form,
Input,
Label_Shadcn_,
RadioGroupStacked,
RadioGroupStackedItem,
Separator,
@@ -25,7 +24,6 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { CreateWrapperSheetProps } from './CreateWrapperSheet'
import InputField from './InputField'
import { makeValidateRequired } from './Wrappers.utils'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
const FORM_ID = 'create-wrapper-form'
@@ -68,12 +66,11 @@ export const CreateIcebergWrapperSheet = ({
const org = useSelectedOrganization()
const { mutate: sendEvent } = useSendEventMutation()
const [createSchemaSheetOpen, setCreateSchemaSheetOpen] = useState(false)
const [selectedTarget, setSelectedTarget] = useState<Target>('S3Tables')
const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({})
const { mutate: createFDW, isLoading: isCreating } = useFDWCreateMutation({
const { mutateAsync: createFDW, isLoading: isCreatingWrapper } = useFDWCreateMutation({
onSuccess: () => {
toast.success(`Successfully created ${wrapperMeta?.label} foreign data wrapper`)
onClose()
@@ -100,8 +97,10 @@ export const CreateIcebergWrapperSheet = ({
}
}, [wrapperMetaOriginal, selectedTarget])
// prefetch schemas to make sure the schema selector is populated
useSchemasQuery({ projectRef: project?.ref, connectionString: project?.connectionString })
const { data: schemas } = useSchemasQuery({
projectRef: project?.ref!,
connectionString: project?.connectionString,
})
const initialValues = {
wrapper_name: '',
@@ -113,41 +112,72 @@ export const CreateIcebergWrapperSheet = ({
),
}
const { mutateAsync: createSchema, isLoading: isCreatingSchema } = useSchemaCreateMutation()
const onSubmit = async (values: any) => {
const validate = makeValidateRequired(wrapperMeta.server.options)
const errors: any = validate(values)
if (values.source_schema.length === 0) {
errors.source_schema = 'Please provide a source schema'
errors.source_schema = 'Please provide a namespace name'
}
if (values.wrapper_name.length === 0) {
errors.wrapper_name = 'Please provide a name for your wrapper'
}
if (!isEmpty(errors)) return setFormErrors(errors)
createFDW({
projectRef: project?.ref,
connectionString: project?.connectionString,
wrapperMeta,
formState: { ...values, server_name: `${values.wrapper_name}_server` },
mode: 'schema',
tables: [],
sourceSchema: values.source_schema,
targetSchema: values.target_schema,
})
if (values.target_schema.length === 0) {
errors.target_schema = 'Please provide an unique target schema'
}
const foundSchema = schemas?.find((s) => s.name === values.target_schema)
if (foundSchema) {
errors.target_schema = 'This schema already exists. Please specify a unique schema name.'
}
sendEvent({
action: 'foreign_data_wrapper_created',
properties: {
wrapperType: wrapperMeta.label,
},
groups: {
project: project?.ref ?? 'Unknown',
organization: org?.slug ?? 'Unknown',
},
})
setFormErrors(errors)
if (!isEmpty(errors)) {
return
}
try {
await createSchema({
projectRef: project?.ref,
connectionString: project?.connectionString,
name: values.target_schema,
})
await createFDW({
projectRef: project?.ref,
connectionString: project?.connectionString,
wrapperMeta,
formState: {
...values,
server_name: `${values.wrapper_name}_server`,
supabase_target_schema: values.target_schema,
},
mode: 'schema',
tables: [],
sourceSchema: values.source_schema,
targetSchema: values.target_schema,
})
sendEvent({
action: 'foreign_data_wrapper_created',
properties: {
wrapperType: wrapperMeta.label,
},
groups: {
project: project?.ref ?? 'Unknown',
organization: org?.slug ?? 'Unknown',
},
})
} catch (error) {
console.error(error)
// The error will be handled by the mutation onError callback (toast.error)
}
}
const isLoading = isCreatingWrapper || isCreatingSchema
return (
<>
<div className="h-full" tabIndex={-1}>
@@ -272,7 +302,8 @@ export const CreateIcebergWrapperSheet = ({
<FormSectionLabel>
<p>Foreign Schema</p>
<p className="text-foreground-light mt-2 w-[90%]">
All wrapper tables will be created in the specified target schema.
You can query your data from the foreign tables in the specified schema
after the wrapper is created.
</p>
</FormSectionLabel>
}
@@ -292,18 +323,21 @@ export const CreateIcebergWrapperSheet = ({
</div>
)}
<div className="flex flex-col gap-2">
<Label_Shadcn_ className="text-foreground-light">
Target Schema
</Label_Shadcn_>
<SchemaSelector
portal={false}
size="small"
selectedSchemaName={values.target_schema}
onSelectSchema={(schema) => setFieldValue('target_schema', schema)}
onSelectCreateSchema={() => setCreateSchemaSheetOpen(true)}
<InputField
key="target_schema"
option={{
name: 'target_schema',
label: 'Specify a new schema to create all wrapper tables in',
required: true,
encrypted: false,
secureEntry: false,
}}
loading={false}
error={formErrors['target_schema']}
/>
<p className="text-foreground-lighter text-sm">
Be careful not to use an API exposed schema.
A new schema will be created. For security purposes, the wrapper tables
from the foreign schema cannot be created within an existing schema
</p>
</div>
</FormSectionContent>
@@ -316,7 +350,7 @@ export const CreateIcebergWrapperSheet = ({
type="default"
htmlType="button"
onClick={onClosePanel}
disabled={isCreating}
disabled={isLoading}
>
Cancel
</Button>
@@ -325,20 +359,11 @@ export const CreateIcebergWrapperSheet = ({
type="primary"
form={FORM_ID}
htmlType="submit"
disabled={isCreating}
loading={isCreating}
loading={isLoading}
>
Create wrapper
</Button>
</SheetFooter>
<SchemaEditor
visible={createSchemaSheetOpen}
closePanel={() => setCreateSchemaSheetOpen(false)}
onSuccess={(schema) => {
setFieldValue('target_schema', schema)
setCreateSchemaSheetOpen(false)
}}
/>
</>
)
}}
@@ -4,19 +4,18 @@ import { Edit, Trash } from 'lucide-react'
import { useState } from 'react'
import { toast } from 'sonner'
import SchemaEditor from 'components/interfaces/TableGridEditor/SidePanelEditor/SchemaEditor'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
import SchemaSelector from 'components/ui/SchemaSelector'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { useSchemaCreateMutation } from 'data/database/schema-create-mutation'
import { invalidateSchemasQuery, useSchemasQuery } from 'data/database/schemas-query'
import { useFDWCreateMutation } from 'data/fdw/fdw-create-mutation'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import {
Button,
Form,
Input,
Label_Shadcn_,
RadioGroupStacked,
RadioGroupStackedItem,
Separator,
@@ -30,7 +29,6 @@ import InputField from './InputField'
import { WrapperMeta } from './Wrappers.types'
import { makeValidateRequired } from './Wrappers.utils'
import WrapperTableEditor from './WrapperTableEditor'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
export interface CreateWrapperSheetProps {
isClosing: boolean
@@ -48,6 +46,7 @@ export const CreateWrapperSheet = ({
onClose,
}: CreateWrapperSheetProps) => {
const queryClient = useQueryClient()
const { project } = useProjectContext()
const org = useSelectedOrganization()
const { mutate: sendEvent } = useSendEventMutation()
@@ -73,7 +72,7 @@ export const CreateWrapperSheet = ({
const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({})
const { mutate: createFDW, isLoading: isCreating } = useFDWCreateMutation({
const { mutateAsync: createFDW, isLoading: isCreatingWrapper } = useFDWCreateMutation({
onSuccess: () => {
toast.success(`Successfully created ${wrapperMeta?.label} foreign data wrapper`)
setNewTables([])
@@ -85,8 +84,10 @@ export const CreateWrapperSheet = ({
},
})
// prefetch schemas to make sure the schema selector is populated
useSchemasQuery({ projectRef: project?.ref, connectionString: project?.connectionString })
const { data: schemas } = useSchemasQuery({
projectRef: project?.ref!,
connectionString: project?.connectionString,
})
const initialValues = {
wrapper_name: '',
@@ -98,6 +99,8 @@ export const CreateWrapperSheet = ({
),
}
const { mutateAsync: createSchema, isLoading: isCreatingSchema } = useSchemaCreateMutation()
const onUpdateTable = (values: any) => {
setNewTables((prev) => {
// if the new values have tableIndex, we are editing an existing table
@@ -121,37 +124,70 @@ export const CreateWrapperSheet = ({
if (values.wrapper_name.length === 0) {
errors.wrapper_name = 'Please provide a name for your wrapper'
}
if (selectedMode === 'tables' && newTables.length === 0) {
errors.tables = 'Please add at least one table'
}
if (selectedMode === 'schema' && values.source_schema.length === 0) {
errors.source_schema = 'Please provide a source schema'
}
if (!isEmpty(errors)) return setFormErrors(errors)
createFDW({
projectRef: project?.ref,
connectionString: project?.connectionString,
wrapperMeta,
formState: { ...values, server_name: `${values.wrapper_name}_server` },
mode: selectedMode,
tables: newTables,
sourceSchema: values.source_schema,
targetSchema: values.target_schema,
})
if (selectedMode === 'tables') {
if (newTables.length === 0) {
errors.tables = 'Please provide at least one table'
}
}
if (selectedMode === 'schema') {
if (values.source_schema.length === 0) {
errors.source_schema = 'Please provide a source schema'
}
if (values.target_schema.length === 0) {
errors.target_schema = 'Please provide an unique target schema'
}
const foundSchema = schemas?.find((s) => s.name === values.target_schema)
if (foundSchema) {
errors.target_schema = 'This schema already exists. Please specify a unique schema name.'
}
}
sendEvent({
action: 'foreign_data_wrapper_created',
properties: {
wrapperType: wrapperMeta.label,
},
groups: {
project: project?.ref ?? 'Unknown',
organization: org?.slug ?? 'Unknown',
},
})
setFormErrors(errors)
if (!isEmpty(errors)) {
return
}
try {
await createSchema({
projectRef: project?.ref,
connectionString: project?.connectionString,
name: values.target_schema,
})
await createFDW({
projectRef: project?.ref,
connectionString: project?.connectionString,
wrapperMeta,
formState: {
...values,
server_name: `${values.wrapper_name}_server`,
supabase_target_schema: selectedMode === 'schema' ? values.target_schema : undefined,
},
mode: selectedMode,
tables: newTables,
sourceSchema: values.source_schema,
targetSchema: values.target_schema,
})
sendEvent({
action: 'foreign_data_wrapper_created',
properties: {
wrapperType: wrapperMeta.label,
},
groups: {
project: project?.ref ?? 'Unknown',
organization: org?.slug ?? 'Unknown',
},
})
} catch (error) {
console.error(error)
// The error will be handled by the mutation onError callback (toast.error)
}
}
const isLoading = isCreatingWrapper || isCreatingSchema
return (
<>
<div className="h-full" tabIndex={-1}>
@@ -161,7 +197,7 @@ export const CreateWrapperSheet = ({
onSubmit={onSubmit}
className="flex-grow flex flex-col h-full"
>
{({ handleReset, values, initialValues, setFieldValue }: any) => {
{({ values, initialValues, setFieldValue }: any) => {
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
const onClosePanel = () => {
@@ -236,7 +272,7 @@ export const CreateWrapperSheet = ({
<div className="flex gap-x-5">
<div className="flex flex-col">
<p className="text-foreground-light text-left">
Define tables where the wrapper data will be shown.
Create foreign tables to query data from {wrapperMeta.label}.
</p>
</div>
</div>
@@ -261,7 +297,8 @@ export const CreateWrapperSheet = ({
<div className="flex gap-x-5">
<div className="flex flex-col">
<p className="text-foreground-light text-left">
Specify schema in which the wrapper will create tables.
Create all foreign tables from {wrapperMeta.label} in a specified
schema.
</p>
</div>
</div>
@@ -366,38 +403,44 @@ export const CreateWrapperSheet = ({
<FormSectionLabel>
<p>Foreign Schema</p>
<p className="text-foreground-light mt-2 w-[90%]">
All wrapper tables will be created in the specified target schema.
You can query your data from the foreign tables in the specified schema
after the wrapper is created.
</p>
</FormSectionLabel>
}
>
<FormSectionContent loading={false}>
{wrapperMeta.sourceSchemaOption && (
<div>
<InputField
key="source_schema"
option={wrapperMeta.sourceSchemaOption}
loading={false}
error={formErrors['source_schema']}
/>
<p className="text-foreground-lighter text-sm">
{wrapperMeta.sourceSchemaOption.description}
</p>
</div>
)}
{wrapperMeta.sourceSchemaOption &&
!wrapperMeta.sourceSchemaOption?.readOnly && (
// Hide the field if the source schema is read-only
<div>
<InputField
key="source_schema"
option={wrapperMeta.sourceSchemaOption}
loading={false}
error={formErrors['source_schema']}
/>
<p className="text-foreground-lighter text-sm">
{wrapperMeta.sourceSchemaOption.description}
</p>
</div>
)}
<div className="flex flex-col gap-2">
<Label_Shadcn_ className="text-foreground-light">
Target Schema
</Label_Shadcn_>
<SchemaSelector
portal={false}
size="small"
selectedSchemaName={values.target_schema}
onSelectSchema={(schema) => setFieldValue('target_schema', schema)}
onSelectCreateSchema={() => setCreateSchemaSheetOpen(true)}
<InputField
key="target_schema"
option={{
name: 'target_schema',
label: 'Specify a new schema to create all wrapper tables in',
required: true,
encrypted: false,
secureEntry: false,
}}
loading={false}
error={formErrors['target_schema']}
/>
<p className="text-foreground-lighter text-sm">
Be careful not to use an API exposed schema.
A new schema will be created. For security purposes, the wrapper tables
from the foreign schema cannot be created within an existing schema
</p>
</div>
</FormSectionContent>
@@ -411,7 +454,7 @@ export const CreateWrapperSheet = ({
type="default"
htmlType="button"
onClick={onClosePanel}
disabled={isCreating}
disabled={isLoading}
>
Cancel
</Button>
@@ -420,20 +463,11 @@ export const CreateWrapperSheet = ({
type="primary"
form={FORM_ID}
htmlType="submit"
disabled={isCreating}
loading={isCreating}
loading={isLoading}
>
Create wrapper
</Button>
</SheetFooter>
<SchemaEditor
visible={createSchemaSheetOpen}
closePanel={() => setCreateSchemaSheetOpen(false)}
onSuccess={(schema) => {
setFieldValue('target_schema', schema)
setCreateSchemaSheetOpen(false)
}}
/>
</>
)
}}
@@ -107,37 +107,39 @@ const WrapperRow = ({ wrapper }: WrapperRowProps) => {
</div>
))}
</TableCell>
<TableCell className="space-x-2 flex-nowrap">
<ButtonTooltip
disabled={!canManageWrappers}
type="default"
icon={<Edit strokeWidth={1.5} />}
className="px-1.5"
onClick={() => setEditWrapperShown(true)}
tooltip={{
content: {
side: 'bottom',
text: !canManageWrappers
? 'You need additional permissions to edit wrappers'
: 'Edit wrapper',
},
}}
/>
<ButtonTooltip
type="default"
disabled={!canManageWrappers}
icon={<Trash strokeWidth={1.5} />}
className="px-1.5"
onClick={() => setDeleteWrapperShown(true)}
tooltip={{
content: {
side: 'bottom',
text: !canManageWrappers
? 'You need additional permissions to delete wrappers'
: 'Delete wrapper',
},
}}
/>
<TableCell className="flex-nowrap">
<div className="flex items-center gap-x-2">
<ButtonTooltip
disabled={!canManageWrappers}
type="default"
icon={<Edit strokeWidth={1.5} />}
className="px-1.5"
onClick={() => setEditWrapperShown(true)}
tooltip={{
content: {
side: 'bottom',
text: !canManageWrappers
? 'You need additional permissions to edit wrappers'
: 'Edit wrapper',
},
}}
/>
<ButtonTooltip
type="default"
disabled={!canManageWrappers}
icon={<Trash strokeWidth={1.5} />}
className="px-1.5"
onClick={() => setDeleteWrapperShown(true)}
tooltip={{
content: {
side: 'bottom',
text: !canManageWrappers
? 'You need additional permissions to delete wrappers'
: 'Delete wrapper',
},
}}
/>
</div>
</TableCell>
</TableRow>
<Sheet open={editWrapperShown} onOpenChange={() => setIsClosingEditWrapper(true)}>
@@ -1,6 +1,6 @@
import { BASE_PATH } from 'lib/constants'
import { CreateIcebergWrapperSheet } from './CreateIcebergWrapperSheet'
import type { WrapperMeta } from './Wrappers.types'
import type { ServerOption, WrapperMeta } from './Wrappers.types'
export const WRAPPER_HANDLERS = {
STRIPE: 'stripe_fdw_handler',
@@ -27,6 +27,16 @@ export const WRAPPER_HANDLERS = {
ORB: 'wasm_fdw_handler',
}
const SUPABASE_TARGET_SCHEMA_OPTION: ServerOption = {
name: 'supabase_target_schema',
label: 'Target Schema',
required: false,
encrypted: false,
secureEntry: false,
readOnly: true,
hidden: true,
}
export const WRAPPERS: WrapperMeta[] = [
{
name: 'stripe_wrapper',
@@ -55,6 +65,7 @@ export const WRAPPERS: WrapperMeta[] = [
encrypted: false,
secureEntry: false,
},
SUPABASE_TARGET_SCHEMA_OPTION,
],
},
tables: [
@@ -1775,6 +1786,7 @@ export const WRAPPERS: WrapperMeta[] = [
encrypted: false,
secureEntry: false,
},
SUPABASE_TARGET_SCHEMA_OPTION,
],
},
canTargetSchema: true,
@@ -2224,12 +2236,13 @@ export const WRAPPERS: WrapperMeta[] = [
encrypted: false,
secureEntry: false,
},
SUPABASE_TARGET_SCHEMA_OPTION,
],
},
canTargetSchema: true,
sourceSchemaOption: {
name: 'source_schema',
label: 'Source Schema',
label: 'Namespace',
description: 'It should match the namespace of the Iceberg catalog.',
required: true,
encrypted: false,
@@ -12,7 +12,7 @@ export const makeValidateRequired = (options: { name: string; required: boolean
)
const requiredArrayOptions = Array.from(requiredArrayOptionsSet)
return (values: any) => {
return (values: Record<string, any>) => {
const errors = Object.fromEntries(
Object.entries(values)
.flatMap(([key, value]) =>
@@ -20,15 +20,18 @@ import {
useAsyncCheckProjectPermissions,
useCheckPermissions,
} from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { Button, Card, CardFooter, Form_Shadcn_ as Form } from 'ui'
import { BillingCustomerDataForm } from './BillingCustomerDataForm'
import {
BillingCustomerDataForm,
type BillingCustomerDataFormValues,
} from './BillingCustomerDataForm'
import { TAX_IDS } from './TaxID.constants'
import { useBillingCustomerDataForm } from './useBillingCustomerDataForm'
export const BillingCustomerData = () => {
const { slug } = useParams()
const selectedOrganization = useSelectedOrganization()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const { isSuccess: isPermissionsLoaded, can: canReadBillingCustomerData } =
useAsyncCheckProjectPermissions(PermissionAction.BILLING_READ, 'stripe.customer')
@@ -51,9 +54,13 @@ export const BillingCustomerData = () => {
isSuccess: loadedTaxId,
} = useOrganizationTaxIdQuery({ slug })
const initialCustomerData = useMemo(
const initialCustomerData = useMemo<Partial<BillingCustomerDataFormValues>>(
() => ({
...customerProfile?.address,
city: customerProfile?.address?.city ?? undefined,
country: customerProfile?.address?.country,
line1: customerProfile?.address?.line1,
line2: customerProfile?.address?.line2 ?? undefined,
postal_code: customerProfile?.address?.postal_code ?? undefined,
billing_name: customerProfile?.billing_name,
tax_id_type: taxId?.type,
tax_id_value: taxId?.value,
@@ -1,215 +0,0 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Pencil } from 'lucide-react'
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import AlertError from 'components/ui/AlertError'
import NoPermission from 'components/ui/NoPermission'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query'
import { useOrganizationCustomerProfileUpdateMutation } from 'data/organizations/organization-customer-profile-update-mutation'
import { useOrganizationTaxIdQuery } from 'data/organizations/organization-tax-id-query'
import { useOrganizationTaxIdUpdateMutation } from 'data/organizations/organization-tax-id-update-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogSectionSeparator,
DialogTitle,
Form_Shadcn_ as Form,
Label_Shadcn_ as Label,
} from 'ui'
import { BillingCustomerDataForm } from './BillingCustomerDataForm'
import { TAX_IDS } from './TaxID.constants'
import { useBillingCustomerDataForm } from './useBillingCustomerDataForm'
export const BillingCustomerDataExistingOrgDialog = () => {
const [open, setOpen] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const { slug } = useSelectedOrganization() ?? {}
const canReadBillingCustomerData = useCheckPermissions(
PermissionAction.BILLING_READ,
'stripe.customer'
)
const canUpdateBillingCustomerData = useCheckPermissions(
PermissionAction.BILLING_WRITE,
'stripe.customer'
)
const {
data: customerProfile,
error,
isLoading,
isSuccess,
} = useOrganizationCustomerProfileQuery({ slug }, { enabled: canReadBillingCustomerData })
const {
data: taxId,
error: errorTaxId,
isLoading: isLoadingTaxId,
isSuccess: isSuccessTaxId,
} = useOrganizationTaxIdQuery({ slug })
const initialCustomerData = useMemo(
() => ({
...customerProfile?.address,
billing_name: customerProfile?.billing_name,
tax_id_type: taxId?.type,
tax_id_value: taxId?.value,
tax_id_name: taxId
? TAX_IDS.find(
(option) => option.type === taxId.type && option.countryIso2 === taxId.country
)?.name || ''
: '',
}),
[customerProfile, taxId]
)
const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation()
const { mutateAsync: updateTaxId } = useOrganizationTaxIdUpdateMutation()
const { form, handleSubmit, handleReset, isDirty } = useBillingCustomerDataForm({
initialCustomerData,
onCustomerDataChange: async (data) => {
setIsSubmitting(true)
try {
await updateCustomerProfile({
slug,
address: data.address ?? undefined,
billing_name: data.billing_name,
})
await updateTaxId({ slug, taxId: data.tax_id })
toast.success('Successfully updated billing data')
setOpen(false)
setIsSubmitting(false)
} catch (error: any) {
toast.error(`Failed updating billing data: ${error.message}`)
setIsSubmitting(false)
}
},
})
const handleClose = () => {
handleReset()
setOpen(false)
}
const getAddressSummary = () => {
if (!customerProfile?.address?.line1) return 'Optionally add a billing address'
const parts = [
customerProfile?.billing_name,
customerProfile?.address?.line1,
customerProfile?.address?.city,
customerProfile?.address?.state,
customerProfile?.address?.country,
].filter(Boolean)
return parts.join(', ')
}
const isSubmitDisabled = !isDirty || !canUpdateBillingCustomerData || isSubmitting
return (
<>
<div>
<Label htmlFor="billing-address-btn" className="text-foreground-light block mb-0">
Billing Address / Tax Id
</Label>
{!canReadBillingCustomerData ? (
<NoPermission resourceText="view this organization's billing address" />
) : (
<>
{(isLoading || isLoadingTaxId) && (
<div className="space-y-2">
<ShimmeringLoader />
</div>
)}
{(error || errorTaxId) && (
<AlertError
subject="Failed to retrieve organization customer profile"
error={(error || errorTaxId) as any}
/>
)}
{isSuccess && isSuccessTaxId && (
<div className="flex items-center justify-between">
<p className="text-xs text-foreground">{getAddressSummary()}</p>
<Button
id="billing-address-btn"
onClick={() => setOpen(true)}
type="text"
aria-label="Edit"
size={'tiny'}
className="w-8 h-8 p-0 text-foreground-light"
disabled={!canUpdateBillingCustomerData}
>
<Pencil size={14} strokeWidth={1.5} />
</Button>
</div>
)}
</>
)}
</div>
<Dialog
open={open}
onOpenChange={(value) => {
if (!value) setOpen(false)
else setOpen(true)
}}
>
<DialogContent size={'large'}>
<DialogHeader>
<DialogTitle>Billing Address &amp; Tax Id</DialogTitle>
<DialogDescription>
This will be reflected in every upcoming invoice, past invoices are not affected
</DialogDescription>
</DialogHeader>
<DialogSectionSeparator />
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)}>
<BillingCustomerDataForm
className="p-5"
form={form}
disabled={!canUpdateBillingCustomerData}
/>
<DialogFooter className="justify-end">
{!canUpdateBillingCustomerData && (
<span className="text-sm text-foreground-lighter">
You need additional permissions to manage this organization's billing address
</span>
)}
<div className="flex items-center gap-2">
<Button type="default" onClick={handleClose}>
Cancel
</Button>
<Button
type="primary"
htmlType="submit"
disabled={isSubmitDisabled}
loading={isSubmitting}
>
Save
</Button>
</div>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</>
)
}
@@ -2,7 +2,7 @@ import { Check, ChevronsUpDown, X } from 'lucide-react'
import { UseFormReturn } from 'react-hook-form'
import { z } from 'zod'
import { useState } from 'react'
import { useMemo, useState } from 'react'
import {
Button,
cn,
@@ -90,18 +90,24 @@ export const BillingCustomerDataForm = ({
form.setValue('tax_id_value', '', { shouldDirty: true })
}
const { tax_id_name } = form.watch()
const { tax_id_name, country } = form.watch()
const selectedTaxId = TAX_IDS.find((option) => option.name === tax_id_name)
const availableTaxIds = useMemo(() => {
return TAX_IDS.filter((taxId) => !country || taxId.countryIso2 === country).sort((a, b) =>
a.country.localeCompare(b.country)
)
}, [country])
return (
<div className={cn('flex flex-col space-y-4', className)}>
<FormField
control={form.control}
name="billing_name"
render={({ field }: { field: any }) => (
<FormItemLayout hideMessage>
<FormItemLayout hideMessage label="Name">
<FormControl>
<Input {...field} placeholder="Name" disabled={disabled} />
<Input {...field} disabled={disabled} />
</FormControl>
<FormMessage />
</FormItemLayout>
@@ -112,9 +118,9 @@ export const BillingCustomerDataForm = ({
control={form.control}
name="line1"
render={({ field }: { field: any }) => (
<FormItemLayout hideMessage>
<FormItemLayout hideMessage label="Address line 1">
<FormControl>
<Input {...field} placeholder="Address line 1" disabled={disabled} />
<Input {...field} placeholder="123 Main Street" disabled={disabled} />
</FormControl>
<FormMessage />
</FormItemLayout>
@@ -125,9 +131,13 @@ export const BillingCustomerDataForm = ({
control={form.control}
name="line2"
render={({ field }: { field: any }) => (
<FormItemLayout hideMessage>
<FormItemLayout hideMessage label="Address line 2 (optional)">
<FormControl>
<Input {...field} placeholder="Address line 2 (Optional)" disabled={disabled} />
<Input
{...field}
placeholder="Apartment, suite, unit, building, floor, etc."
disabled={disabled}
/>
</FormControl>
<FormMessage />
</FormItemLayout>
@@ -139,7 +149,7 @@ export const BillingCustomerDataForm = ({
control={form.control}
name="country"
render={({ field }: { field: any }) => (
<FormItemLayout hideMessage>
<FormItemLayout hideMessage label="Country">
<Popover open={showCountriesPopover} onOpenChange={setShowCountriesPopover}>
<PopoverTrigger asChild>
<FormControl>
@@ -207,9 +217,9 @@ export const BillingCustomerDataForm = ({
control={form.control}
name="postal_code"
render={({ field }: { field: any }) => (
<FormItemLayout hideMessage>
<FormItemLayout hideMessage label="Postal code">
<FormControl>
<Input {...field} placeholder="Postal code" disabled={disabled} />
<Input {...field} placeholder="12345" disabled={disabled} />
</FormControl>
<FormMessage />
</FormItemLayout>
@@ -222,9 +232,9 @@ export const BillingCustomerDataForm = ({
control={form.control}
name="city"
render={({ field }: { field: any }) => (
<FormItemLayout hideMessage>
<FormItemLayout hideMessage label="City">
<FormControl>
<Input {...field} placeholder="City" disabled={disabled} />
<Input {...field} disabled={disabled} />
</FormControl>
<FormMessage />
</FormItemLayout>
@@ -234,9 +244,9 @@ export const BillingCustomerDataForm = ({
control={form.control}
name="state"
render={({ field }: { field: any }) => (
<FormItemLayout hideMessage>
<FormItemLayout hideMessage label="State / Province">
<FormControl>
<Input {...field} placeholder="State / Province" disabled={disabled} />
<Input {...field} disabled={disabled} />
</FormControl>
<FormMessage />
</FormItemLayout>
@@ -281,26 +291,24 @@ export const BillingCustomerDataForm = ({
<CommandList>
<CommandEmpty>No tax ID found.</CommandEmpty>
<CommandGroup>
{TAX_IDS.sort((a, b) => a.country.localeCompare(b.country)).map(
(option) => (
<CommandItem
key={option.name}
value={`${option.country} - ${option.name}`}
onSelect={() => {
onSelectTaxIdType(option.name)
setShowTaxIDsPopover(false)
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedTaxId?.name === option.name ? 'opacity-100' : 'opacity-0'
)}
/>
{option.country} - {option.name}
</CommandItem>
)
)}
{availableTaxIds.map((option) => (
<CommandItem
key={option.name}
value={`${option.country} - ${option.name}`}
onSelect={() => {
onSelectTaxIdType(option.name)
setShowTaxIDsPopover(false)
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedTaxId?.name === option.name ? 'opacity-100' : 'opacity-0'
)}
/>
{option.country} - {option.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
@@ -1,122 +0,0 @@
import { FocusTrap } from '@headlessui/react'
import { Pencil } from 'lucide-react'
import { useState } from 'react'
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogSectionSeparator,
DialogTitle,
DialogTrigger,
Form_Shadcn_ as Form,
} from 'ui'
import { BillingCustomerDataForm } from './BillingCustomerDataForm'
import { FormCustomerData, useBillingCustomerDataForm } from './useBillingCustomerDataForm'
interface BillingCustomerDataNewOrgDialogProps {
onCustomerDataChange: (data: FormCustomerData) => void
}
export const BillingCustomerDataNewOrgDialog = ({
onCustomerDataChange,
}: BillingCustomerDataNewOrgDialogProps) => {
const [open, setOpen] = useState(false)
const handleDialogClose = () => {
setOpen(false)
}
const { form, handleSubmit, handleReset, isDirty } = useBillingCustomerDataForm({
onCustomerDataChange,
})
const handleClose = () => {
handleReset()
handleDialogClose()
}
const getAddressSummary = () => {
const { line1, city, state, country, billing_name } = form.getValues()
if (!line1) return 'Optionally add a billing address'
const parts = [billing_name, line1, city, state, country].filter(Boolean)
return parts.join(', ')
}
const isSubmitDisabled = !isDirty
return (
<div className="flex items-center justify-between">
<label className="text-sm text-foreground-light" htmlFor="billing-address-btn">
{getAddressSummary()}
</label>
<Dialog
open={open}
onOpenChange={(value) => {
if (!value) handleDialogClose()
else setOpen(true)
}}
>
<DialogTrigger asChild>
<Button
id="billing-address-btn"
onClick={() => setOpen(true)}
type="text"
aria-label="Edit"
size={'tiny'}
className="w-8 h-8 p-0 text-foreground-light"
>
<Pencil size={14} strokeWidth={1.5} />
</Button>
</DialogTrigger>
<DialogContent size="large">
<DialogHeader>
<DialogTitle>Billing Address &amp; Tax Id</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
{/*
[Joshen] There's something odd going on with using Dialog and RHF FormField here
where the focus keeps going to the Dialog when tabbing across the input fields, hence the FocusTrap
What's weirder is that once you've cycled the focus through all the inputs at least once, the focus
then no longer goes to the Dialog thereafter
FWIW it's likely happening across the dashboard whereever we're using Dialog and FormField
*/}
<FocusTrap>
<Form {...form}>
<form
id="new-org-billing-data-form"
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
form.handleSubmit(handleSubmit)(e)
// Only close the dialog if the form is valid
if (!Object.keys(form.formState.errors).length) {
handleDialogClose()
}
}}
>
<BillingCustomerDataForm className="p-5" form={form} />
<DialogFooter className="justify-end">
<div className="flex items-center gap-2">
<Button type="default" onClick={handleClose}>
Cancel
</Button>
<Button type="primary" htmlType="submit" disabled={isSubmitDisabled}>
Continue
</Button>
</div>
</DialogFooter>
</form>
</Form>
</FocusTrap>
</DialogContent>
</Dialog>
</div>
)
}
@@ -1,8 +1,3 @@
// List of available Tax IDs as reflected in Stripe's web portal
// This was manually ported over so there may be a chance of mistakes
// Last updated as of 29th March 2022.
// The code may not necessarily match with the name (ref SE_VAT)
// https://stripe.com/docs/api/customer_tax_ids/create
export interface TaxId {
name: string
type: string
@@ -6,9 +6,10 @@ import { BillingCustomerDataFormValues, BillingCustomerDataSchema } from './Bill
import { TAX_IDS } from './TaxID.constants'
import { sanitizeTaxIdValue } from './TaxID.utils'
import { components } from 'api-types'
import type { CustomerAddress } from 'data/organizations/types'
export type FormCustomerData = {
address: components['schemas']['CustomerResponse']['address'] | undefined
address: CustomerAddress | undefined
billing_name: string
tax_id: components['schemas']['TaxIdResponse']['tax_id'] | null
}
@@ -7,7 +7,7 @@ import { useQueryClient } from '@tanstack/react-query'
import { AlertCircle, Info } from 'lucide-react'
import { useTheme } from 'next-themes'
import Link from 'next/link'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'
@@ -17,7 +17,6 @@ import { useOrganizationCreditTopUpMutation } from 'data/organizations/organizat
import { subscriptionKeys } from 'data/subscriptions/keys'
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
import { STRIPE_PUBLIC_KEY } from 'lib/constants'
import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store'
import {
Alert_Shadcn_,
AlertDescription_Shadcn_,
@@ -40,6 +39,7 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import PaymentMethodSelection from './Subscription/PaymentMethodSelection'
import { PaymentConfirmation } from 'components/interfaces/Billing/Payment/PaymentConfirmation'
import { getStripeElementsAppearanceOptions } from 'components/interfaces/Billing/Payment/Payment.utils'
import type { PaymentMethodElementRef } from './PaymentMethods/NewPaymentMethodElement'
const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
@@ -48,8 +48,8 @@ const FORM_ID = 'credit-top-up'
const FormSchema = z.object({
amount: z.coerce
.number()
.gte(100, 'Amount must be between $100 - $999.')
.lte(999, 'Amount must be between $100 - $999.')
.gte(100, 'Amount must be between $100 - $2000.')
.lte(2000, 'Amount must be between $100 - $2000.')
.int('Amount must be a whole number.'),
paymentMethod: z.string(),
})
@@ -59,6 +59,9 @@ type CreditTopUpForm = z.infer<typeof FormSchema>
export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
const { resolvedTheme } = useTheme()
const queryClient = useQueryClient()
const paymentMethodSelectionRef = useRef<{
createPaymentMethod: PaymentMethodElementRef['createPaymentMethod']
}>(null)
const canTopUpCredits = useCheckPermissions(
PermissionAction.BILLING_WRITE,
@@ -82,7 +85,6 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
const [topUpModalVisible, setTopUpModalVisible] = useState(false)
const [paymentConfirmationLoading, setPaymentConfirmationLoading] = useState(false)
const captchaLoaded = useIsHCaptchaLoaded()
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
const [captchaRef, setCaptchaRef] = useState<HCaptcha | null>(null)
@@ -96,7 +98,7 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
}
const initHcaptcha = async () => {
if (topUpModalVisible && captchaRef && captchaLoaded) {
if (topUpModalVisible && captchaRef) {
let token = captchaToken
try {
@@ -116,7 +118,7 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
useEffect(() => {
initHcaptcha()
}, [topUpModalVisible, captchaRef, captchaLoaded])
}, [topUpModalVisible, captchaRef])
const [paymentIntentSecret, setPaymentIntentSecret] = useState('')
const [paymentIntentConfirmation, setPaymentIntentConfirmation] = useState<PaymentIntentResult>()
@@ -126,12 +128,20 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
const token = await initHcaptcha()
const paymentMethodResult = await paymentMethodSelectionRef.current?.createPaymentMethod()
if (!paymentMethodResult) {
return
}
await topUpCredits(
{
slug,
amount,
payment_method_id: paymentMethod,
payment_method_id: paymentMethodResult.paymentMethod.id,
hcaptchaToken: token,
address: paymentMethodResult.address,
tax_id: paymentMethodResult.taxId ?? undefined,
billing_name: paymentMethodResult.customerName,
},
{
onSuccess: (data) => {
@@ -225,10 +235,21 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
/>
<DialogHeader>
<DialogTitle>Top Up Credits</DialogTitle>
<DialogDescription>
On successful payment, an invoice will be issued and you'll be granted credits.
Credits will be applied to outstanding and future invoices and are not refundable. The
topped up credits do not expire.
<DialogDescription className="space-y-2">
<p className="prose text-sm">
On successful payment, an invoice will be issued and you'll be granted credits.
Credits will be applied to outstanding and future invoices and are not refundable.
The topped up credits do not expire.
</p>
<p className="prose text-sm">
For larger discounted credit packages, please{' '}
<Link
href={`/support/new?slug=${slug}&subject=${encodeURIComponent('I would like to inquire about larger credit packages')}&category=${SupportCategories.SALES_ENQUIRY}`}
target="_blank"
>
reach out.
</Link>
</p>
</DialogDescription>
</DialogHeader>
@@ -252,7 +273,7 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
name="paymentMethod"
render={() => (
<PaymentMethodSelection
createPaymentMethodInline={false}
ref={paymentMethodSelectionRef}
onSelectPaymentMethod={(pm) => form.setValue('paymentMethod', pm)}
selectedPaymentMethod={form.getValues('paymentMethod')}
readOnly={executingTopUp || paymentConfirmationLoading}
@@ -260,16 +281,6 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
)}
/>
<p className="prose text-sm">
For larger discounted credit packages, please{' '}
<Link
href={`/support/new?slug=${slug}&subject=${encodeURIComponent('I would like to inquire about larger credit packages')}&category=${SupportCategories.SALES_ENQUIRY}`}
target="_blank"
>
reach out.
</Link>
</p>
{paymentIntentConfirmation && paymentIntentConfirmation.error && (
<Alert_Shadcn_ variant="destructive">
<AlertCircle className="h-4 w-4" />
@@ -4,26 +4,148 @@
* If Elements is on a higher level, we risk losing all form state in case a payment fails.
*/
import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js'
import { forwardRef, useImperativeHandle } from 'react'
import { AddressElement, PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js'
import {
StripeAddressElementChangeEvent,
StripeAddressElementOptions,
type SetupIntent,
} from '@stripe/stripe-js'
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
Button,
Checkbox_Shadcn_,
cn,
Command_Shadcn_ as Command,
CommandEmpty_Shadcn_ as CommandEmpty,
CommandGroup_Shadcn_ as CommandGroup,
CommandInput_Shadcn_ as CommandInput,
CommandItem_Shadcn_ as CommandItem,
CommandList_Shadcn_ as CommandList,
FormControl_Shadcn_ as FormControl,
FormField_Shadcn_ as FormField,
FormItem_Shadcn_,
FormMessage_Shadcn_ as FormMessage,
Input_Shadcn_ as Input,
Popover_Shadcn_ as Popover,
PopoverContent_Shadcn_ as PopoverContent,
PopoverTrigger_Shadcn_ as PopoverTrigger,
} from 'ui'
import { TAX_IDS, type TaxId } from '../BillingCustomerData/TaxID.constants'
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { Form } from '@ui/components/shadcn/ui/form'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { Check, ChevronsUpDown } from 'lucide-react'
import { zodResolver } from '@hookform/resolvers/zod'
import { getURL } from 'lib/helpers'
import type { CustomerAddress, CustomerTaxId } from 'data/organizations/types'
import type { PaymentMethod } from '@stripe/stripe-js'
export const BillingCustomerDataSchema = z.object({
tax_id_type: z.string(),
tax_id_value: z.string().min(2, {
message: 'Tax ID needs to be set.',
}),
tax_id_name: z.string(),
})
type BillingCustomerDataFormValues = z.infer<typeof BillingCustomerDataSchema>
export type PaymentMethodElementRef = {
confirmSetup: () => Promise<
| {
setupIntent: SetupIntent
address: CustomerAddress
customerName: string
taxId: CustomerTaxId | null
}
| undefined
>
createPaymentMethod: () => Promise<
| {
paymentMethod: PaymentMethod
address: CustomerAddress | null
customerName: string | null
taxId: CustomerTaxId | null
}
| undefined
>
}
const NewPaymentMethodElement = forwardRef(
(
{
email,
readOnly,
currentAddress,
currentTaxId,
customerName,
}: {
email?: string | null | undefined
readOnly: boolean
currentAddress?: CustomerAddress | null
currentTaxId?: CustomerTaxId | null
customerName?: string | undefined
},
ref
) => {
const stripe = useStripe()
const elements = useElements()
const createPaymentMethod = async () => {
const form = useForm<BillingCustomerDataFormValues>({
resolver: zodResolver(BillingCustomerDataSchema),
defaultValues: {
tax_id_name: currentTaxId
? TAX_IDS.find(
(option) =>
option.type === currentTaxId.type && option.countryIso2 === currentTaxId.country
)?.name || ''
: '',
tax_id_type: currentTaxId ? currentTaxId.type : '',
tax_id_value: currentTaxId ? currentTaxId.value : '',
},
})
// To avoid rendering the business checkbox prematurely and causing weird layout shifts, we wait until the address element is fully loaded
const [fullyLoaded, setFullyLoaded] = useState(false)
const [showTaxIDsPopover, setShowTaxIDsPopover] = useState(false)
const onSelectTaxIdType = (name: string) => {
const selectedTaxIdOption = TAX_IDS.find((option) => option.name === name)
if (!selectedTaxIdOption) return
form.setValue('tax_id_type', selectedTaxIdOption.type)
form.setValue('tax_id_value', '')
form.setValue('tax_id_name', name)
}
const { tax_id_name } = form.watch()
const selectedTaxId = TAX_IDS.find((option) => option.name === tax_id_name)
const [purchasingAsBusiness, setPurchasingAsBusiness] = useState(currentTaxId != null)
const [stripeAddress, setStripeAddress] = useState<
StripeAddressElementChangeEvent['value'] | undefined
>(undefined)
const availableTaxIds = useMemo(() => {
const country = stripeAddress?.address.country || null
return TAX_IDS.filter((taxId) => country == null || taxId.countryIso2 === country).sort(
(a, b) => a.country.localeCompare(b.country)
)
}, [stripeAddress])
const createPaymentMethod = async (): ReturnType<
PaymentMethodElementRef['createPaymentMethod']
> => {
if (!stripe || !elements) return
await form.trigger()
if (purchasingAsBusiness && availableTaxIds.length > 0 && !form.getValues('tax_id_value')) {
return
}
await elements.submit()
// To avoid double 3DS confirmation, we just create the payment method here, as there might be a confirmation step while doing the actual payment
@@ -34,17 +156,209 @@ const NewPaymentMethodElement = forwardRef(
toast.error(error?.message ?? ' Failed to process card details')
return
}
return paymentMethod
const addressElement = await elements.getElement('address')!.getValue()
return {
paymentMethod,
address: {
...addressElement.value.address,
line2: addressElement.value.address.line2 || undefined,
},
customerName: addressElement.value.name,
taxId: getConfiguredTaxId(),
}
}
function getConfiguredTaxId(): CustomerTaxId | null {
return purchasingAsBusiness && selectedTaxId
? {
country: selectedTaxId.countryIso2,
type: selectedTaxId.type,
value: form.getValues('tax_id_value'),
}
: null
}
const confirmSetup = async (): ReturnType<PaymentMethodElementRef['confirmSetup']> => {
if (!stripe || !elements) return
await elements.submit()
const { error, setupIntent } = await stripe.confirmSetup({
elements,
redirect: 'if_required',
confirmParams: { return_url: `${getURL()}/org/_/billing` },
})
if (error || setupIntent == null) {
toast.error(error?.message ?? ' Failed to process card details')
return
}
const addressElement = await elements.getElement('address')!.getValue()
return {
setupIntent,
address: {
...addressElement.value.address,
line2: addressElement.value.address.line2 || undefined,
},
customerName: addressElement.value.name,
taxId: getConfiguredTaxId(),
}
}
useImperativeHandle(ref, () => ({
createPaymentMethod,
confirmSetup,
}))
const addressOptions: StripeAddressElementOptions = useMemo(
() => ({
mode: 'billing',
autocomplete: {
apiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_KEY!,
mode: 'google_maps_api',
},
display: { name: purchasingAsBusiness ? 'organization' : 'full' },
defaultValues: {
address: currentAddress ?? undefined,
name: customerName,
},
}),
[purchasingAsBusiness]
)
// Preselect tax id if there is no more than 2 available tax ids (even if there are two options, first one in the list is likely to be it)
useEffect(() => {
if (availableTaxIds.length && stripeAddress?.address.country && !currentTaxId) {
const taxIdOption = availableTaxIds[0]
form.setValue('tax_id_type', taxIdOption.type)
form.setValue('tax_id_value', '')
form.setValue('tax_id_name', taxIdOption.name)
}
}, [availableTaxIds, stripeAddress])
return (
<PaymentElement
options={{ defaultValues: { billingDetails: { email: email ?? undefined } }, readOnly }}
/>
<div className="space-y-4">
<p className="text-sm text-foreground-light mb-2">
Please ensure CVC and postal codes match what is on file for your card.
</p>
<PaymentElement
options={{
layout: 'tabs',
defaultValues: { billingDetails: { email: email ?? undefined } },
readOnly,
}}
/>
{fullyLoaded && (
<div className="flex items-center space-x-2">
<Checkbox_Shadcn_
id="business"
checked={purchasingAsBusiness}
onCheckedChange={() => setPurchasingAsBusiness(!purchasingAsBusiness)}
/>
<label htmlFor="business" className="text-foreground-light text-sm leading-none">
I'm purchasing as a business
</label>
</div>
)}
<AddressElement
options={addressOptions}
// Force reload after changing purchasingAsBusiness setting, it seems like the element does not reload otherwise
key={`address-elements-${purchasingAsBusiness}`}
onChange={(evt) => setStripeAddress(evt.value)}
onReady={() => setFullyLoaded(true)}
/>
{purchasingAsBusiness && availableTaxIds.length > 0 && (
<Form {...form}>
<div className="grid grid-cols-2 gap-x-2 w-full">
<FormField
name="tax_id_name"
control={form.control}
render={({ field }) => (
<FormItemLayout hideMessage layout="vertical">
<Popover open={showTaxIDsPopover} onOpenChange={setShowTaxIDsPopover}>
<PopoverTrigger asChild>
<FormControl>
<Button
type="default"
role="combobox"
size="medium"
className={cn(
'w-full justify-between h-[34px]',
!selectedTaxId && 'text-muted'
)}
iconRight={
<ChevronsUpDown
className="ml-2 h-4 w-4 shrink-0 opacity-50"
strokeWidth={1.5}
/>
}
>
{selectedTaxId
? `${selectedTaxId.country} - ${selectedTaxId.name}`
: 'Select tax ID'}
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent sameWidthAsTrigger className="p-0" align="start">
<Command>
<CommandInput placeholder="Search tax ID..." />
<CommandList>
<CommandEmpty>No tax ID found.</CommandEmpty>
<CommandGroup>
{availableTaxIds.map((option) => (
<CommandItem
key={option.name}
value={`${option.country} - ${option.name}`}
onSelect={() => {
onSelectTaxIdType(option.name)
setShowTaxIDsPopover(false)
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedTaxId?.name === option.name
? 'opacity-100'
: 'opacity-0'
)}
/>
{option.country} - {option.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItemLayout>
)}
/>
{selectedTaxId && (
<FormField
name="tax_id_value"
control={form.control}
render={({ field }) => (
<FormItem_Shadcn_>
<FormControl>
<Input {...field} placeholder={selectedTaxId?.placeholder} />
</FormControl>
<FormMessage />
</FormItem_Shadcn_>
)}
/>
)}
</div>
</Form>
)}
</div>
)
}
)
@@ -176,7 +176,6 @@ const PaymentMethods = () => {
setShowAddPaymentMethodModal(false)
toast.success('Successfully added new payment method')
}}
showSetDefaultCheckbox={true}
/>
<ChangePaymentMethodModal
@@ -1,7 +1,7 @@
import { Check } from 'lucide-react'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { PricingInformation } from 'shared-data'
import { pickFeatures } from 'shared-data/plans'
import { Button, cn } from 'ui'
@@ -13,7 +13,7 @@ export interface EnterpriseCardProps {
}
export const EnterpriseCard = ({ plan, isCurrentPlan, billingPartner }: EnterpriseCardProps) => {
const selectedOrganization = useSelectedOrganization()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const orgSlug = selectedOrganization?.slug
const features = pickFeatures(plan, billingPartner)
@@ -1,5 +1,5 @@
import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { Button, Modal } from 'ui'
export interface MembersExceedLimitModalProps {
@@ -8,7 +8,7 @@ export interface MembersExceedLimitModalProps {
}
const MembersExceedLimitModal = ({ visible, onClose }: MembersExceedLimitModalProps) => {
const selectedOrganization = useSelectedOrganization()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const slug = selectedOrganization?.slug
const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery(
{ slug },
@@ -1,4 +1,3 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import {
forwardRef,
useCallback,
@@ -9,26 +8,30 @@ import {
useState,
} from 'react'
import { toast } from 'sonner'
import AddNewPaymentMethodModal from 'components/interfaces/Billing/Payment/AddNewPaymentMethodModal'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useOrganizationPaymentMethodsQuery } from 'data/organizations/organization-payment-methods-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import {
useSelectedOrganization,
useSelectedOrganizationQuery,
} from 'hooks/misc/useSelectedOrganization'
import { BASE_PATH, STRIPE_PUBLIC_KEY } from 'lib/constants'
import { getURL } from 'lib/helpers'
import { AlertCircle, CreditCard, Loader, Plus } from 'lucide-react'
import { Listbox } from 'ui'
import { Loader, Plus } from 'lucide-react'
import { Checkbox_Shadcn_, Listbox } from 'ui'
import HCaptcha from '@hcaptcha/react-hcaptcha'
import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store'
import { useOrganizationPaymentMethodSetupIntent } from 'data/organizations/organization-payment-method-setup-intent-mutation'
import { SetupIntentResponse } from 'data/stripe/setup-intent-mutation'
import { loadStripe, PaymentMethod, StripeElementsOptions } from '@stripe/stripe-js'
import { getStripeElementsAppearanceOptions } from 'components/interfaces/Billing/Payment/Payment.utils'
import { useTheme } from 'next-themes'
import { Elements } from '@stripe/react-stripe-js'
import { NewPaymentMethodElement } from '../PaymentMethods/NewPaymentMethodElement'
import {
NewPaymentMethodElement,
type PaymentMethodElementRef,
} from '../PaymentMethods/NewPaymentMethodElement'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
import { useFlag } from 'hooks/ui/useFlag'
import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query'
import { useOrganizationTaxIdQuery } from 'data/organizations/organization-tax-id-query'
import { useParams } from 'common'
const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
@@ -36,7 +39,6 @@ export interface PaymentMethodSelectionProps {
selectedPaymentMethod?: string
onSelectPaymentMethod: (id: string) => void
layout?: 'vertical' | 'horizontal'
createPaymentMethodInline: boolean
readOnly: boolean
}
@@ -45,27 +47,48 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
selectedPaymentMethod,
onSelectPaymentMethod,
layout = 'vertical',
createPaymentMethodInline = false,
readOnly,
}: PaymentMethodSelectionProps,
ref
) {
const selectedOrganization = useSelectedOrganization()
const slug = selectedOrganization?.slug
const [showAddNewPaymentMethodModal, setShowAddNewPaymentMethodModal] = useState(false)
const captchaLoaded = useIsHCaptchaLoaded()
const { slug } = useParams()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
const [captchaRef, setCaptchaRef] = useState<HCaptcha | null>(null)
const [setupIntent, setSetupIntent] = useState<SetupIntentResponse | undefined>(undefined)
const [useAsDefaultBillingAddress, setUseAsDefaultBillingAddress] = useState(true)
const { resolvedTheme } = useTheme()
const paymentRef = useRef<{ createPaymentMethod: () => Promise<PaymentMethod | undefined> }>(null)
const paymentRef = useRef<PaymentMethodElementRef | null>(null)
const [setupNewPaymentMethod, setSetupNewPaymentMethod] = useState<boolean | null>(null)
const { data: customerProfile, isLoading: isCustomerProfileLoading } =
useOrganizationCustomerProfileQuery({
slug,
})
const { data: taxId, isLoading: isCustomerTaxIdLoading } = useOrganizationTaxIdQuery({ slug })
const {
data: paymentMethods,
isLoading,
refetch: refetchPaymentMethods,
} = useOrganizationPaymentMethodsQuery({ slug })
const hidePaymentMethodsWithoutAddress = useFlag('hidePaymentMethodsWithoutAddress')
const { data: allPaymentMethods, isLoading } = useOrganizationPaymentMethodsQuery({ slug })
const paymentMethods = useMemo(() => {
if (!allPaymentMethods)
return {
data: [],
defaultPaymentMethodId: null,
}
const filtered = allPaymentMethods.data.filter(
(pm) => !hidePaymentMethodsWithoutAddress || pm.has_address
)
return {
data: filtered,
defaultPaymentMethodId: allPaymentMethods.data.some(
(pm) => pm.id === allPaymentMethods.defaultPaymentMethodId
)
? allPaymentMethods.defaultPaymentMethodId
: null,
}
}, [allPaymentMethods])
const captchaRefCallback = useCallback((node: any) => {
setCaptchaRef(node)
@@ -98,7 +121,7 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
}
const loadPaymentForm = async () => {
if (setupNewPaymentMethod && createPaymentMethodInline && captchaRef && captchaLoaded) {
if (setupNewPaymentMethod && captchaRef) {
let token = captchaToken
try {
@@ -116,18 +139,13 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
}
loadPaymentForm()
}, [createPaymentMethodInline, captchaRef, captchaLoaded, setupNewPaymentMethod])
}, [captchaRef, setupNewPaymentMethod])
const resetCaptcha = () => {
setCaptchaToken(null)
captchaRef?.resetCaptcha()
}
const canUpdatePaymentMethods = useCheckPermissions(
PermissionAction.BILLING_WRITE,
'stripe.payment_methods'
)
const stripeOptionsPaymentMethod: StripeElementsOptions = useMemo(
() =>
({
@@ -156,11 +174,27 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
}, [selectedPaymentMethod, paymentMethods, onSelectPaymentMethod])
// If createPaymentMethod already exists, use it. Otherwise, define it here.
const createPaymentMethod = async () => {
const createPaymentMethod = async (): ReturnType<
PaymentMethodElementRef['createPaymentMethod']
> => {
if (setupNewPaymentMethod || (paymentMethods?.data && paymentMethods.data.length === 0)) {
return paymentRef.current?.createPaymentMethod()
const paymentResult = await paymentRef.current?.createPaymentMethod()
if (!paymentResult) return paymentResult
return {
paymentMethod: paymentResult.paymentMethod,
customerName: useAsDefaultBillingAddress ? paymentResult.customerName : null,
address: useAsDefaultBillingAddress ? paymentResult.address : null,
taxId: useAsDefaultBillingAddress ? paymentResult.taxId : null,
}
} else {
return { id: selectedPaymentMethod }
return {
paymentMethod: { id: selectedPaymentMethod } as PaymentMethod,
customerName: useAsDefaultBillingAddress ? customerProfile?.billing_name || '' : null,
address: useAsDefaultBillingAddress ? customerProfile?.address ?? null : null,
taxId: useAsDefaultBillingAddress ? taxId ?? null : null,
}
}
}
@@ -197,42 +231,6 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
<Loader className="animate-spin" size={14} />
<p className="text-sm text-foreground-light">Retrieving payment methods</p>
</div>
) : paymentMethods?.data.length === 0 && !createPaymentMethodInline ? (
<div className="flex items-center justify-between px-4 py-2 border border-dashed rounded-md bg-alternative">
<div className="flex items-center space-x-4 text-foreground-light">
<AlertCircle size={16} strokeWidth={1.5} />
<p className="text-sm">No payment methods</p>
</div>
<ButtonTooltip
type="default"
disabled={!canUpdatePaymentMethods}
icon={<CreditCard />}
onClick={() => {
if (createPaymentMethodInline) {
setSetupNewPaymentMethod(true)
} else {
setShowAddNewPaymentMethodModal(true)
}
}}
htmlType="button"
tooltip={{
content: {
side: 'bottom',
text: !canUpdatePaymentMethods ? (
<div className="w-48 text-center">
<span>
You need additional permissions to add new payment methods to this
organization
</span>
</div>
) : undefined,
},
}}
>
Add new
</ButtonTooltip>
</div>
) : paymentMethods?.data && paymentMethods?.data.length > 0 && !setupNewPaymentMethod ? (
<Listbox
layout={layout}
@@ -267,11 +265,7 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
<div
className="flex items-center px-3 py-2 space-x-2 transition cursor-pointer group hover:bg-surface-300"
onClick={() => {
if (createPaymentMethodInline) {
setSetupNewPaymentMethod(true)
} else {
setShowAddNewPaymentMethodModal(true)
}
setSetupNewPaymentMethod(true)
}}
>
<Plus size={16} />
@@ -282,17 +276,39 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
</Listbox>
) : null}
{stripePromise && setupIntent && (
<Elements stripe={stripePromise} options={stripeOptionsPaymentMethod}>
<NewPaymentMethodElement
ref={paymentRef}
email={selectedOrganization?.billing_email ?? undefined}
readOnly={readOnly}
/>
</Elements>
{stripePromise && setupIntent && customerProfile && (
<>
<Elements stripe={stripePromise} options={stripeOptionsPaymentMethod}>
<NewPaymentMethodElement
ref={paymentRef}
email={selectedOrganization?.billing_email ?? undefined}
readOnly={readOnly}
customerName={customerProfile?.billing_name}
currentAddress={customerProfile?.address}
currentTaxId={taxId}
/>
</Elements>
{/* If the customer already has a billing address, optionally allow overwriting it - if they have no address, we use that as a default */}
{customerProfile?.address != null && (
<div className="flex items-center space-x-2 mt-4">
<Checkbox_Shadcn_
id="defaultBillingAddress"
checked={useAsDefaultBillingAddress}
onCheckedChange={() => setUseAsDefaultBillingAddress(!useAsDefaultBillingAddress)}
/>
<label
htmlFor="defaultBillingAddress"
className="text-sm leading-none text-foreground-light"
>
Use address as my org's billing address
</label>
</div>
)}
</>
)}
{setupIntentLoading && (
{(setupIntentLoading || isCustomerProfileLoading || isCustomerTaxIdLoading) && (
<div className="space-y-2">
<ShimmeringLoader className="h-10" />
<div className="grid grid-cols-2 gap-4">
@@ -303,26 +319,6 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
</div>
)}
</div>
<AddNewPaymentMethodModal
visible={showAddNewPaymentMethodModal}
returnUrl={`${getURL()}/org/${selectedOrganization?.slug}/billing?panel=subscriptionPlan&source=paymentMethod`}
onCancel={() => setShowAddNewPaymentMethodModal(false)}
autoMarkAsDefaultPaymentMethod={true}
onConfirm={async () => {
setShowAddNewPaymentMethodModal(false)
toast.success('Successfully added new payment method')
const { data: refetchedPaymentMethods } = await refetchPaymentMethods()
if (refetchedPaymentMethods?.data?.length) {
// Preselect the card that was just added
const mostRecentPaymentMethod = refetchedPaymentMethods?.data.reduce(
(prev, current) => (prev.created > current.created ? prev : current),
refetchedPaymentMethods.data[0]
)
onSelectPaymentMethod(mostRecentPaymentMethod.id)
}
}}
/>
</>
)
})
@@ -18,7 +18,10 @@ import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-que
import type { OrgPlan } from 'data/subscriptions/types'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import {
useSelectedOrganization,
useSelectedOrganizationQuery,
} from 'hooks/misc/useSelectedOrganization'
import { formatCurrency } from 'lib/helpers'
import { pickFeatures, pickFooter, plans as subscriptionsPlans } from 'shared-data/plans'
import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings'
@@ -26,14 +29,15 @@ import { Button, SidePanel, cn } from 'ui'
import DowngradeModal from './DowngradeModal'
import { EnterpriseCard } from './EnterpriseCard'
import { ExitSurveyModal } from './ExitSurveyModal'
import { useParams } from 'common'
import MembersExceedLimitModal from './MembersExceedLimitModal'
import { SubscriptionPlanUpdateDialog } from './SubscriptionPlanUpdateDialog'
import UpgradeSurveyModal from './UpgradeModal'
const PlanUpdateSidePanel = () => {
const router = useRouter()
const selectedOrganization = useSelectedOrganization()
const slug = selectedOrganization?.slug
const { slug } = useParams()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const { mutate: sendEvent } = useSendEventMutation()
const originalPlanRef = useRef<string>()
@@ -13,22 +13,23 @@ import { OrganizationBillingSubscriptionPreviewResponse } from 'data/organizatio
import { ProjectInfo } from 'data/projects/projects-query'
import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation'
import { SubscriptionTier } from 'data/subscriptions/types'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { PRICING_TIER_PRODUCT_IDS, PROJECT_STATUS, STRIPE_PUBLIC_KEY } from 'lib/constants'
import { formatCurrency } from 'lib/helpers'
import { Badge, Button, Dialog, DialogContent, Table, TableBody, TableCell, TableRow } from 'ui'
import { Button, Dialog, DialogContent, Table, TableBody, TableCell, TableRow } from 'ui'
import { Admonition } from 'ui-patterns'
import { InfoTooltip } from 'ui-patterns/info-tooltip'
import { BillingCustomerDataExistingOrgDialog } from '../BillingCustomerData/BillingCustomerDataExistingOrgDialog'
import PaymentMethodSelection from './PaymentMethodSelection'
import { useConfirmPendingSubscriptionChangeMutation } from 'data/subscriptions/org-subscription-confirm-pending-change'
import { PaymentConfirmation } from 'components/interfaces/Billing/Payment/PaymentConfirmation'
import { Elements } from '@stripe/react-stripe-js'
import { loadStripe, PaymentMethod, StripeElementsOptions } from '@stripe/stripe-js'
import { loadStripe, StripeElementsOptions } from '@stripe/stripe-js'
import { useTheme } from 'next-themes'
import { PaymentIntentResult } from '@stripe/stripe-js'
import { getStripeElementsAppearanceOptions } from 'components/interfaces/Billing/Payment/Payment.utils'
import { plans as subscriptionsPlans } from 'shared-data/plans'
import type { PaymentMethodElementRef } from '../PaymentMethods/NewPaymentMethodElement'
import { useParams } from 'common'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
@@ -76,12 +77,12 @@ export const SubscriptionPlanUpdateDialog = ({
projects,
}: Props) => {
const { resolvedTheme } = useTheme()
const selectedOrganization = useSelectedOrganization()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>()
const [paymentIntentSecret, setPaymentIntentSecret] = useState<string | null>(null)
const [paymentConfirmationLoading, setPaymentConfirmationLoading] = useState(false)
const paymentMethodSelection = useRef<{
createPaymentMethod: () => Promise<PaymentMethod | undefined>
const paymentMethodSelectionRef = useRef<{
createPaymentMethod: PaymentMethodElementRef['createPaymentMethod']
}>(null)
const billingViaPartner = subscription?.billing_via_partner === true
@@ -161,18 +162,14 @@ export const SubscriptionPlanUpdateDialog = ({
setPaymentConfirmationLoading(true)
const paymentMethod = await paymentMethodSelection.current?.createPaymentMethod()
if (paymentMethod) {
setSelectedPaymentMethod(paymentMethod.id)
const result = await paymentMethodSelectionRef.current?.createPaymentMethod()
if (result) {
setSelectedPaymentMethod(result.paymentMethod.id)
} else {
setPaymentConfirmationLoading(false)
}
if (
!paymentMethod &&
subscription?.payment_method_type !== 'invoice' &&
changeType === 'upgrade'
) {
if (!result && subscription?.payment_method_type !== 'invoice' && changeType === 'upgrade') {
return
}
@@ -185,7 +182,10 @@ export const SubscriptionPlanUpdateDialog = ({
updateOrgSubscription({
slug: selectedOrganization?.slug,
tier,
paymentMethod: paymentMethod?.id,
paymentMethod: result?.paymentMethod?.id,
address: result?.address,
tax_id: result?.taxId ?? undefined,
billing_name: result?.customerName ?? undefined,
})
}
@@ -246,13 +246,40 @@ export const SubscriptionPlanUpdateDialog = ({
{/* Left Column */}
<div className="p-8 pb-8 flex flex-col xl:col-span-3">
<div className="flex-1">
<h3 className="text-base mb-4">
{changeType === 'downgrade' ? 'Downgrade' : 'Upgrade'}{' '}
<span className="font-bold">{selectedOrganization?.name}</span> to{' '}
{changeType === 'downgrade'
? DOWNGRADE_PLAN_HEADINGS[(selectedTier as DowngradePlanHeadingKey) || 'default']
: PLAN_HEADINGS[(selectedTier as PlanHeadingKey) || 'default']}
</h3>
<div>
{!billingViaPartner && subscriptionPreview != null && changeType === 'upgrade' && (
<div className="space-y-2 mb-4">
<PaymentMethodSelection
ref={paymentMethodSelectionRef}
selectedPaymentMethod={selectedPaymentMethod}
onSelectPaymentMethod={(pm) => setSelectedPaymentMethod(pm)}
readOnly={paymentConfirmationLoading || isConfirming || isUpdating}
/>
</div>
)}
{billingViaPartner && (
<div className="mb-4">
<p className="text-sm">
This organization is billed through our partner{' '}
{billingPartnerLabel(billingPartner)}.{' '}
{billingPartner === 'aws' ? (
<>The organization's credit balance will be decreased accordingly.</>
) : (
<>You will be charged by them directly.</>
)}
</p>
{billingViaPartner &&
billingPartner === 'fly' &&
subscriptionPreview?.plan_change_type === 'downgrade' && (
<p className="text-sm">
Your organization will be downgraded at the end of your current billing
cycle.
</p>
)}
</div>
)}
</div>
{subscriptionPreviewIsLoading && (
<div className="space-y-2 mb-4 mt-2">
@@ -264,48 +291,6 @@ export const SubscriptionPlanUpdateDialog = ({
{subscriptionPreviewInitialized && (
<>
<div className="mt-2 mb-4 text-foreground-light text-sm">
<div className="flex items-center justify-between gap-2 border-b border-muted">
<div className="py-2 pl-0 flex items-center gap-1">
<span>{subscriptionPlanMeta?.name} Plan</span>
<Badge variant={'brand'} size={'small'} className="ml-1">
New
</Badge>
</div>
<div className="py-2 pr-0 text-right" translate="no">
{formatCurrency(newPlanCost)}
</div>
</div>
{subscription?.plan?.id !== 'free' && (
<div className="flex items-center justify-between gap-2 border-b border-muted">
<div className="py-2 pl-0 flex items-center gap-1">
<span>Unused Time on {subscription?.plan?.name} Plan</span>
<InfoTooltip className="max-w-sm">
Your previous plan was charged upfront, so a plan change will prorate
any unused time in credits. If the prorated credits exceed the new plan
charge, the excessive credits are added to your organization for future
use.
</InfoTooltip>
</div>
<div className="py-2 pr-0 text-right" translate="no">
-{formatCurrency(proratedCredit)}
</div>
</div>
)}
{/* Ignore rare case with negative balance (debt) */}
{customerBalance > 0 && (
<div className="flex items-center justify-between gap-2 border-b border-muted">
<div className="py-2 pl-0 flex items-center gap-1">
<span>Credits</span>
<InfoTooltip>
Credits will be used first before charging your card.
</InfoTooltip>
</div>
<div className="py-2 pr-0 text-right" translate="no">
{formatCurrency(customerBalance)}
</div>
</div>
)}
<div className="flex items-center justify-between gap-2 border-b border-muted text-foreground">
<div className="py-2 pl-0">Charge today</div>
<div className="py-2 pr-0 text-right" translate="no">
@@ -324,6 +309,39 @@ export const SubscriptionPlanUpdateDialog = ({
)}
</div>
</div>
{subscription?.plan?.id !== 'free' && (
<div className="flex items-center justify-between gap-2 border-b border-muted text-xs">
<div className="py-2 pl-0 flex items-center gap-1">
<span>Unused Time on {subscription?.plan?.name} Plan</span>
<InfoTooltip className="max-w-sm">
Your previous plan was charged upfront, so a plan change will prorate
any unused time in credits. If the prorated credits exceed the new plan
charge, the excessive credits are added to your organization for future
use.
</InfoTooltip>
</div>
<div className="py-2 pr-0 text-right" translate="no">
-{formatCurrency(proratedCredit)}
</div>
</div>
)}
{/* Ignore rare case with negative balance (debt) */}
{customerBalance > 0 && (
<div className="flex items-center justify-between gap-2 border-b border-muted text-xs">
<div className="py-2 pl-0 flex items-center gap-1">
<span>Credits</span>
<InfoTooltip>
Credits will be used first before charging your card.
</InfoTooltip>
</div>
<div className="py-2 pr-0 text-right" translate="no">
{formatCurrency(customerBalance)}
</div>
</div>
)}
<div className="flex items-center justify-between gap-2 text-foreground-lighter text-xs">
<div className="py-2 pl-0 flex items-center gap-1">
<span>Monthly invoice estimate</span>
@@ -542,47 +560,11 @@ export const SubscriptionPlanUpdateDialog = ({
</div>
<div className="pt-4">
{!billingViaPartner && subscriptionPreview != null && changeType === 'upgrade' && (
<div className="space-y-2 mb-4">
<BillingCustomerDataExistingOrgDialog />
<PaymentMethodSelection
ref={paymentMethodSelection}
selectedPaymentMethod={selectedPaymentMethod}
onSelectPaymentMethod={(pm) => setSelectedPaymentMethod(pm)}
createPaymentMethodInline={true}
readOnly={paymentConfirmationLoading || isConfirming || isUpdating}
/>
</div>
)}
{billingViaPartner && (
<div className="mb-4">
<p className="text-sm">
This organization is billed through our partner{' '}
{billingPartnerLabel(billingPartner)}.{' '}
{billingPartner === 'aws' ? (
<>The organization's credit balance will be decreased accordingly.</>
) : (
<>You will be charged by them directly.</>
)}
</p>
{billingViaPartner &&
billingPartner === 'fly' &&
subscriptionPreview?.plan_change_type === 'downgrade' && (
<p className="text-sm">
Your organization will be downgraded at the end of your current billing
cycle.
</p>
)}
</div>
)}
{projects.filter(
(it) =>
it.status === PROJECT_STATUS.ACTIVE_HEALTHY ||
it.status === PROJECT_STATUS.COMING_UP
).length === 0 &&
).length === 5 &&
subscriptionPreview?.plan_change_type !== 'downgrade' && (
<div className="pb-2">
<Admonition title="Empty organization" type="warning">
@@ -618,16 +600,13 @@ export const SubscriptionPlanUpdateDialog = ({
)}
<div className="flex space-x-2">
<Button type="default" size="medium" onClick={onClose} className="flex-1">
Cancel
</Button>
<Button
loading={isUpdating || paymentConfirmationLoading || isConfirming}
disabled={subscriptionPreviewIsLoading}
type="primary"
onClick={onUpdateSubscription}
className="flex-1"
size="medium"
size="small"
>
Confirm {changeType === 'downgrade' ? 'downgrade' : 'upgrade'}
</Button>
@@ -637,6 +616,13 @@ export const SubscriptionPlanUpdateDialog = ({
{/* Right Column */}
<div className="bg-surface-100 p-8 flex flex-col border-l xl:col-span-2">
<h3 className="text-base mb-8">
{changeType === 'downgrade' ? 'Downgrade' : 'Upgrade'}{' '}
<span className="font-bold">{selectedOrganization?.name}</span> to{' '}
{changeType === 'downgrade'
? DOWNGRADE_PLAN_HEADINGS[(selectedTier as DowngradePlanHeadingKey) || 'default']
: PLAN_HEADINGS[(selectedTier as PlanHeadingKey) || 'default']}
</h3>
{changeType === 'downgrade'
? featuresToLose.length > 0 && (
<div className="mb-4">
@@ -11,20 +11,20 @@ import PartnerManagedResource from 'components/ui/PartnerManagedResource'
import { getInvoice } from 'data/invoices/invoice-query'
import { useInvoicesCountQuery } from 'data/invoices/invoices-count-query'
import { useInvoicesQuery } from 'data/invoices/invoices-query'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { formatCurrency } from 'lib/helpers'
import { ChevronLeft, ChevronRight, Download, FileText } from 'lucide-react'
import { Button } from 'ui'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
import InvoicePayButton from './InvoicePayButton'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
const PAGE_LIMIT = 5
const InvoicesSettings = () => {
const [page, setPage] = useState(1)
const selectedOrganization = useSelectedOrganization()
const { slug } = selectedOrganization ?? {}
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const slug = selectedOrganization?.slug
const offset = (page - 1) * PAGE_LIMIT
const { data: count, isError: isErrorCount } = useInvoicesCountQuery(
@@ -33,7 +33,7 @@ const InvoicesSettings = () => {
},
{ enabled: selectedOrganization?.managed_by === 'supabase' }
)
const { data, error, isLoading, isError, isSuccess } = useInvoicesQuery(
const { data, error, isLoading, isError } = useInvoicesQuery(
{
slug,
offset,
@@ -32,8 +32,6 @@ import {
TooltipTrigger,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { BillingCustomerDataNewOrgDialog } from '../BillingSettings/BillingCustomerData/BillingCustomerDataNewOrgDialog'
import { FormCustomerData } from '../BillingSettings/BillingCustomerData/useBillingCustomerDataForm'
import { useConfirmPendingSubscriptionCreateMutation } from 'data/subscriptions/org-subscription-confirm-pending-create'
import { loadStripe } from '@stripe/stripe-js'
import { useTheme } from 'next-themes'
@@ -41,7 +39,12 @@ import { SetupIntentResponse } from 'data/stripe/setup-intent-mutation'
import { useProfile } from 'lib/profile'
import { PaymentConfirmation } from 'components/interfaces/Billing/Payment/PaymentConfirmation'
import { getStripeElementsAppearanceOptions } from 'components/interfaces/Billing/Payment/Payment.utils'
import { NewPaymentMethodElement } from '../BillingSettings/PaymentMethods/NewPaymentMethodElement'
import {
NewPaymentMethodElement,
type PaymentMethodElementRef,
} from '../BillingSettings/PaymentMethods/NewPaymentMethodElement'
import { components } from 'api-types'
import type { CustomerAddress, CustomerTaxId } from 'data/organizations/types'
const ORG_KIND_TYPES = {
PERSONAL: 'Personal',
@@ -90,6 +93,8 @@ type FormState = z.infer<typeof formSchema>
const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
const newMandatoryAddressInput = true
/**
* No org selected yet, create a new one
* [Joshen] Need to refactor to use Form_Shadcn here
@@ -115,8 +120,6 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr
const [isOrgCreationConfirmationModalVisible, setIsOrgCreationConfirmationModalVisible] =
useState(false)
const [customerData, setCustomerData] = useState<FormCustomerData | null>(null)
const stripeOptionsPaymentMethod: StripeElementsOptions = useMemo(
() =>
({
@@ -244,7 +247,14 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr
} as StripeElementsOptions
}, [paymentIntentSecret, resolvedTheme])
async function createOrg(paymentMethodId?: string) {
async function createOrg(
paymentMethodId?: string,
customerData?: {
address: CustomerAddress | null
billing_name: string | null
tax_id: CustomerTaxId | null
}
) {
const dbTier = formState.plan === 'PRO' && !formState.spend_cap ? 'PAYG' : formState.plan
createOrganization({
@@ -258,12 +268,12 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr
...(formState.kind == 'COMPANY' ? { size: formState.size } : {}),
payment_method: paymentMethodId,
billing_name: dbTier === 'FREE' ? undefined : customerData?.billing_name,
address: dbTier === 'FREE' ? undefined : customerData?.address,
address: dbTier === 'FREE' ? null : customerData?.address,
tax_id: dbTier === 'FREE' ? undefined : customerData?.tax_id ?? undefined,
})
}
const paymentRef = useRef<{ createPaymentMethod: () => Promise<any> }>(null)
const paymentRef = useRef<PaymentMethodElementRef | null>(null)
const handleSubmit = async () => {
setNewOrgLoading(true)
@@ -271,10 +281,16 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr
if (formState.plan === 'FREE') {
await createOrg()
} else if (!paymentMethod) {
const paymentMethod = await paymentRef.current?.createPaymentMethod()
if (paymentMethod) {
setPaymentMethod(paymentMethod)
createOrg(paymentMethod.id)
const result = await paymentRef.current?.createPaymentMethod()
if (result) {
setPaymentMethod(result.paymentMethod)
const customerData = {
address: result.address,
billing_name: result.customerName,
tax_id: result.taxId,
}
createOrg(result.paymentMethod.id, customerData)
} else {
setNewOrgLoading(false)
}
@@ -340,6 +356,7 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr
</div>
</div>
}
className="overflow-visible"
>
<Panel.Content>
<p className="text-sm">This is your organization within Supabase.</p>
@@ -532,21 +549,6 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr
</>
)}
{formState.plan !== 'FREE' && (
<Panel.Content className="border-b border-panel-border-interior-light dark:border-panel-border-interior-dark">
<div className="grid grid-cols-3">
<div className="col-span-1 flex space-x-2 text-sm items-center">
<Label_Shadcn_ htmlFor="spend-cap" className=" leading-normal">
Billing Address
</Label_Shadcn_>
</div>
<div className="col-span-2">
<BillingCustomerDataNewOrgDialog onCustomerDataChange={setCustomerData} />
</div>
</div>
</Panel.Content>
)}
{setupIntent && formState.plan !== 'FREE' && (
<Panel.Content>
<Elements stripe={stripePromise} options={stripeOptionsPaymentMethod}>
@@ -15,17 +15,19 @@ export const OrgNotFound = ({ slug }: { slug?: string }) => {
return (
<>
<Admonition type="danger">
The selected organization does not exist or you don't have permission to access it.{' '}
{slug ? (
<>
Contact the owner or administrator to create a new project in the <code>{slug}</code>{' '}
organization.
</>
) : (
<>Contact the owner or administrator to create a new project.</>
)}
</Admonition>
{slug !== '_' && (
<Admonition type="danger">
The selected organization does not exist or you don't have permission to access it.{' '}
{slug ? (
<>
Contact the owner or administrator to create a new project in the <code>{slug}</code>{' '}
organization.
</>
) : (
<>Contact the owner or administrator to create a new project.</>
)}
</Admonition>
)}
<h3 className="text-sm">Select an organization to create your new project from</h3>
@@ -88,7 +88,7 @@ export const MemberRow = ({ member }: MemberRowProps) => {
/>
<div className="flex item-center gap-x-2">
<p className="text-foreground-light truncate">{member.primary_email}</p>
{member.primary_email === profile?.primary_email && <Badge color="scale">You</Badge>}
{member.gotrue_id === profile?.gotrue_id && <Badge color="scale">You</Badge>}
</div>
{(member.metadata as any)?.origin && (
@@ -54,13 +54,13 @@ const MembersView = ({ searchString }: MembersViewProps) => {
const [[user], otherMembers] = partition(
filteredMembers,
(m) => m.primary_email === profile?.primary_email
(m) => m.gotrue_id === profile?.gotrue_id
)
const sortedMembers = otherMembers.sort((a, b) =>
(a.primary_email ?? '').localeCompare(b.primary_email ?? '')
)
const userMember = members.find((m) => m.primary_email === profile?.primary_email)
const userMember = members.find((m) => m.gotrue_id === profile?.gotrue_id)
const orgScopedRoleIds = (roles?.org_scoped_roles ?? []).map((r) => r.id)
const isOrgScopedRole = orgScopedRoleIds.includes(userMember?.role_ids?.[0] ?? -1)
@@ -9,6 +9,7 @@ import {
ScaffoldSectionContent,
ScaffoldTitle,
} from 'components/layouts/Scaffold'
import { DocsButton } from 'components/ui/DocsButton'
import { Input } from 'ui-patterns/DataInputs/Input'
import { InviteMemberButton } from './InviteMemberButton'
import MembersView from './MembersView'
@@ -32,6 +33,7 @@ export const TeamSettings = () => {
placeholder="Filter members"
/>
<ScaffoldActionsGroup className="w-full md:w-auto">
<DocsButton href="https://supabase.com/docs/guides/platform/access-control" />
<InviteMemberButton />
</ScaffoldActionsGroup>
</ScaffoldActionsContainer>
@@ -90,10 +90,11 @@ export const UpdateRolesConfirmationModal = ({
})
toast.success(`Successfully updated role for ${member.username}`)
onClose(true)
return
} catch (error: any) {
toast.error(`Failed to update role: ${error.message}`)
} finally {
setSaving(false)
return toast.error(`Failed to update role: ${error.message}`)
return
}
}
@@ -130,8 +131,10 @@ export const UpdateRolesConfirmationModal = ({
toast.success(`Successfully updated role for ${member.username}`)
onClose(true)
} catch (error: any) {
toast.error(`Failed to update role: ${error.message}`)
} finally {
setSaving(false)
return toast.error(`Failed to update role: ${error.message}`)
return
}
}
@@ -195,8 +195,8 @@ const Usage = () => {
title="Usage filtered by project"
description={
<div>
You are currently viewing usage for the "
{selectedProject?.name || selectedProjectRef}" project. Supabase uses{' '}
You are currently viewing usage for the
{selectedProject?.name || selectedProjectRef} project. Supabase uses{' '}
<Link
href="/docs/guides/platform/billing-on-supabase#organization-based-billing"
target="_blank"
@@ -224,7 +224,7 @@ const AttributeUsage = ({
</div>
) : chartData.length > 0 && notAllValuesZero ? (
<UsageBarChart
name={`${attribute.chartPrefix || ''}${attribute.name}`}
name={`${attribute.chartPrefix || ''} ${attribute.name}`}
unit={attribute.unit}
attributes={attribute.attributes}
data={chartData}
@@ -7,16 +7,19 @@
* This component acts as a bridge between the data-fetching logic and the
* presentational chart component.
*/
import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted'
import Link from 'next/link'
import { useRef, useState } from 'react'
import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils'
import LogChartHandler from 'components/ui/Charts/LogChartHandler'
import Panel from 'components/ui/Panel'
import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted'
import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useChartData } from 'hooks/useChartData'
import type { UpdateDateRange } from 'pages/project/[ref]/reports/database'
import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import Link from 'next/link'
import { Button, cn } from 'ui'
import Panel from 'components/ui/Panel'
import { useRef, useState } from 'react'
const ReportChart = ({
chart,
@@ -25,9 +28,7 @@ const ReportChart = ({
interval,
updateDateRange,
functionIds,
orgPlanId,
isLoading,
availableIn,
className,
}: {
chart: any
@@ -36,15 +37,17 @@ const ReportChart = ({
interval: string
updateDateRange: UpdateDateRange
functionIds?: string[]
orgPlanId?: string
isLoading?: boolean
availableIn?: string[]
className?: string
}) => {
const org = useSelectedOrganization()
const { plan: orgPlan } = useCurrentOrgPlan()
const orgPlanId = orgPlan?.id
const [isHoveringUpgrade, setIsHoveringUpgrade] = useState(false)
const isAvailable =
chart.availableIn === undefined || (orgPlanId && chart.availableIn.includes(orgPlanId))
const canFetch = orgPlanId !== undefined
const {
data,
@@ -114,7 +117,9 @@ const ReportChart = ({
<h2 className="">{chart.label}</h2>
<p className="text-sm text-foreground-light">
This chart is available from{' '}
<span className="capitalize">{!!availableIn?.length ? availableIn[0] : 'Pro'}</span>{' '}
<span className="capitalize">
{!!chart.availableIn?.length ? chart.availableIn[0] : 'Pro'}
</span>{' '}
plan and above
</p>
<Button
@@ -125,7 +130,9 @@ const ReportChart = ({
>
<Link href={`/org/${org?.slug}/billing?panel=subscriptionPlan&source=reports`}>
Upgrade to{' '}
<span className="capitalize">{!!availableIn?.length ? availableIn[0] : 'Pro'}</span>
<span className="capitalize">
{!!chart.availableIn?.length ? chart.availableIn[0] : 'Pro'}
</span>
</Link>
</Button>
</div>
@@ -416,7 +416,7 @@ const InfrastructureActivity = () => {
</div>
) : chartData.length ? (
<UsageBarChart
name={`${attribute.chartPrefix || ''}${attribute.name}`}
name={`${attribute.chartPrefix || ''} ${attribute.name}`}
unit={attribute.unit}
attributes={attribute.attributes}
data={chartData}
@@ -65,8 +65,9 @@ const InfrastructureInfo = () => {
const currentPgVersion = (current_app_version ?? '')
.split('supabase-postgres-')[1]
?.replace('-orioledb', '')
const isOnNonGenerallyAvailableReleaseChannel =
current_app_version_release_channel && current_app_version_release_channel !== 'ga'
const isVisibleReleaseChannel =
current_app_version_release_channel &&
!['ga', 'withdrawn'].includes(current_app_version_release_channel)
? current_app_version_release_channel
: undefined
const isOrioleDb = useIsOrioleDb()
@@ -144,16 +145,16 @@ const InfrastructureInfo = () => {
value={currentPgVersion || serviceVersions?.['supabase-postgres'] || ''}
label="Postgres version"
actions={[
isOnNonGenerallyAvailableReleaseChannel && (
isVisibleReleaseChannel && (
<Tooltip>
<TooltipTrigger>
<Badge variant="warning" className="mr-1 capitalize">
{isOnNonGenerallyAvailableReleaseChannel}
{isVisibleReleaseChannel}
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="w-44 text-center">
This project uses a {isOnNonGenerallyAvailableReleaseChannel}{' '}
database version release
This project uses a {isVisibleReleaseChannel} database version
release
</TooltipContent>
</Tooltip>
),
@@ -22,7 +22,6 @@ export interface LogSelectionProps {
onClose: () => void
queryType?: QueryType
projectRef: string
collectionName?: string
isLoading: boolean
error?: string | object
}
@@ -41,8 +40,6 @@ const LogSelection = ({ log, onClose, queryType, isLoading, error }: LogSelectio
if (!log) return <LogDetailEmptyState />
switch (queryType) {
// case 'warehouse':
// return <WarehouseSelectionRenderer log={log} />
case 'api':
const status = log?.metadata?.[0]?.response?.[0]?.status_code
const method = log?.metadata?.[0]?.request?.[0]?.method
@@ -1,52 +0,0 @@
import { cn } from 'ui'
import { LOGS_TAILWIND_CLASSES } from '../Logs.constants'
import { jsonSyntaxHighlight, SelectionDetailedRow } from '../LogsFormatters'
const DetailedJsonRow = ({
label,
value,
code,
}: {
label: string
value: Object
code?: boolean
}) => {
return (
<div className="grid grid-cols-12">
<span className="text-foreground-lighter text-sm col-span-3">{label}</span>
<span
className={cn(
'text-foreground text-sm col-span-9 overflow-x-auto',
code && 'text-xs font-mono'
)}
>
<pre
dangerouslySetInnerHTML={{
__html: jsonSyntaxHighlight(value),
}}
/>
</span>
</div>
)
}
export const WarehouseSelectionRenderer = ({ log }: any) => {
return (
<div className="overflow-hidden overflow-x-auto space-y-6">
{Object.entries(log).map(([key, value], index) => {
return (
<div
key={`${key}-${index}`}
className={`${LOGS_TAILWIND_CLASSES.log_selection_x_padding}`}
>
{value && typeof value === 'object' ? (
<DetailedJsonRow label={key} value={value} />
) : (
<SelectionDetailedRow label={key} value={value === null ? 'NULL ' : String(value)} />
)}
</div>
)
})}
</div>
)
}
@@ -49,8 +49,6 @@ interface Props {
onSave?: () => void
hasEditorValue?: boolean
className?: string
collectionName?: string // Used for warehouse queries
warehouseError?: string
EmptyState?: ReactNode
showHeader?: boolean
showHistogramToggle?: boolean
@@ -79,7 +77,6 @@ const LogTable = ({
onSave,
hasEditorValue,
className,
collectionName,
EmptyState,
showHeader = true,
showHistogramToggle = true,
@@ -154,9 +151,6 @@ const LogTable = ({
columns
} else {
switch (queryType) {
case 'warehouse':
columns = DEFAULT_COLUMNS
break
case 'api':
columns = DatabaseApiColumnRender
break
@@ -460,7 +454,6 @@ const LogTable = ({
log={selectedLog}
error={selectedLogError}
queryType={queryType}
collectionName={collectionName}
/>
</ResizablePanel>
)}
@@ -369,7 +369,6 @@ export enum LogsTableName {
POSTGREST = 'postgrest_logs',
SUPAVISOR = 'supavisor_logs',
PGBOUNCER = 'pgbouncer_logs',
WAREHOUSE = 'warehouse_logs',
PG_UPGRADE = 'pg_upgrade_logs',
PG_CRON = 'pg_cron_logs',
}
@@ -384,7 +383,6 @@ export const LOGS_TABLES = {
storage: LogsTableName.STORAGE,
postgrest: LogsTableName.POSTGREST,
supavisor: LogsTableName.SUPAVISOR,
warehouse: LogsTableName.WAREHOUSE,
pg_upgrade: LogsTableName.PG_UPGRADE,
pg_cron: LogsTableName.POSTGRES,
pgbouncer: LogsTableName.PGBOUNCER,
@@ -401,7 +399,6 @@ export const LOGS_SOURCE_DESCRIPTION = {
[LogsTableName.POSTGREST]: 'RESTful API web server logs',
[LogsTableName.SUPAVISOR]: 'Shared connection pooler logs for PostgreSQL',
[LogsTableName.PGBOUNCER]: 'Dedicated connection pooler for PostgreSQL',
[LogsTableName.WAREHOUSE]: 'Logs obtained from a data warehouse collection',
[LogsTableName.PG_UPGRADE]: 'Logs generated by the Postgres version upgrade process',
[LogsTableName.PG_CRON]: 'Postgres logs from pg_cron cron jobs',
}
@@ -90,7 +90,6 @@ export type QueryType =
| 'storage'
| 'supavisor'
| 'postgrest'
| 'warehouse'
| 'pg_upgrade'
| 'pg_cron'
| 'pgbouncer'
@@ -145,9 +144,3 @@ export interface DatetimeHelper {
disabled?: boolean
availableIn?: PlanId[]
}
export interface WarehouseCollection {
name: string
id: number
token: string
}
@@ -6,7 +6,6 @@ import { ReactNode, useState } from 'react'
import { IS_PLATFORM } from 'common'
import Table from 'components/to-be-cleaned/Table'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useFlag } from 'hooks/ui/useFlag'
import { logConstants } from 'shared-data'
import {
Badge,
@@ -28,23 +27,16 @@ import {
LogsTableName,
} from './Logs.constants'
import { DatePickerValue, LogsDatePicker } from './Logs.DatePickers'
import { LogsWarning, LogTemplate, WarehouseCollection } from './Logs.types'
import { WarehouseQueryTemplate } from './Warehouse.utils'
import { LogsWarning, LogTemplate } from './Logs.types'
export type SourceType = 'logs' | 'warehouse'
export interface LogsQueryPanelProps {
templates?: LogTemplate[]
warehouseTemplates?: WarehouseQueryTemplate[]
defaultFrom: string
defaultTo: string
warnings: LogsWarning[]
warehouseCollections: WarehouseCollection[]
dataSource: SourceType
onSelectTemplate: (template: LogTemplate) => void
onSelectWarehouseTemplate: (template: WarehouseQueryTemplate) => void
onSelectSource: (source: string) => void
onDateChange: (value: DatePickerValue) => void
onDataSourceChange: (sourceType: SourceType) => void
}
function DropdownMenuItemContent({ name, desc }: { name: ReactNode; desc?: string }) {
@@ -58,17 +50,12 @@ function DropdownMenuItemContent({ name, desc }: { name: ReactNode; desc?: strin
const LogsQueryPanel = ({
templates = [],
warehouseTemplates = [],
defaultFrom,
defaultTo,
warnings,
warehouseCollections,
dataSource,
onSelectTemplate,
onSelectWarehouseTemplate,
onSelectSource,
onDateChange,
onDataSourceChange,
}: LogsQueryPanelProps) => {
const [showReference, setShowReference] = useState(false)
@@ -78,15 +65,12 @@ const LogsQueryPanel = ({
projectEdgeFunctionAll: edgeFunctionsEnabled,
} = useIsFeatureEnabled(['project_auth:all', 'project_storage:all', 'project_edge_function:all'])
const warehouseEnabled = useFlag('warehouse')
const logsTableNames = Object.entries(LogsTableName)
.filter(([key]) => {
if (key === 'AUTH') return authEnabled
if (key === 'STORAGE') return storageEnabled
if (key === 'FN_EDGE') return edgeFunctionsEnabled
if (key === 'PG_CRON') return false
if (key === 'WAREHOUSE') return false
return true
})
.map(([, value]) => value)
@@ -117,90 +101,31 @@ const LogsQueryPanel = ({
<div className="flex w-full items-center justify-between px-4 md:px-5 py-2 overflow-x-scroll no-scrollbar">
<div className="flex w-full flex-row items-center justify-between gap-x-4">
<div className="flex items-center gap-2">
{warehouseEnabled && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" iconRight={<ChevronDown />}>
Data source{' '}
<span className="ml-2 font-mono opacity-50">
{dataSource === 'warehouse' ? 'collections' : 'logs'}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="start">
<DropdownMenuItem onClick={() => onDataSourceChange('logs')}>
<DropdownMenuItemContent name="Logs" desc="Logs for all Supabase products" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onDataSourceChange('warehouse')}>
<DropdownMenuItemContent
name={
<span>
Collections <Badge variant="warning">NEW</Badge>
</span>
}
desc="Query your collections"
/>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{dataSource === 'warehouse' && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" iconRight={<ChevronDown />}>
Templates
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="max-h-96 overflow-auto" side="bottom" align="start">
{warehouseTemplates.map((template) => (
<DropdownMenuItem
key={template.name}
onClick={() => onSelectWarehouseTemplate(template)}
>
<DropdownMenuItemContent name={template.name} desc={template.description} />
</DropdownMenuItem>
))}
{warehouseCollections.length === 0 && (
<DropdownMenuItem className="hover:bg-transparent cursor-default">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" iconRight={<ChevronDown />}>
Insert source
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="start"
className="max-h-[70vh] overflow-auto"
>
{logsTableNames
.sort((a, b) => a.localeCompare(b))
.map((source) => (
<DropdownMenuItem key={source} onClick={() => onSelectSource(source)}>
<DropdownMenuItemContent
name="No collections found"
desc="You can create collections in the left sidebar."
name={source}
desc={LOGS_SOURCE_DESCRIPTION[source]}
/>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
))}
</DropdownMenuContent>
</DropdownMenu>
{dataSource === 'logs' && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" iconRight={<ChevronDown />}>
Insert source
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="start"
className="max-h-[70vh] overflow-auto"
>
{dataSource === 'logs' &&
logsTableNames
.sort((a, b) => a.localeCompare(b))
.map((source) => (
<DropdownMenuItem key={source} onClick={() => onSelectSource(source)}>
<DropdownMenuItemContent
name={source}
desc={LOGS_SOURCE_DESCRIPTION[source]}
/>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{dataSource === 'logs' && IS_PLATFORM && (
{IS_PLATFORM && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" iconRight={<ChevronDown />}>
@@ -222,16 +147,14 @@ const LogsQueryPanel = ({
</DropdownMenu>
)}
{dataSource === 'logs' && (
<LogsDatePicker
value={selectedDatePickerValue}
onSubmit={(value) => {
setSelectedDatePickerValue(value)
onDateChange(value)
}}
helpers={EXPLORER_DATEPICKER_HELPERS}
/>
)}
<LogsDatePicker
value={selectedDatePickerValue}
onSubmit={(value) => {
setSelectedDatePickerValue(value)
onDateChange(value)
}}
helpers={EXPLORER_DATEPICKER_HELPERS}
/>
<div
data-testid="log-explorer-warnings"
@@ -261,93 +184,92 @@ const LogsQueryPanel = ({
</Tooltip>
</div>
</div>
{dataSource === 'logs' && (
<SidePanel
size="large"
header={
<div className="flex flex-row justify-between items-center">
<h3>Field Reference</h3>
<Button
type="text"
className="px-1"
onClick={() => setShowReference(false)}
icon={<X />}
/>
</div>
}
visible={showReference}
cancelText="Close"
onCancel={() => setShowReference(false)}
hideFooter
triggerElement={
<SidePanel
size="large"
header={
<div className="flex flex-row justify-between items-center">
<h3>Field Reference</h3>
<Button
asChild // ?: we don't want a button inside a button
type="text"
onClick={() => setShowReference(true)}
icon={<BookOpen />}
className="px-2"
>
<span>Field Reference</span>
</Button>
}
>
<SidePanel.Content>
<div className="pt-4 pb-2 space-y-1">
<p className="text-sm">
The following table shows all the available paths that can be queried from each
respective source. Do note that to access nested keys, you would need to perform
the necessary{' '}
<Link
href="https://supabase.com/docs/guides/platform/logs#unnesting-arrays"
target="_blank"
rel="noreferrer"
className="text-brand"
>
unnesting joins
<ExternalLink
size="14"
className="ml-1 inline -translate-y-[2px]"
strokeWidth={1.5}
/>
</Link>
</p>
</div>
</SidePanel.Content>
<SidePanel.Separator />
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="edge_logs"
listClassNames="px-2"
className="px-1"
onClick={() => setShowReference(false)}
icon={<X />}
/>
</div>
}
visible={showReference}
cancelText="Close"
onCancel={() => setShowReference(false)}
hideFooter
triggerElement={
<Button
asChild // ?: we don't want a button inside a button
type="text"
onClick={() => setShowReference(true)}
icon={<BookOpen />}
className="px-2"
>
{logConstants.schemas.map((schema) => (
<Tabs.Panel
key={schema.reference}
id={schema.reference}
label={schema.name}
className="px-4 pb-4"
<span>Field Reference</span>
</Button>
}
>
<SidePanel.Content>
<div className="pt-4 pb-2 space-y-1">
<p className="text-sm">
The following table shows all the available paths that can be queried from each
respective source. Do note that to access nested keys, you would need to perform
the necessary{' '}
<Link
href="https://supabase.com/docs/guides/platform/logs#unnesting-arrays"
target="_blank"
rel="noreferrer"
className="text-brand"
>
<Table
head={[
<Table.th className="text-xs !p-2" key="path">
Path
</Table.th>,
<Table.th key="type" className="text-xs !p-2">
Type
</Table.th>,
]}
body={schema.fields
.sort((a: any, b: any) => a.path - b.path)
.map((field) => (
<Field key={field.path} field={field} />
))}
unnesting joins
<ExternalLink
size="14"
className="ml-1 inline -translate-y-[2px]"
strokeWidth={1.5}
/>
</Tabs.Panel>
))}
</Tabs>
</SidePanel>
)}
</Link>
</p>
</div>
</SidePanel.Content>
<SidePanel.Separator />
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="edge_logs"
listClassNames="px-2"
>
{logConstants.schemas.map((schema) => (
<Tabs.Panel
key={schema.reference}
id={schema.reference}
label={schema.name}
className="px-4 pb-4"
>
<Table
head={[
<Table.th className="text-xs !p-2" key="path">
Path
</Table.th>,
<Table.th key="type" className="text-xs !p-2">
Type
</Table.th>,
]}
body={schema.fields
.sort((a: any, b: any) => a.path - b.path)
.map((field) => (
<Field key={field.path} field={field} />
))}
/>
</Tabs.Panel>
))}
</Tabs>
</SidePanel>
</div>
</div>
</div>
@@ -1,26 +0,0 @@
export type WarehouseQueryTemplate = {
query: string
name: string
description: string
}
export function createWarehouseQueryTemplates(
collections: { name: string }[]
): WarehouseQueryTemplate[] {
return collections.flatMap((collection) => [
{
query: `select count(*) as event_count from \`${collection.name}\`
where timestamp > timestamp_sub(current_timestamp(), interval 1 day)`,
name: `${collection.name} - Daily Event Count`,
description: `Count events in the last 24 hours from ${collection.name} collection`,
},
{
query: `select id, timestamp, event_message from \`${collection.name}\`
where timestamp > timestamp_sub(current_timestamp(), interval 1 day)
and event_message like '%YOUR_TEXT_HERE%'
order by timestamp desc limit 10`,
name: `${collection.name} - Filter by text`,
description: `Select last 10 events from ${collection.name} collection`,
},
])
}
@@ -223,8 +223,6 @@ const ProjectLinks = () => {
const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled()
const { securityLints, errorLints } = useLints()
const showWarehouse = useFlag('warehouse')
const activeRoute = router.pathname.split('/')[3]
const {
@@ -310,14 +308,11 @@ const ProjectLinks = () => {
</div>
)
} else if (route.key === 'logs') {
// TODO: Undo this when warehouse flag is removed
const label = showWarehouse ? 'Logs & Analytics' : route.label
const newRoute = { ...route, label }
return (
<SideBarNavLink
key={`other-routes-${i}`}
route={newRoute}
active={activeRoute === newRoute.key}
route={route}
active={activeRoute === route.key}
/>
)
} else {
@@ -60,10 +60,13 @@ const ConfirmResetCodeForm = ({ email }: { email: string }) => {
if (user?.factors?.length) {
await router.push({
pathname: '/forgot-password-mfa',
query: { returnTo: '/reset-password' },
query: router.query,
})
} else {
await router.push('reset-password')
await router.push({
pathname: '/reset-password',
query: router.query,
})
}
}
}
@@ -7,7 +7,7 @@ import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'
import { auth } from 'lib/gotrue'
import { auth, getReturnToPath } from 'lib/gotrue'
import {
Button,
Form_Shadcn_,
@@ -67,7 +67,7 @@ const ResetPasswordForm = () => {
// logout all other sessions after changing password
await auth.signOut({ scope: 'others' })
await router.push('/organizations')
await router.push(getReturnToPath('/organizations'))
} else {
toast.error(`Failed to save password: ${error.message}`, { id: toastId })
if (!WHITELIST_ERRORS.some((e) => error.message.includes(e))) {
@@ -4,7 +4,7 @@ import type { AuthError } from '@supabase/supabase-js'
import { useQueryClient } from '@tanstack/react-query'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useRef, useState } from 'react'
import { useRef, useState, useEffect } from 'react'
import { toast } from 'sonner'
import { object, string } from 'yup'
@@ -28,10 +28,22 @@ const SignInForm = () => {
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
const captchaRef = useRef<HCaptcha>(null)
const [returnTo, setReturnTo] = useState<string | null>(null)
useEffect(() => {
// Only call getReturnToPath after component mounts client-side
setReturnTo(getReturnToPath())
}, [])
const { mutate: sendEvent } = useSendEventMutation()
const { mutate: addLoginEvent } = useAddLoginEvent()
let forgotPasswordUrl = `/forgot-password`
if (returnTo && !returnTo.includes('/forgot-password')) {
forgotPasswordUrl = `${forgotPasswordUrl}?returnTo=${encodeURIComponent(returnTo)}`
}
const onSignIn = async ({ email, password }: { email: string; password: string }) => {
const toastId = toast.loading('Signing in...')
@@ -68,9 +80,12 @@ const SignInForm = () => {
addLoginEvent({})
await queryClient.resetQueries()
const returnTo = getReturnToPath()
// since we're already on the /sign-in page, prevent redirect loops
router.push(returnTo === '/sign-in' ? '/organizations' : returnTo)
let redirectPath = '/organizations'
if (returnTo && returnTo !== '/sign-in') {
redirectPath = returnTo
}
router.push(redirectPath)
} catch (error: any) {
toast.error(`Failed to sign in: ${(error as AuthError).message}`, { id: toastId })
Sentry.captureMessage('[CRITICAL] Failed to sign in via EP: ' + error.message)
@@ -124,7 +139,7 @@ const SignInForm = () => {
{/* positioned using absolute instead of labelOptional prop so tabbing between inputs works smoothly */}
<Link
href="/forgot-password"
href={forgotPasswordUrl}
className="absolute top-0 right-0 text-sm text-foreground-lighter"
>
Forgot Password?
@@ -18,7 +18,11 @@ const signInSchema = object({
code: string().required('MFA Code is required'),
})
const SignInMfaForm = () => {
interface SignInMfaFormProps {
context?: 'forgot-password' | 'sign-in'
}
const SignInMfaForm = ({ context = 'sign-in' }: SignInMfaFormProps) => {
const router = useRouter()
const signOut = useSignOut()
const queryClient = useQueryClient()
@@ -38,7 +42,15 @@ const SignInMfaForm = () => {
} = useMfaChallengeAndVerifyMutation({
onSuccess: async () => {
await queryClient.resetQueries()
router.push(getReturnToPath())
if (context === 'forgot-password') {
router.push({
pathname: '/reset-password',
query: router.query,
})
} else {
router.push(getReturnToPath())
}
},
})
@@ -1,8 +1,10 @@
import { ExternalLink, Eye, EyeOff, Loader } from 'lucide-react'
import { useState } from 'react'
import { useParams } from 'common'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useVaultSecretDecryptedValueQuery } from 'data/vault/vault-secret-decrypted-value-query'
import { Eye, EyeOff, Loader } from 'lucide-react'
import { useState } from 'react'
import { Button, Input } from 'ui'
import { Button, Input, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
export const DecryptedReadOnlyInput = ({
value,
@@ -15,10 +17,11 @@ export const DecryptedReadOnlyInput = ({
descriptionText: string
label: string
}) => {
const [showHidden, setShowHidden] = useState(false)
const { ref } = useParams()
const { project } = useProjectContext()
const [showHidden, setShowHidden] = useState(false)
const { isLoading: isDecryptedValueLoading, data: decryptedValue } =
const { data: decryptedValue, isLoading: isDecryptedValueLoading } =
useVaultSecretDecryptedValueQuery(
{
projectRef: project?.ref,
@@ -29,22 +32,40 @@ export const DecryptedReadOnlyInput = ({
)
const isLoading = isDecryptedValueLoading && showHidden
const renderedValue = secureEntry
? isLoading
? 'Fetching value from Vault...'
: showHidden
? decryptedValue
: value
: value
return (
<Input
label={label}
readOnly
copy
disabled
value={
secureEntry
? isLoading
? 'Fetching value from Vault...'
: showHidden
? decryptedValue
: value
: value
label={
<div className="flex items-center gap-x-2">
<span>{label}</span>
<Tooltip>
<TooltipTrigger asChild>
<a
target="_blank"
rel="noreferrer noopener"
href={`/project/${ref}/integrations/vault/secrets?search=${value}`}
>
<ExternalLink
size={14}
className="text-foreground-lighter hover:text-foreground-light transition"
/>
</a>
</TooltipTrigger>
<TooltipContent side="bottom">View parameter in Vault</TooltipContent>
</Tooltip>
</div>
}
value={renderedValue}
type={secureEntry ? (isLoading ? 'text' : showHidden ? 'text' : 'password') : 'text'}
descriptionText={descriptionText}
layout="horizontal"
@@ -1,6 +1,7 @@
import { RefreshCw, SquareArrowOutUpRight } from 'lucide-react'
import { useMemo, useState } from 'react'
import type { WrapperMeta } from 'components/interfaces/Integrations/Wrappers/Wrappers.types'
import { FormattedWrapperTable } from 'components/interfaces/Integrations/Wrappers/Wrappers.utils'
import { ImportForeignSchemaDialog } from 'components/interfaces/Storage/ImportForeignSchemaDialog'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
@@ -9,34 +10,32 @@ import { useFDWImportForeignSchemaMutation } from 'data/fdw/fdw-import-foreign-s
import { FDW } from 'data/fdw/fdws-query'
import { useIcebergNamespaceTablesQuery } from 'data/storage/iceberg-namespace-tables-query'
import { BASE_PATH } from 'lib/constants'
import { Badge, Button, TableCell, TableRow } from 'ui'
import { Button, cn, TableCell, TableRow } from 'ui'
type NamespaceRowProps = {
bucketName: string
namespace: string
schema: string
excludedSchemas: string[]
tables: (FormattedWrapperTable & { id: number })[]
token: string
wrapperInstance: FDW
wrapperValues: Record<string, string>
wrapperMeta: WrapperMeta
}
export const NamespaceRow = ({
bucketName,
namespace,
schema,
excludedSchemas,
tables,
token,
wrapperInstance,
wrapperValues,
wrapperMeta,
}: NamespaceRowProps) => {
const { project } = useProjectContext()
const [importForeignSchemaShown, setImportForeignSchemaShown] = useState(false)
const targetSchema = tables[0]?.schema_name ?? ''
const { data: tablesData, isLoading: isLoadingNamespaceTables } = useIcebergNamespaceTablesQuery(
{
catalogUri: wrapperValues.catalog_uri,
@@ -56,7 +55,7 @@ export const NamespaceRow = ({
connectionString: project?.connectionString,
serverName: wrapperInstance.server_name,
sourceSchema: namespace,
targetSchema: targetSchema,
targetSchema: schema,
})
}
@@ -76,11 +75,11 @@ export const NamespaceRow = ({
return (
<TableRow key={namespace}>
<TableCell>
<Badge variant="brand">{namespace}</Badge>
<TableCell>{namespace}</TableCell>
<TableCell className={cn(!schema ? 'text-foreground-lighter' : '')}>
{schema ?? 'No schema'}
</TableCell>
<TableCell className="text-foreground-light">{schema && <Badge>{schema}</Badge>}</TableCell>
<TableCell className="text-foreground-light text-center">
<TableCell>
{tablesData ? `${tables.length}/${tablesData.length} connected tables` : ``}
</TableCell>
<TableCell className="text-right">
@@ -96,15 +95,15 @@ export const NamespaceRow = ({
Sync
</ButtonTooltip>
{schema ? (
<a
href={`${BASE_PATH}/project/${project?.ref}/editor?schema=${schema}`}
target="_blank"
rel="noreferrer"
>
<Button type="default" icon={<SquareArrowOutUpRight />}>
Table Editor
</Button>
</a>
<Button asChild type="default" icon={<SquareArrowOutUpRight />}>
<a
target="_blank"
rel="noreferrer"
href={`${BASE_PATH}/project/${project?.ref}/editor?schema=${schema}`}
>
Open in Table Editor
</a>
</Button>
) : (
<ButtonTooltip
type="default"
@@ -114,7 +113,7 @@ export const NamespaceRow = ({
content: { text: 'There are no tables connected.' },
}}
>
Table Editor
Open in Table Editor
</ButtonTooltip>
)}
</div>
@@ -122,8 +121,7 @@ export const NamespaceRow = ({
<ImportForeignSchemaDialog
bucketName={bucketName}
namespace={namespace}
excludedSchemas={excludedSchemas}
wrapperValues={wrapperValues}
wrapperMeta={wrapperMeta}
visible={importForeignSchemaShown}
onClose={() => setImportForeignSchemaShown(false)}
/>
@@ -127,7 +127,6 @@ export const AnalyticBucketDetails = ({ bucket }: { bucket: Bucket }) => {
}
})
}, [wrapperTables, namespacesData])
const excludedSchemas = uniq(namespaces.map((n) => n.schema))
const wrappersExtension = extensionsData?.find((ext) => ext.name === 'wrappers')
@@ -196,10 +195,10 @@ export const AnalyticBucketDetails = ({ bucket }: { bucket: Bucket }) => {
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-center">Namespace</TableHead>
<TableHead className="text-center">Schema</TableHead>
<TableHead className="text-center">Tables</TableHead>
<TableHead className="text-center">Actions</TableHead>
<TableHead>Namespace</TableHead>
<TableHead>Schema</TableHead>
<TableHead>Tables</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
@@ -209,11 +208,11 @@ export const AnalyticBucketDetails = ({ bucket }: { bucket: Bucket }) => {
bucketName={bucket.name}
namespace={namespace}
schema={schema}
excludedSchemas={excludedSchemas}
tables={tables as any}
token={token!}
wrapperInstance={wrapperInstance}
wrapperValues={wrapperValues}
wrapperMeta={wrapperMeta}
/>
))}
</TableBody>
Loaded 100 of 235 files, more files were not shown because too many files have changed in this diff. Show more