mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 04:15:04 +03:00
Merge branch 'twitter-logo-update-to-x' of github.com:AbhijeetBiswalCodes/supabase into twitter-logo-update-to-x
This commit is contained in:
commit
d5f3b3936e
177 files changed
+3769
-2185
No files matched your search
@@ -58,7 +58,7 @@ export const meta = {
|
||||
<StepHikeCompact.Code>
|
||||
|
||||
```bash Terminal
|
||||
cd my-app && npm install @supabase/supabase-js @supabase/auth-ui-react
|
||||
cd my-app && npm install @supabase/supabase-js @supabase/auth-ui-react @supabase/auth-ui-shared
|
||||
```
|
||||
|
||||
</StepHikeCompact.Code>
|
||||
|
||||
@@ -289,6 +289,34 @@ async function handleSignInWithGoogle(response) {
|
||||
|
||||
Use of nonce is recommended, though optional. Make sure each nonce is generated randomly and available both in the `data-nonce` attribute as well as in the `handleSignInWithGoogle` callback function; otherwise the ID token will not be accepted.
|
||||
|
||||
### Important Note on Nonce Validation
|
||||
|
||||
Supabase Auth expects the nonce in Google's ID token to be hashed (specifically with SHA-256 and represented as a hexadecimal string).
|
||||
|
||||
To succeed with nonce validation, you will need to provide a hashed version of the nonce to Google and the non-hashed version to the `signInWithIdToken` method.
|
||||
|
||||
The following code snippet provides an example of how to create both versions in a web application:
|
||||
|
||||
```js
|
||||
// Adapted from https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#converting_a_digest_to_a_hex_string
|
||||
|
||||
import crypto from 'crypto'
|
||||
|
||||
// ...
|
||||
|
||||
const nonce = crypto.randomBytes(16).toString('base64') // Generate a random nonce
|
||||
const encoder = new TextEncoder()
|
||||
const encodedNonce = encoder.encode(nonce) // Encode the nonce
|
||||
const hash = await crypto.subtle.digest('SHA-256', encodedNonce) // Hash it with SHA-256
|
||||
const bytes = new Uint8Array(hash)
|
||||
const hashedNonce = Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0')) // Convert the hash to a hexadecimal string
|
||||
.join('')
|
||||
|
||||
// Use 'hashedNonce' when making the authentication request to Google
|
||||
// Use 'nonce' when invoking the supabase.auth.signInWithIdToken() method
|
||||
```
|
||||
|
||||
### Configuration [#configuration-personalized-sign-in-button]
|
||||
|
||||
1. Obtain OAuth credentials for your Google Cloud project in the [Credentials](https://console.developers.google.com/apis/credentials) page of the console. When creating a new credential, choose _Web application_. As you're using the Google sign in button, you should configure the _Authorized JavaScript origins_ and _Authorized redirect URIs_ to the website where the buttons appear. You should not use your Supabase project domain name. For this use case, the client secret provided is not needed and can be ignored.
|
||||
|
||||
@@ -161,7 +161,7 @@ The CLI provides a number of commands to help you develop your project locally a
|
||||
- [Project](/docs/reference/cli/supabase-projects) and [Organization](/docs/reference/cli/supabase-orgs) management
|
||||
- [Database management](/docs/reference/cli/supabase-db)
|
||||
- [Database migrations](/docs/reference/cli/supabase-migration) and [Database Branching](/docs/reference/cli/supabase-branches)
|
||||
- [Database debugigng tools](/docs/reference/cli/supabase-inspect-db-calls)
|
||||
- [Database debugging tools](/docs/reference/cli/supabase-inspect-db-calls)
|
||||
- [Edge Function management](/docs/reference/cli/supabase-functions)
|
||||
- [Auth management](/docs/reference/cli/supabase-functions)
|
||||
- [Types Generators](/docs/reference/cli/supabase-gen)
|
||||
|
||||
@@ -63,8 +63,9 @@ Files=1, Tests=1, 1 wallclock secs ( 0.01 usr 0.00 sys + 0.04 cusr 0.02 csys
|
||||
Result: PASS
|
||||
```
|
||||
|
||||
## More resource
|
||||
## More resources
|
||||
|
||||
- [Testing RLS policies](/docs/guides/auth/row-level-security#testing-policies)
|
||||
- [pgTAP extension](/docs/guides/database/extensions/pgtap)
|
||||
- Official [pgTAP documentation](https://pgtap.org/)
|
||||
|
||||
|
||||
@@ -333,7 +333,7 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
### Sign out
|
||||
|
||||
Let's create an route handler to handle the signout from the server side.
|
||||
Let's create a route handler to handle the signout from the server side.
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
|
||||
@@ -26,7 +26,7 @@ Resources:
|
||||
|
||||
Once your application is live, you should never change your database using the Dashboard. Supabase provides various [access levels](https://supabase.com/docs/guides/platform/access-control) for the Dashboard that can help enforce this. Some other important things to consider at this point include:
|
||||
|
||||
- Read the [Production Checklist](docs/guides/platform/going-into-prod).
|
||||
- Read the [Production Checklist](/docs/guides/platform/going-into-prod).
|
||||
- Make your team aware of the [Shared Responsibilities](/docs/guides/platform/shared-responsibility-model) between your organization and Supabase.
|
||||
- Design a [safe workflow](https://supabase.com/docs/guides/platform/shared-responsibility-model#you-decide-your-own-workflow) for managing your database. We strongly recommend running [multiple environments](/docs/guides/cli/managing-environments) as part of your development workflow (`local` -> `staging` -> `prod`).
|
||||
- As your database to grows, we strongly recommend moving to [Point-in-Time Recovery](/docs/guides/platform/backups#point-in-time-recovery). This is safer and has less impact on your database performance during maintenance windows.
|
||||
|
||||
@@ -42,7 +42,7 @@ Migrating projects can be achieved using the Supabase CLI. This is particularly
|
||||
```bash
|
||||
supabase db dump --db-url "$OLD_DB_URL" -f roles.sql --role-only
|
||||
supabase db dump --db-url "$OLD_DB_URL" -f schema.sql
|
||||
supabase db dump --db-url "$OLD_DB_URL" -f data.sql --data-only
|
||||
supabase db dump --db-url "$OLD_DB_URL" -f data.sql --use-copy --data-only
|
||||
```
|
||||
|
||||
### Restore to your new project
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import Layout from '~/layouts/DefaultGuideLayout'
|
||||
import { getAnalyticsConfigV0 } from '~/lib/mdx/getConfig'
|
||||
import Param from '~/components/Params'
|
||||
|
||||
export const spec = getAnalyticsConfigV0()
|
||||
import { serialize } from 'next-mdx-remote/serialize'
|
||||
import components from '~/components'
|
||||
import { MDXRemote } from 'next-mdx-remote'
|
||||
|
||||
export const meta = {
|
||||
title: 'Analytics Self-hosting Config',
|
||||
description: 'How to configure and deploy Supabase Analytics.',
|
||||
}
|
||||
|
||||
<div>{spec.info.description}</div>
|
||||
<MDXRemote {...props.descriptionSource} components={components} />
|
||||
|
||||
<div>
|
||||
{spec.info.tags.map(tag => {
|
||||
{props.spec.info.tags.map(tag => {
|
||||
return (
|
||||
<>
|
||||
<h2 className="text-scale-1200">{tag.title}</h2>
|
||||
@@ -20,7 +21,7 @@ export const meta = {
|
||||
<div className="not-prose">
|
||||
<h5 className="text-base text-scale-1200 mb-3">Parameters</h5>
|
||||
<ul>
|
||||
{spec.parameters.filter(param => param.tags.includes(tag.id)).map((param) => {
|
||||
{props.spec.parameters.filter(param => param.tags.includes(tag.id)).map((param) => {
|
||||
return <Param name={param.title} type={param.type} description={param.description} required={param.required} />
|
||||
})}
|
||||
|
||||
@@ -36,3 +37,14 @@ export const meta = {
|
||||
export const Page = ({ children }) => <Layout meta={meta} children={children} />
|
||||
|
||||
export default Page
|
||||
|
||||
export const getStaticProps = async () => {
|
||||
const spec = getAnalyticsConfigV0()
|
||||
const descriptionSource = await serialize(spec.info.description)
|
||||
return {
|
||||
props: {
|
||||
descriptionSource,
|
||||
spec,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
import Layout from '~/layouts/DefaultGuideLayout'
|
||||
import { getAuthConfigV1 } from '~/lib/mdx/getConfig'
|
||||
import Param from '~/components/Params'
|
||||
|
||||
export const spec = getAuthConfigV1()
|
||||
import { serialize } from 'next-mdx-remote/serialize'
|
||||
import components from '~/components'
|
||||
import { MDXRemote } from 'next-mdx-remote'
|
||||
|
||||
export const meta = {
|
||||
title: 'Auth Self-hosting Config',
|
||||
description: 'How to configure and deploy Supabase.',
|
||||
}
|
||||
|
||||
<div>{spec.info.description}</div>
|
||||
<MDXRemote {...props.descriptionSource} components={components} />
|
||||
|
||||
<div>
|
||||
{spec.info.tags.map(tag => {
|
||||
{props.spec.info.tags.map(tag => {
|
||||
return (
|
||||
<>
|
||||
<h2 className="text-scale-1200">{tag.title}</h2>
|
||||
@@ -20,7 +21,7 @@ export const meta = {
|
||||
<div className="not-prose">
|
||||
<h5 className="text-base text-scale-1200 mb-3">Parameters</h5>
|
||||
<ul>
|
||||
{spec.parameters.filter(param => param.tags.includes(tag.id)).map((param) => {
|
||||
{props.spec.parameters.filter(param => param.tags.includes(tag.id)).map((param) => {
|
||||
return <Param name={param.title} type={param.type} description={param.description} required={param.required} />
|
||||
})}
|
||||
|
||||
@@ -36,3 +37,14 @@ export const meta = {
|
||||
export const Page = ({ children }) => <Layout meta={meta} children={children} />
|
||||
|
||||
export default Page
|
||||
|
||||
export const getStaticProps = async () => {
|
||||
const spec = getAuthConfigV1()
|
||||
const descriptionSource = await serialize(spec.info.description)
|
||||
return {
|
||||
props: {
|
||||
descriptionSource,
|
||||
spec,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,21 @@
|
||||
import Layout from '~/layouts/DefaultGuideLayout'
|
||||
import { getRealtimeConfigV0 } from '~/lib/mdx/getConfig'
|
||||
import Param from '~/components/Params'
|
||||
|
||||
export const spec = getRealtimeConfigV0()
|
||||
import { serialize } from 'next-mdx-remote/serialize'
|
||||
import components from '~/components'
|
||||
import { MDXRemote } from 'next-mdx-remote'
|
||||
|
||||
export const meta = {
|
||||
title: 'Realtime Self-hosting Config',
|
||||
description: 'How to configure and deploy Supabase.',
|
||||
}
|
||||
|
||||
<div>{spec.info.description}</div>
|
||||
<MDXRemote {...props.descriptionSource} components={components} />
|
||||
|
||||
See [this guide](/docs/guides/realtime/bring-your-own-database) if you want to configure your database to work with Realtime.
|
||||
|
||||
<div>
|
||||
{spec.info.tags.map(tag => {
|
||||
{props.spec.info.tags.map(tag => {
|
||||
return (
|
||||
<>
|
||||
<h2 className="text-scale-1200">{tag.title}</h2>
|
||||
@@ -22,7 +23,7 @@ See [this guide](/docs/guides/realtime/bring-your-own-database) if you want to c
|
||||
<div className="not-prose">
|
||||
<h5 className="text-base text-scale-1200 mb-3">Parameters</h5>
|
||||
<ul>
|
||||
{spec.parameters.filter(param => param.tags.includes(tag.id)).map((param) => {
|
||||
{props.spec.parameters.filter(param => param.tags.includes(tag.id)).map((param) => {
|
||||
return <Param name={param.title} type={param.type} description={param.description} required={param.required} />
|
||||
})}
|
||||
|
||||
@@ -38,3 +39,14 @@ See [this guide](/docs/guides/realtime/bring-your-own-database) if you want to c
|
||||
export const Page = ({ children }) => <Layout meta={meta} children={children} />
|
||||
|
||||
export default Page
|
||||
|
||||
export const getStaticProps = async () => {
|
||||
const spec = getRealtimeConfigV0()
|
||||
const descriptionSource = await serialize(spec.info.description)
|
||||
return {
|
||||
props: {
|
||||
descriptionSource,
|
||||
spec,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
import Layout from '~/layouts/DefaultGuideLayout'
|
||||
import { getStorageConfigV0 } from '~/lib/mdx/getConfig'
|
||||
import Param from '~/components/Params'
|
||||
|
||||
export const spec = getStorageConfigV0()
|
||||
import { serialize } from 'next-mdx-remote/serialize'
|
||||
import components from '~/components'
|
||||
import { MDXRemote } from 'next-mdx-remote'
|
||||
|
||||
export const meta = {
|
||||
title: 'Storage Self-hosting Config',
|
||||
description: 'How to configure and deploy Supabase.',
|
||||
}
|
||||
|
||||
<div>{spec.info.description}</div>
|
||||
<MDXRemote {...props.descriptionSource} components={components} />
|
||||
|
||||
<div>
|
||||
{spec.info.tags.map(tag => {
|
||||
{props.spec.info.tags.map(tag => {
|
||||
return (
|
||||
<>
|
||||
<h2 className="text-scale-1200">{tag.title}</h2>
|
||||
@@ -20,7 +21,7 @@ export const meta = {
|
||||
<div className="not-prose">
|
||||
<h5 className="text-base text-scale-1200 mb-3">Parameters</h5>
|
||||
<ul>
|
||||
{spec.parameters.filter(param => param.tags.includes(tag.id)).map((param) => {
|
||||
{props.spec.parameters.filter(param => param.tags.includes(tag.id)).map((param) => {
|
||||
return <Param name={param.title} type={param.type} description={param.description} required={param.required} />
|
||||
})}
|
||||
|
||||
@@ -36,3 +37,14 @@ export const meta = {
|
||||
export const Page = ({ children }) => <Layout meta={meta} children={children} />
|
||||
|
||||
export default Page
|
||||
|
||||
export const getStaticProps = async () => {
|
||||
const spec = getStorageConfigV0()
|
||||
const descriptionSource = await serialize(spec.info.description)
|
||||
return {
|
||||
props: {
|
||||
descriptionSource,
|
||||
spec,
|
||||
},
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -12,4 +12,4 @@ NEXT_PUBLIC_REFERENCE_DOCS_URL="https://localhost:3010"
|
||||
NEXT_PUBLIC_LW_STARTED=true
|
||||
NEXT_PUBLIC_SITE_ORIGIN=http://localhost:3000
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://obuldanrptloktxcffvn.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9idWxkYW5ycHRsb2t0eGNmZnZuIiwicm9sZSI6ImFub24iLCJpYXQiOjE2Njk3MjcwMTIsImV4cCI6MTk4NTMwMzAxMn0.SZLqryz_-stF8dgzeVXmzZWPOqdOrBwqJROlFES8v3I
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9idWxkYW5ycHRsb2t0eGNmZnZuIiwicm9sZSI6ImFub24iLCJpYXQiOjE2OTIyNjkzMjYsImV4cCI6MjAwNzg0NTMyNn0.1S6qpBbHtEmGuMsIx5UOhRiFd4YbVv-yLTrLk6tVGmM
|
||||
@@ -265,7 +265,7 @@ import 'https://deno.land/x/xhr@0.2.1/mod.ts'
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.5.0'
|
||||
import GPT3Tokenizer from 'https://esm.sh/gpt3-tokenizer@1.1.5'
|
||||
import { Configuration, OpenAIApi } from 'https://esm.sh/openai@3.1.0'
|
||||
import { stripIndent } from 'https://esm.sh/common-tags@1.8.2'
|
||||
import { oneLine, stripIndent } from 'https://esm.sh/common-tags@1.8.2'
|
||||
import { supabaseClient } from './lib/supabase'
|
||||
|
||||
export const corsHeaders = {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
name: Berri AI
|
||||
title: Berri AI Boosts Productivity by Migrating from AWS RDS to Supabase with pgvector
|
||||
title: Berri AI boosts productivity by migrating from AWS RDS to Supabase Vector
|
||||
# Use meta_title to add a custom meta title. Otherwise it defaults to '{name} | Supabase Customer Stories':
|
||||
# meta_title:
|
||||
meta_title: Berri AI boosts productivity by migrating from AWS RDS to Supabase Vector
|
||||
description: Learn how Berri AI overcame challenges with self-hosting their vector database on AWS RDS and successfully migrated to Supabase.
|
||||
# Use meta_description to add a custom meta description. Otherwise it defaults to {description}:
|
||||
meta_description: Learn how Berri AI overcame challenges with self-hosting their vector database on AWS RDS and successfully migrated to Supabase.
|
||||
@@ -42,29 +42,31 @@ Initially, Berri AI attempted to develop with AWS RDS to reduce latency and gain
|
||||
|
||||
## Why they chose Supabase:
|
||||
|
||||
Berri AI migrated their vector database from AWS RDS to Supabase for several compelling reasons.
|
||||
Berri AI migrated their vector database from AWS RDS to Supabase for several compelling reasons:
|
||||
|
||||
1. **Community:** After considering alternatives like pgvector, Pinecone, and Chroma, they prioritized a solution with a strong and accessible community, along with managed infrastructure.
|
||||
1. **Community:** After considering alternatives like Pinecone and Chroma, they prioritized a solution with a strong and accessible community, along with [managed infrastructure](https://supabase.com/docs/guides/platform).
|
||||
2. **Fewer services to manage:** Since they were already utilizing Supabase for other purposes, it made sense for Berri AI to consolidate their systems and avoid the complexities of self-hosting. One of the major pain points they faced with their previous setup was managing connection pools and handling errors with the Postgres database hosted on AWS. This involved writing extensive code and dealing with rollbacks, with each database update function requiring 40-50 lines of code. Supabase simplified this process significantly by condensing it to just one line of code, eliminating the burdens of managing dependencies and handling new errors. This significantly improved their update velocity, which had a huge impact on their productivity.
|
||||
3. **Better tooling:** Berri AI experienced frustration with pg_admin. Supabase's user-friendly interface offered a seamless and efficient way to manage and read tables, eliminating the need for pg_admin and providing a simple alternative. This streamlined their workflow and saved valuable time and effort. The accessibility of Supabase's client SDKs and intuitive interface played a crucial role in their decision as well. Team members who were not database experts found it easy to navigate and make changes, reducing reliance on specialized expertise and improving their overall workflow.
|
||||
4. **Simplicity:** Supabase provides the ability to add new columns in a no-code manner, which provided flexibility and agility in responding to evolving requirements. This feature empowered the team to make on-the-fly adjustments and meet their immediate needs effectively.
|
||||
5. **Flexibility:** Berri AI also appreciated Supabase's flexibility in allowing them to choose their preferred embedding method, as it was not tied to a specific embedding model. This ensured compatibility and adaptability for their specific use case.
|
||||
5. **Flexibility:** Berri AI also appreciated Supabase's flexibility in allowing them to choose their [preferred embedding method](https://supabase.com/docs/guides/ai/quickstarts/generate-text-embeddings), as it was not tied to a specific embedding model. This ensured compatibility and adaptability for their specific use case.
|
||||
|
||||
The integration of pgvector on Supabase, which facilitated efficient storage and retrieval of vectors, proved to be a significant advantage for Berri AI's operations.
|
||||
The migration to [Supabase Vector](https://supabase.com/vector), which facilitated efficient storage and retrieval of vectors, proved to be a significant advantage for Berri AI's operations.
|
||||
|
||||
<Quote img="krrish-d.png" caption="Krrish D. - Co-Founder & CEO, Berri AI">
|
||||
Switching to Supabase was a game-changer for us. The faster view times allowed me to quickly
|
||||
access and debug data, enhancing my ability to analyze individual customer issues. The no-code
|
||||
option to add new columns on the fly was fantastic, enabling us to adapt swiftly.
|
||||
Switching to Supabase Vector was a game-changer for us. The faster view times allowed me to
|
||||
quickly access and debug data, enhancing my ability to analyze individual customer issues. The
|
||||
no-code option to add new columns on the fly was fantastic, enabling us to adapt swiftly.
|
||||
</Quote>
|
||||
|
||||
## What They Built
|
||||
|
||||
Berri AI built a platform that enables developers to create and manage custom ChatGPT instances for each individual user, integrating seamlessly with their unique data.
|
||||
|
||||

|
||||
|
||||
With Berri AI, businesses can rapidly scale the power of ChatGPT to cater to the needs of every user. The platform provides an out-of-the-box solution for managing a fleet of ChatGPT instances, offering businesses the ability to deliver personalized responses to user queries. This includes answering specific questions related to order status, monthly revenue, or generating personalized learning experiences in the education technology (EdTech) sector, such as summarizing lecture slides or generating practice questions from textbooks.
|
||||
|
||||
The platform's integration with Supabase and pgvector empowers businesses to unlock the full potential of ChatGPT and deliver customized solutions for a wide range of use cases.
|
||||
The platform's integration with Supabase empowers businesses to unlock the full potential of ChatGPT and deliver customized solutions for a wide range of use cases.
|
||||
|
||||
<Quote img="ishaan-jaffer.png" caption="Ishaan Jaffer - Co-Founder & CTO, Berri AI">
|
||||
With Supabase, my life has become much easier. Previously, managing connection pools and dealing
|
||||
@@ -77,12 +79,14 @@ The platform's integration with Supabase and pgvector empowers businesses to unl
|
||||
|
||||
## The Results
|
||||
|
||||
Berri AI achieved significant improvements in their operations by choosing Supabase with pgvector. They experienced streamlined database management, increased update velocity, and improved efficiency in accessing and debugging data. The user-friendly interface and flexibility of Supabase empowered non-experts within the team and facilitated agile adaptations to changing requirements. Overall, this decision transformed their workflow, enhancing productivity and positioning Berri AI for continued success and growth.
|
||||
Berri AI achieved significant improvements in their operations by choosing Supabase Vector. They experienced streamlined database management, increased update velocity, and improved efficiency in accessing and debugging data. The user-friendly interface and flexibility of Supabase empowered non-experts within the team and facilitated agile adaptations to changing requirements. Overall, this decision transformed their workflow, enhancing productivity and positioning Berri AI for continued success and growth.
|
||||
|
||||
To learn more about how Supabas Vector can help you store vector embeddings at scale and build AI apps with ease, [reach out to us](https://forms.supabase.com/enterprise).
|
||||
|
||||
## Tech stack
|
||||
|
||||
- Supabase
|
||||
- Zeet
|
||||
- AWS
|
||||
- OpenAI
|
||||
- Hugging Face
|
||||
- Supabase
|
||||
@@ -2,7 +2,7 @@
|
||||
name: HappyTeams
|
||||
title: HappyTeams unlocks better performance and reduces cost with Supabase.
|
||||
# Use meta_title to add a custom meta title. Otherwise it defaults to '{name} | Supabase Customer Stories':
|
||||
# meta_title:
|
||||
meta_title: HappyTeams unlocks better performance and reduces cost with Supabase.
|
||||
description: How a bootstrapped startup migrated from Heroku to Supabase in 30 minutes and never looked back.
|
||||
# Use meta_description to add a custom meta description. Otherwise it defaults to {description}:
|
||||
meta_description: How a bootstrapped startup migrated from Heroku to Supabase in 30 minutes and never looked back.
|
||||
@@ -32,11 +32,11 @@ about: HappyTeams is a purpose-built solution that empowers management to unlock
|
||||
|
||||
> Finding a painless and hands-free database experience to support growth
|
||||
|
||||
[HappyTeams](https://happyteams.io/) is a growing startup with an ambitious vision. The company is bootstrapped, so technical resources are scarce. Devs need to do more with less. This means staying laser-focused on delivering features, rather than database timeouts or performance issues.
|
||||
HappyTeams is a growing startup with an ambitious vision. The company is bootstrapped, so technical resources are scarce. Devs need to do more with less. This means staying laser-focused on delivering features, rather than database timeouts or performance issues.
|
||||
|
||||
The company launched its app on Heroku Postgres. But one year in, they were experiencing scaling issues. They needed to handle thousands of users while also shipping new features and building out integrations. Issues with database slowness and concurrent connections were holding them back.
|
||||
|
||||
Michael Dever, the founder and CTO of HappyTeams, realized it was time for a change. A large overhaul of the app in early 2022 presented the opportunity to do so: "When you're in that first sprint of building a product, you're kind of locked with that first database decision you make. When we did a rebuild, it was time to look at something like Supabase."
|
||||
Michael Dever, founder and CTO of HappyTeams, realized it was time for a change. A large overhaul of the app in early 2022 presented the opportunity to do so: "When you're in that first sprint of building a product, you're kind of locked with that first database decision you make. When we did a rebuild, it was time to look at something like Supabase."
|
||||
|
||||

|
||||
|
||||
@@ -58,7 +58,7 @@ There were four main factors that led HappyTeams to switch from Heroku Postgres
|
||||
|
||||
## Supabase provides a fast and reliable production database
|
||||
|
||||
After a 30 minute migration, HappyTeams can iterate quickly, add new features, and improve their product based on customer feedback
|
||||
After a 30-minute migration, HappyTeams can iterate quickly, add new features, and improve their product based on customer feedback
|
||||
|
||||
Once they made the decision, migrating from Heroku to Supabase was simple. HappyTeams completed their database migration in less than an hour using pg_dump and pg_restore.
|
||||
|
||||
@@ -74,3 +74,5 @@ Thanks to Supabase's ease of use and reliability, HappyTeams can iterate quickly
|
||||
When we needed to add a comments feature to our app, we could get it done in just two days.
|
||||
Supabase is really like a dream come true for a developer.
|
||||
</Quote>
|
||||
|
||||
Supabase is one of the best free alternatives to Heroku Postgres. Migrate your Heroku Postgres database to Supabase with this [guide](https://supabase.com/docs/guides/resources/migrating-to-supabase/heroku).
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Markprompt and Supabase - GDPR-Compliant AI Chatbots for Docs and Websites.
|
||||
title: Markprompt and Supabase - GDPR-compliant AI chatbots for docs and websites.
|
||||
name: Markprompt
|
||||
description: AI-powered chatbot platform, Markprompt, empowers developers to deliver efficient and GDPR-compliant prompt experiences on top of their content, by leveraging Supabase's secure and privacy-focused database and authentication solutions.
|
||||
description: AI-powered chatbot platform, Markprompt, empowers developers to deliver efficient and GDPR-compliant prompt experiences on top of their content, by leveraging Supabase's secure and privacy-focused vector database and authentication solutions.
|
||||
author: paul_copplestone
|
||||
author_title: Supabase
|
||||
author_url: https://github.com/kiwicopple
|
||||
@@ -33,18 +33,18 @@ Markprompt is an AI-powered chatbot platform that simplifies the process of find
|
||||
|
||||
## The Challenge
|
||||
|
||||
When developing the platform, Markprompt considered GDPR compliance crucial as it enabled them to handle user data responsibly, maintain data privacy, and foster trust and confidence among their customers. As a result, the choice of a robust database and authentication solution became vital in ensuring adherence to GDPR requirements.
|
||||
When developing the platform, Markprompt considered GDPR compliance crucial as it enabled them to handle user data responsibly, maintain data privacy, and foster trust and confidence among their customers. As a result, the choice of a robust vector database and authentication solution became vital in ensuring adherence to GDPR requirements.
|
||||
|
||||
Markprompt has successfully indexed over half a million sections of content, with a steady influx of 10,000 - 50,000 new sections daily. This emphasizes the significance of their vector database solution in terms of reliability and scalability.
|
||||
Markprompt has successfully indexed over half a million sections of content, with a steady influx of 10,000 - 50,000 new sections daily. This emphasizes the significance of [Supabase Vector](https://supabase.com/vector) in terms of reliability and scalability.
|
||||
|
||||
<Quote img="michael-fester.jpeg" caption="Michael Fester - Co-Founder, Markprompt">
|
||||
We decided to use Supabase over other specialized vector databases because it enabled us to be
|
||||
GDPR compliant from day one with little effort.
|
||||
</Quote>
|
||||
|
||||
## Why they chose Supabase:
|
||||
## Why they chose Supabase
|
||||
|
||||
By utilizing a trusted solution like Supabase for their database and authentication needs, Markprompt can implement access controls, user consent management, and audit trails, which are essential elements for GDPR compliance. This ensures that personal data is handled with care, protected from unauthorized access, and enables Markprompt to meet the strict data protection standards set forth by the GDPR.
|
||||
By utilizing a trusted solution like Supabase for their vector database and authentication needs, Markprompt can implement access controls, user consent management, and audit trails, which are essential elements for GDPR compliance. This ensures that personal data is handled with care, protected from unauthorized access, and enables Markprompt to meet the strict data protection standards set forth by the GDPR.
|
||||
|
||||
Markprompt were also impressed with the ease of use and scalability of Supabase. The platform allowed them to focus on building their product, knowing that they had a powerful and reliable database solution backing them up.
|
||||
|
||||
@@ -55,7 +55,7 @@ Markprompt were also impressed with the ease of use and scalability of Supabase.
|
||||
|
||||
## What They Built
|
||||
|
||||
Markprompt is a set of API endpoints that allow you to train your content and create a prompt to ask questions to it, for instance for a docs site. Markprompt also designed a user-friendly web dashboard that simplifies content training, synchronization with GitHub repos or websites, access key management, and provides insights into user query statistics. Furthermore, Markprompt's UI components, available as React and Web Components, allow integration of prompts at all touch points of a company, from public websites to internal knowledge bases with just a few lines of code.
|
||||
Markprompt is a set of API endpoints that allow you to train your content and create a prompt to ask questions to it, for instance for a docs site. Markprompt also designed a user-friendly web dashboard that simplifies content training, synchronization with GitHub repos or websites, access key management, and provides insights into user query statistics. Furthermore, Markprompt's UI components, available as React and Web Components, allow the integration of prompts at all touch points of a company, from public websites to internal knowledge bases with just a few lines of code.
|
||||
|
||||
Like Supabase, Markprompt is an open-source platform, allowing customers the freedom to host the dashboard and model backend on their own premises. Markprompt warmly welcomes contributions from the community, fostering collaboration and continuous improvement of the platform to meet the evolving needs of developers and technical users.
|
||||
|
||||
@@ -66,16 +66,18 @@ Like Supabase, Markprompt is an open-source platform, allowing customers the fre
|
||||
|
||||
## The Results
|
||||
|
||||
Choosing Supabase as their database and authentication solution empowered Markprompt to focus on developing innovative AI-powered solutions without compromising data security or impeding scalability. The seamless integration between Supabase and pgvector enabled them to meet the increasing demands of their expanding user base while upholding stringent GDPR requirements. Consequently, companies gain trust and confidence in the platform, solidifying its reputation as a robust enterprise offering.
|
||||
Choosing Supabase as their vector database and authentication solution empowered Markprompt to focus on developing innovative AI-powered solutions without compromising data security or impeding scalability. The seamless integration between Supabase and pgvector enabled them to meet the increasing demands of their expanding user base while upholding stringent GDPR requirements. Consequently, companies gain trust and confidence in the platform, solidifying its reputation as a robust enterprise offering.
|
||||
|
||||
To learn more about how Supabase Vector can help you store vector embeddings at scale and build GDPR-compliant apps, [reach out to us](https://forms.supabase.com/enterprise).
|
||||
|
||||
## Tech stack
|
||||
|
||||
- Next.js - framework
|
||||
- Vercel - hosting
|
||||
- Typescript - language
|
||||
- Tailwind - CSS
|
||||
- Upstash - Redis and rate limiting
|
||||
- Supabase - database and auth
|
||||
- Stripe - payments
|
||||
- Plain - support chat
|
||||
- Fathom - analytics
|
||||
- Next.js
|
||||
- Vercel
|
||||
- Typescript
|
||||
- Tailwind
|
||||
- Upstash
|
||||
- Supabase
|
||||
- Stripe
|
||||
- Plain
|
||||
- Fathom
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: Mendable
|
||||
title: Mendable switches from Pinecone to Supabase for PostgreSQL vector embeddings.
|
||||
title: Mendable switches from Pinecone to Supabase Vector for PostgreSQL vector embeddings.
|
||||
# Use meta_title to add a custom meta title. Otherwise it defaults to '{name} | Supabase Customer Stories':
|
||||
# meta_title:
|
||||
description: How Mendable boosts efficiency and accuracy of chat powered search for documentation using Supabase with pgvector.
|
||||
# meta_title: Mendable switches from Pinecone to Supabase Vector for PostgreSQL vector embeddings.
|
||||
description: How Mendable boosts efficiency and accuracy of chat powered search for documentation using Supabase Vector.
|
||||
# Use meta_description to add a custom meta description. Otherwise it defaults to {description}:
|
||||
meta_description: How Mendable boosts efficiency and accuracy of chat powered search for documentation using Supabase with pgvector.
|
||||
meta_description: How Mendable boosts efficiency and accuracy of chat powered search for documentation using Supabase Vector.
|
||||
author: paul_copplestone
|
||||
author_title: Supabase
|
||||
author_url: https://github.com/kiwicopple
|
||||
@@ -32,11 +32,11 @@ about: Mendable is Chat Powered Search for Documentation.
|
||||
|
||||
## The Challenge
|
||||
|
||||
Mendable was experiencing tremendous success, growing Weekly Active Users nearly 300% since March. They needed a tool to store and search through large amounts of vector data to improve the efficiency and accuracy of their similarity search operations. They tried Faiss, Weaviate, and Pinecone, but found them to be expensive and not very intuitive, especially when it came to storing metadata along with the vectors.
|
||||
Mendable was experiencing tremendous success, growing Weekly Active Users by nearly 300% since March. They needed a tool to store and search through large amounts of vector data to improve the efficiency and accuracy of their similarity search operations. They tried Faiss, Weaviate, and Pinecone, but found them to be expensive and not very intuitive, especially when it came to storing metadata along with the vectors.
|
||||
|
||||
## Why they chose Supabase
|
||||
|
||||
Mendable discovered that Supabase supports [pgvector](https://supabase.com/docs/guides/database/extensions/pgvector) and found it to be a simple and cost-effective solution. They were impressed with the open source nature of Supabase, as well as its ability to store metadata alongside the vectors. They also appreciated the intuitive interface and ease of use.
|
||||
Mendable lear that Supabase supports [pgvector](https://supabase.com/docs/guides/database/extensions/pgvector) and found it to be a simple and cost-effective solution. They were impressed with the open source nature of Supabase, as well as its ability to store metadata alongside the vectors. They also appreciated the intuitive interface and ease of use.
|
||||
|
||||
<Quote img="caleb-peffer.jpg" caption="Caleb Peffer - CEO, Mendable">
|
||||
We tried other vector databases - we tried Faiss, we tried Weaviate, we tried Pinecone. We found
|
||||
@@ -48,16 +48,24 @@ Mendable discovered that Supabase supports [pgvector](https://supabase.com/docs/
|
||||
|
||||
Using [Supabase Vector](https://supabase.com/vector), Mendable was able to build a more efficient and accurate search function for their AI chatbot. By storing vector data alongside metadata in Supabase, Mendable was able to quickly and easily search through their customers documentation to find the most relevant responses to queries. They found that Supabase's solution was just as performant as dedicated vector databases, but without the high cost.
|
||||
|
||||

|
||||
|
||||
## The Results
|
||||
|
||||
Thanks to Supabase and pgvector, Mendable was able to significantly improve the efficiency and accuracy of their Chat Powered Search for Documentation. They were able to build faster and more cost-effectively using Supabase’s open source stack.
|
||||
Thanks to Supabase Vector, Mendable was able to significantly improve the efficiency and accuracy of their Chat Powered Search for Documentation. They were able to build faster and more cost-effectively using Supabase’s open source stack.
|
||||
|
||||
<Quote img="caleb-peffer.jpg" caption="Caleb Peffer - CEO, Mendable">
|
||||
We looked at the alternatives and chose Supabase because it’s open source, it’s simpler, and, for
|
||||
all the ways we need use it, Supabase has been just as performant - if not more performant - than
|
||||
the other vector databases.
|
||||
all the ways we need to use it, Supabase has been just as performant - if not more performant -
|
||||
than the other vector databases.
|
||||
</Quote>
|
||||
|
||||
To learn more about how Supabase Vector can help you store vector embeddings at scale and build AI apps with ease, [reach out to us](https://forms.supabase.com/enterprise).
|
||||
|
||||
## Tech stack
|
||||
|
||||
Mendable's tech stack includes React, Next.js, Express, Vercel, and Supabase.
|
||||
- React
|
||||
- Vercel
|
||||
- Next.js
|
||||
- Express
|
||||
- Supabase
|
||||
@@ -35,7 +35,7 @@ export default function TicketBrickWall({ users }: Props) {
|
||||
{isMobile && <TicketBrickWallSlider users={users.slice(11, 17)} speed={16000} />}
|
||||
</div>
|
||||
<div className="flex justify-center w-full mx-auto mt-2 lg:mt-4">
|
||||
<Link href="/launch-week/tickets">
|
||||
<Link href="/launch-week/7/tickets">
|
||||
<a>
|
||||
<Button
|
||||
type="outline"
|
||||
|
||||
@@ -49,10 +49,9 @@ const LW8Meetups = ({ meetups }: { meetups?: Meetup[] }) => {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto grid grid-cols-1 xl:grid-cols-12 gap-8 xl:gap-8">
|
||||
<div className="col-span-1 xl:col-span-4 flex flex-col justify-center max-w-lg">
|
||||
<h2 className="text-2xl sm:text-3xl xl:text-4xl tracking-[-.5px]">Join the community</h2>
|
||||
<p className="text-scale-1000 text-base py-3 xl:max-w-md">
|
||||
Celebrate LW8 at our first-ever live community meetups across various locations. Join the
|
||||
fun, grab some swag, and meet the Supabase community.
|
||||
<h2 className="text-2xl sm:text-3xl xl:text-4xl tracking-[-.5px]">Community meetups</h2>
|
||||
<p className="text-[#9296AA] text-base py-3 xl:max-w-md">
|
||||
We celebrated LW8 with our first-ever live community meetups across various locations.
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-1 xl:col-span-7 xl:col-start-6 w-full max-w-4xl flex flex-col justify-between items-stretch">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react'
|
||||
import { StyledArticleBadge } from './Releases/components'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
const LWArchive = () => {
|
||||
return (
|
||||
@@ -14,36 +13,38 @@ const LWArchive = () => {
|
||||
</p>
|
||||
</div>
|
||||
<div className="lg:basis-2/3 relative grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<Link href="/launch-week/6">
|
||||
<a className="relative h-[160px] group w-full rounded-md md:rounded-lg transition-transform bg-[#030A0C] flex items-center justify-center border border-[#111618] hover:border-brand-800 overflow-hidden">
|
||||
<span className="relative z-10 stroke-text inline-block text-transparent bg-clip-text bg-gradient-to-b from-scale-1200 to-scale-1000 text-7xl drop-shadow-lg">
|
||||
6
|
||||
</span>
|
||||
<div className="absolute inset-0 grayscale group-hover:grayscale-0">
|
||||
<Image
|
||||
src="/images/launchweek/archive/lw6.jpg"
|
||||
alt="Launch Week 6"
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
<Link href="/launch-week/7">
|
||||
<a className="relative h-[160px] group w-full rounded-md md:rounded-lg transition-transform bg-[#030A0C] flex items-center justify-center border border-[#111718] hover:border-brand-1200 overflow-hidden">
|
||||
<span className="relative z-10 stroke-text inline-block text-transparent bg-clip-text bg-gradient-to-b from-scale-1200 to-scale-1000 text-7xl drop-shadow-lg">
|
||||
7
|
||||
</span>
|
||||
<div className="absolute inset-0 grayscale group-hover:grayscale-0">
|
||||
<Image
|
||||
src="/images/launchweek/archive/lw7.jpg"
|
||||
alt="Launch Week 7"
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
<a
|
||||
href="/launch-week/6"
|
||||
className="relative h-[160px] group w-full rounded-md md:rounded-lg transition-transform bg-[#030A0C] flex items-center justify-center border border-[#111618] hover:border-brand-800 overflow-hidden"
|
||||
>
|
||||
<span className="relative z-10 stroke-text inline-block text-transparent bg-clip-text bg-gradient-to-b from-scale-1200 to-scale-1000 text-7xl drop-shadow-lg">
|
||||
6
|
||||
</span>
|
||||
<div className="absolute inset-0 grayscale group-hover:grayscale-0">
|
||||
<Image
|
||||
src="/images/launchweek/archive/lw6.jpg"
|
||||
alt="Launch Week 6"
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href="/launch-week/7"
|
||||
className="relative h-[160px] group w-full rounded-md md:rounded-lg transition-transform bg-[#030A0C] flex items-center justify-center border border-[#111718] hover:border-brand-1200 overflow-hidden"
|
||||
>
|
||||
<span className="relative z-10 stroke-text inline-block text-transparent bg-clip-text bg-gradient-to-b from-scale-1200 to-scale-1000 text-7xl drop-shadow-lg">
|
||||
7
|
||||
</span>
|
||||
<div className="absolute inset-0 grayscale group-hover:grayscale-0">
|
||||
<Image
|
||||
src="/images/launchweek/archive/lw7.jpg"
|
||||
alt="Launch Week 7"
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -369,7 +369,7 @@ export default function LW8Releases() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-b border-[#111718] scroll-mt-16" id="today">
|
||||
<div className="border-b border-[#111718] scroll-mt-16">
|
||||
<AccordionHeader
|
||||
date={day4.date}
|
||||
day={day4.d}
|
||||
@@ -463,7 +463,7 @@ export default function LW8Releases() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-b border-[#111718] scroll-mt-16" id="today">
|
||||
<div className="border-b border-[#111718] scroll-mt-16">
|
||||
<AccordionHeader
|
||||
date={day5.date}
|
||||
day={day5.d}
|
||||
|
||||
@@ -166,7 +166,6 @@ const Nav = () => {
|
||||
<div
|
||||
className={[
|
||||
`
|
||||
text-scale-1200 hover:text-brand
|
||||
inline-flex cursor-pointer items-center
|
||||
border-b-2
|
||||
border-transparent
|
||||
@@ -174,6 +173,7 @@ const Nav = () => {
|
||||
text-sm font-medium
|
||||
transition-colors`,
|
||||
showLaunchWeekNavMode && '!text-white',
|
||||
props.active ? 'text-brand' : 'hover:text-brand',
|
||||
props.active,
|
||||
].join(' ')}
|
||||
onClick={props.onClick}
|
||||
@@ -198,7 +198,7 @@ const Nav = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Announcement link="/launch-week#today">
|
||||
<Announcement link="/launch-week">
|
||||
<LW8CountdownBanner />
|
||||
</Announcement>
|
||||
<div className="sticky top-0 z-40 transform" style={{ transform: 'translate3d(0,0,999px)' }}>
|
||||
|
||||
@@ -26,16 +26,16 @@ export default function TileGrid({
|
||||
>
|
||||
<h2 className="h2">Featured</h2>
|
||||
<div className="grid grid-cols-1 gap-5 lg:max-w-none lg:grid-cols-2 xl:grid-cols-3">
|
||||
{featuredPartners.map((p) => (
|
||||
{featuredPartners?.map((p) => (
|
||||
<Link key={p.slug} href={`/partners/${p.slug}`}>
|
||||
<a>
|
||||
<div
|
||||
className="
|
||||
bg-scale-100 dark:bg-scale-300
|
||||
hover:bg-scale-200 hover:dark:bg-scale-400
|
||||
group flex h-full w-full flex-col rounded-xl border px-6
|
||||
py-6 shadow
|
||||
transition-all
|
||||
group flex h-full w-full flex-col rounded-xl border px-6
|
||||
py-6 shadow
|
||||
transition-all
|
||||
hover:shadow-lg"
|
||||
>
|
||||
<div className="flex w-full space-x-6">
|
||||
@@ -79,9 +79,9 @@ export default function TileGrid({
|
||||
className="
|
||||
bg-scale-100 dark:bg-scale-300
|
||||
hover:bg-scale-200 hover:dark:bg-scale-400
|
||||
group flex h-full w-full flex-col rounded-xl border px-6
|
||||
py-6 shadow
|
||||
transition-all
|
||||
group flex h-full w-full flex-col rounded-xl border px-6
|
||||
py-6 shadow
|
||||
transition-all
|
||||
hover:shadow-lg"
|
||||
>
|
||||
<div className="flex w-full space-x-6">
|
||||
|
||||
@@ -56,6 +56,7 @@ function TwitterSocialProof() {
|
||||
<div className="mt-6">
|
||||
<div className="cursor-move lg:-mr-32 lg:-ml-32">
|
||||
<Swiper
|
||||
loop={true}
|
||||
initialSlide={3}
|
||||
spaceBetween={0}
|
||||
slidesPerView={4}
|
||||
|
||||
@@ -1857,6 +1857,11 @@ module.exports = [
|
||||
source: '/docs/handbook/supasquad',
|
||||
destination: '/supasquad',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/supasquad',
|
||||
destination: '/open-source/contributing/supasquad',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/contact/enterprise',
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { Database } from './database.types'
|
||||
|
||||
const SUPABASE_URL = 'https://obuldanrptloktxcffvn.supabase.co'
|
||||
const SUPABASE_ANON_KEY =
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9idWxkYW5ycHRsb2t0eGNmZnZuIiwicm9sZSI6ImFub24iLCJpYXQiOjE2Njk3MjcwMTIsImV4cCI6MTk4NTMwMzAxMn0.SZLqryz_-stF8dgzeVXmzZWPOqdOrBwqJROlFES8v3I'
|
||||
|
||||
const supabase = createClient<Database>(SUPABASE_URL, SUPABASE_ANON_KEY)
|
||||
const supabase = createClient<Database>(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
)
|
||||
|
||||
export type SupabaseClient = typeof supabase
|
||||
|
||||
|
||||
@@ -29,9 +29,7 @@ interface Props {
|
||||
const supabaseAdmin = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'http://localhost:54321',
|
||||
// ANON KEY
|
||||
process.env.SUPABASE_SERVICE_ROLE_SECRET ??
|
||||
process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_SECRET ??
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9idWxkYW5ycHRsb2t0eGNmZnZuIiwicm9sZSI6ImFub24iLCJpYXQiOjE2Njk3MjcwMTIsImV4cCI6MTk4NTMwMzAxMn0.SZLqryz_-stF8dgzeVXmzZWPOqdOrBwqJROlFES8v3I'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
)
|
||||
export default function TicketHome({ users }: Props) {
|
||||
const { isDarkMode, toggleTheme } = useTheme()
|
||||
|
||||
@@ -24,9 +24,7 @@ interface Props {
|
||||
|
||||
const supabaseAdmin = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'http://localhost:54321',
|
||||
process.env.SUPABASE_SERVICE_ROLE_SECRET ??
|
||||
process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_SECRET ??
|
||||
''
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
)
|
||||
|
||||
export default function UsernamePage({ user, users, ogImageUrl }: Props) {
|
||||
|
||||
@@ -22,9 +22,7 @@ interface Props {
|
||||
|
||||
const supabaseAdmin = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'http://localhost:54321',
|
||||
process.env.SUPABASE_SERVICE_ROLE_SECRET ??
|
||||
process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_SECRET ??
|
||||
''
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
)
|
||||
|
||||
const generateOgs = async (users: UserData[]) => {
|
||||
|
||||
@@ -39,9 +39,7 @@ interface Props {
|
||||
const supabaseAdmin = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'http://localhost:54321',
|
||||
// ANON KEY
|
||||
process.env.SUPABASE_SERVICE_ROLE_SECRET ??
|
||||
process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_SECRET ??
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9idWxkYW5ycHRsb2t0eGNmZnZuIiwicm9sZSI6ImFub24iLCJpYXQiOjE2Njk3MjcwMTIsImV4cCI6MTk4NTMwMzAxMn0.SZLqryz_-stF8dgzeVXmzZWPOqdOrBwqJROlFES8v3I'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
)
|
||||
|
||||
export default function TicketHome({ users, meetups }: Props) {
|
||||
|
||||
@@ -28,9 +28,7 @@ interface Props {
|
||||
const supabaseAdmin = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'http://localhost:54321',
|
||||
// ANON KEY
|
||||
process.env.SUPABASE_SERVICE_ROLE_SECRET ??
|
||||
process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_SECRET ??
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9idWxkYW5ycHRsb2t0eGNmZnZuIiwicm9sZSI6ImFub24iLCJpYXQiOjE2Njk3MjcwMTIsImV4cCI6MTk4NTMwMzAxMn0.SZLqryz_-stF8dgzeVXmzZWPOqdOrBwqJROlFES8v3I'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
)
|
||||
|
||||
export default function UsernamePage({ user, users, ogImageUrl }: Props) {
|
||||
|
||||
@@ -23,9 +23,7 @@ interface Props {
|
||||
|
||||
const supabaseAdmin = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'http://localhost:54321',
|
||||
process.env.SUPABASE_SERVICE_ROLE_SECRET ??
|
||||
process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_SECRET ??
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9idWxkYW5ycHRsb2t0eGNmZnZuIiwicm9sZSI6ImFub24iLCJpYXQiOjE2Njk3MjcwMTIsImV4cCI6MTk4NTMwMzAxMn0.SZLqryz_-stF8dgzeVXmzZWPOqdOrBwqJROlFES8v3I'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
)
|
||||
|
||||
const generateOgs = async (users: UserData[]) => {
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import Layout from '../layouts/Layout'
|
||||
import SectionContainer from '../components/Layouts/SectionContainer'
|
||||
import Layout from '~/layouts/Layout'
|
||||
import SectionContainer from '~/components/Layouts/SectionContainer'
|
||||
|
||||
export const meta = {
|
||||
title: 'SupaSquad | Supabase',
|
||||
@@ -30,7 +30,7 @@ interface Props {
|
||||
}
|
||||
|
||||
function ExpertPartnersPage(props: Props) {
|
||||
const { partners } = props
|
||||
const partners = props.partners ?? []
|
||||
const router = useRouter()
|
||||
|
||||
const meta_title = 'Find an expert'
|
||||
|
||||
@@ -34,10 +34,10 @@ interface Props {
|
||||
}
|
||||
|
||||
function IntegrationPartnersPage(props: Props) {
|
||||
const { partners: initialPartners } = props
|
||||
const initialPartners = props.partners ?? []
|
||||
const [partners, setPartners] = useState(initialPartners)
|
||||
|
||||
const allCategories = Array.from(new Set(initialPartners.map((p) => p.category)))
|
||||
const allCategories = Array.from(new Set(initialPartners?.map((p) => p.category)))
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -213,7 +213,7 @@ function IntegrationPartnersPage(props: Props) {
|
||||
<div className="lg:col-span-8 xl:col-span-9">
|
||||
{/* Partner Tiles */}
|
||||
<div className="grid space-y-10">
|
||||
{partners.length ? (
|
||||
{partners?.length ? (
|
||||
<TileGrid partners={partners} />
|
||||
) : (
|
||||
<h2 className="h2">No Partners Found</h2>
|
||||
|
||||
@@ -5,63 +5,63 @@
|
||||
<link>https://supabase.com</link>
|
||||
<description>Latest news from Supabase</description>
|
||||
<language>en</language>
|
||||
<lastBuildDate>Mon, 05 Jun 2023 22:00:00 GMT</lastBuildDate>
|
||||
<lastBuildDate>Mon, 05 Jun 2023 21:00:00 GMT</lastBuildDate>
|
||||
<atom:link href="https://supabase.com/rss.xml" rel="self" type="application/rss+xml"/>
|
||||
<item>
|
||||
<guid>https://supabase.com/customers/berriai</guid>
|
||||
<title>Berri AI Boosts Productivity by Migrating from AWS RDS to Supabase with pgvector</title>
|
||||
<title>Berri AI boosts productivity by migrating from AWS RDS to Supabase Vector</title>
|
||||
<link>https://supabase.com/customers/berriai</link>
|
||||
<description>Learn how Berri AI overcame challenges with self-hosting their vector database on AWS RDS and successfully migrated to Supabase.</description>
|
||||
<pubDate>Mon, 05 Jun 2023 22:00:00 GMT</pubDate>
|
||||
<pubDate>Mon, 05 Jun 2023 21:00:00 GMT</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<guid>https://supabase.com/customers/markprompt</guid>
|
||||
<title>Markprompt and Supabase - GDPR-Compliant AI Chatbots for Docs and Websites.</title>
|
||||
<title>Markprompt and Supabase - GDPR-compliant AI chatbots for docs and websites.</title>
|
||||
<link>https://supabase.com/customers/markprompt</link>
|
||||
<description>AI-powered chatbot platform, Markprompt, empowers developers to deliver efficient and GDPR-compliant prompt experiences on top of their content, by leveraging Supabase's secure and privacy-focused database and authentication solutions.</description>
|
||||
<pubDate>Tue, 16 May 2023 22:00:00 GMT</pubDate>
|
||||
<description>AI-powered chatbot platform, Markprompt, empowers developers to deliver efficient and GDPR-compliant prompt experiences on top of their content, by leveraging Supabase's secure and privacy-focused vector database and authentication solutions.</description>
|
||||
<pubDate>Tue, 16 May 2023 21:00:00 GMT</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<guid>https://supabase.com/customers/mendableai</guid>
|
||||
<title>Mendable switches from Pinecone to Supabase for PostgreSQL vector embeddings.</title>
|
||||
<title>Mendable switches from Pinecone to Supabase Vector for PostgreSQL vector embeddings.</title>
|
||||
<link>https://supabase.com/customers/mendableai</link>
|
||||
<description>How Mendable boosts efficiency and accuracy of chat powered search for documentation using Supabase with pgvector.</description>
|
||||
<pubDate>Thu, 04 May 2023 22:00:00 GMT</pubDate>
|
||||
<description>How Mendable boosts efficiency and accuracy of chat powered search for documentation using Supabase Vector.</description>
|
||||
<pubDate>Thu, 04 May 2023 21:00:00 GMT</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<guid>https://supabase.com/customers/happyteams</guid>
|
||||
<title>HappyTeams unlocks better performance and reduces cost with Supabase.</title>
|
||||
<link>https://supabase.com/customers/happyteams</link>
|
||||
<description>How a bootstrapped startup migrated from Heroku to Supabase in 30 minutes and never looked back.</description>
|
||||
<pubDate>Wed, 15 Feb 2023 23:00:00 GMT</pubDate>
|
||||
<pubDate>Wed, 15 Feb 2023 22:00:00 GMT</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<guid>https://supabase.com/customers/xendit</guid>
|
||||
<title>Xendit use Supabase and create a full solution shipped to production in less than one week.</title>
|
||||
<link>https://supabase.com/customers/xendit</link>
|
||||
<description>As a payment processor, Xendit are responsible for verifying that all transactions are legal.</description>
|
||||
<pubDate>Mon, 13 Feb 2023 23:00:00 GMT</pubDate>
|
||||
<pubDate>Mon, 13 Feb 2023 22:00:00 GMT</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<guid>https://supabase.com/customers/replenysh</guid>
|
||||
<title>Replenysh uses Supabase to implement OTP in less than 24 hours.</title>
|
||||
<link>https://supabase.com/customers/replenysh</link>
|
||||
<description>With Supabase, Replenysh gets a slick auth experience, reduces DevOps overhead, and continues to scale with Postgres.</description>
|
||||
<pubDate>Mon, 13 Feb 2023 23:00:00 GMT</pubDate>
|
||||
<pubDate>Mon, 13 Feb 2023 22:00:00 GMT</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<guid>https://supabase.com/customers/mobbin</guid>
|
||||
<title>How Mobbin migrated 200,000 users from Firebase for a better authentication experience.</title>
|
||||
<link>https://supabase.com/customers/mobbin</link>
|
||||
<description>Mobbin helps over 200,000 creators globally search and view the latest design patterns from well-known apps.</description>
|
||||
<pubDate>Mon, 13 Feb 2023 23:00:00 GMT</pubDate>
|
||||
<pubDate>Mon, 13 Feb 2023 22:00:00 GMT</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<guid>https://supabase.com/customers/epsilon3</guid>
|
||||
<title>Epsilon3 digitize paper-based procedures in the space industry using telemetry data, simplifying testing and operations.</title>
|
||||
<link>https://supabase.com/customers/epsilon3</link>
|
||||
<description>Epsilon3 uses Supabase to help teams execute secure and reliable operations in an industry where project spend runs into the billions.</description>
|
||||
<pubDate>Mon, 13 Feb 2023 23:00:00 GMT</pubDate>
|
||||
<pubDate>Mon, 13 Feb 2023 22:00:00 GMT</pubDate>
|
||||
</item>
|
||||
|
||||
</channel>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 183 KiB |
@@ -261,7 +261,7 @@ export async function handler(req: Request) {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization:
|
||||
'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9idWxkYW5ycHRsb2t0eGNmZnZuIiwicm9sZSI6ImFub24iLCJpYXQiOjE2Njk3MjcwMTIsImV4cCI6MTk4NTMwMzAxMn0.SZLqryz_-stF8dgzeVXmzZWPOqdOrBwqJROlFES8v3I',
|
||||
'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9idWxkYW5ycHRsb2t0eGNmZnZuIiwicm9sZSI6ImFub24iLCJpYXQiOjE2OTIyNjkzMjYsImV4cCI6MjAwNzg0NTMyNn0.1S6qpBbHtEmGuMsIx5UOhRiFd4YbVv-yLTrLk6tVGmM',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
|
||||
@@ -257,7 +257,7 @@ export async function handler(req: Request) {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization:
|
||||
'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9idWxkYW5ycHRsb2t0eGNmZnZuIiwicm9sZSI6ImFub24iLCJpYXQiOjE2Njk3MjcwMTIsImV4cCI6MTk4NTMwMzAxMn0.SZLqryz_-stF8dgzeVXmzZWPOqdOrBwqJROlFES8v3I',
|
||||
'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9idWxkYW5ycHRsb2t0eGNmZnZuIiwicm9sZSI6ImFub24iLCJpYXQiOjE2OTIyNjkzMjYsImV4cCI6MjAwNzg0NTMyNn0.1S6qpBbHtEmGuMsIx5UOhRiFd4YbVv-yLTrLk6tVGmM',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
|
||||
@@ -275,7 +275,7 @@ services:
|
||||
|
||||
meta:
|
||||
container_name: supabase-meta
|
||||
image: supabase/postgres-meta:v0.66.3
|
||||
image: supabase/postgres-meta:v0.68.0
|
||||
depends_on:
|
||||
db:
|
||||
# Disable this if you are using an external Postgres database
|
||||
|
||||
Generated
+3
-3
@@ -2,13 +2,13 @@
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2023.5.7"
|
||||
version = "2023.7.22"
|
||||
description = "Python package for providing Mozilla's CA Bundle."
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
files = [
|
||||
{file = "certifi-2023.5.7-py3-none-any.whl", hash = "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716"},
|
||||
{file = "certifi-2023.5.7.tar.gz", hash = "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7"},
|
||||
{file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"},
|
||||
{file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Nuxt3 TODO list example
|
||||
|
||||
Please refer to https://github.com/nuxt-modules/supabase/tree/main/demo
|
||||
Generated
+3
-10
@@ -14121,8 +14121,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/gotrue-js": {
|
||||
"version": "2.27.2",
|
||||
"license": "MIT",
|
||||
"version": "2.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/gotrue-js/-/gotrue-js-2.47.0.tgz",
|
||||
"integrity": "sha512-3e34/vsKH/DoSZCpB85UZpFWSJ2p4GRUUlqgAgeTPagPlx4xS+Nc5v7g7ic7vp3gK0J5PsYVCn9Qu2JQUp4vXg==",
|
||||
"dependencies": {
|
||||
"cross-fetch": "^3.1.5"
|
||||
}
|
||||
@@ -40764,14 +40765,6 @@
|
||||
"typescript": "^5.0.4"
|
||||
}
|
||||
},
|
||||
"packages/common/node_modules/@supabase/gotrue-js": {
|
||||
"version": "2.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/gotrue-js/-/gotrue-js-2.47.0.tgz",
|
||||
"integrity": "sha512-3e34/vsKH/DoSZCpB85UZpFWSJ2p4GRUUlqgAgeTPagPlx4xS+Nc5v7g7ic7vp3gK0J5PsYVCn9Qu2JQUp4vXg==",
|
||||
"dependencies": {
|
||||
"cross-fetch": "^3.1.5"
|
||||
}
|
||||
},
|
||||
"packages/common/node_modules/@types/react": {
|
||||
"version": "17.0.50",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -67,6 +67,12 @@ const CommandMenu = ({ projectRef }: CommandMenuProps) => {
|
||||
useCommandMenu()
|
||||
const showCommandInput = !currentPage || !CHAT_ROUTES.includes(currentPage)
|
||||
|
||||
// This function has been added to prevent the use of double quotes in the search docs input due to an issue with the cmdk-supabase module. This function can be removed when we transition to using cmdk.
|
||||
const handleInputChange = (value: string) => {
|
||||
const newValue = value.replace(/"/g, '') // Remove double quotes
|
||||
setSearch(newValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommandDialog
|
||||
@@ -88,7 +94,7 @@ const CommandMenu = ({ projectRef }: CommandMenuProps) => {
|
||||
ref={commandInputRef}
|
||||
placeholder="Type a command or search..."
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
onValueChange={handleInputChange}
|
||||
/>
|
||||
)}
|
||||
<CommandList className={['my-2', showCommandInput && 'max-h-[300px]'].join(' ')}>
|
||||
|
||||
@@ -53,7 +53,7 @@ const LWAnnouncement = ({
|
||||
className={['border hover:border-scale-800 transition-colors', cardClassName].join(' ')}
|
||||
innerClassName={['!bg-opacity-70', cardInnerClassName].join(' ')}
|
||||
>
|
||||
<Link href={`${LW_SITE_URL}#today`}>
|
||||
<Link href={LW_SITE_URL}>
|
||||
<a className="flex flex-row justify-between items-center w-full h-full gap-2 text-left no-underline">
|
||||
<div className="relative flex-shrink flex items-center p-2 w-2/3 md:w-auto">
|
||||
<div className="flex flex-col gap-1 sm:pl-2">
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
"show": true,
|
||||
"text": "Supabase Launch Week 8",
|
||||
"launchDate": "2023-08-07T09:00:00.000-07:00",
|
||||
"link": "https://supabase.com/launch-week#today",
|
||||
"link": "https://supabase.com/launch-week",
|
||||
"badge": "Get your ticket"
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
clispec: '001'
|
||||
info:
|
||||
id: cli
|
||||
version: 1.86.1
|
||||
version: 1.86.2
|
||||
title: Supabase CLI
|
||||
language: sh
|
||||
source: https://github.com/supabase/cli
|
||||
@@ -1191,7 +1191,7 @@ commands:
|
||||
To reset your migration history to a clean state, first delete your local migration file.
|
||||
|
||||
```bash
|
||||
$ rm supabase/migrations/20230103054303_remote_schema.sql
|
||||
$ rm supabase/migrations/20230103054303_remote_commit.sql
|
||||
$ supabase migration list
|
||||
LOCAL │ REMOTE │ TIME (UTC)
|
||||
─────────────────┼────────────────┼──────────────────────
|
||||
@@ -2568,7 +2568,7 @@ commands:
|
||||
response: |
|
||||
Connecting to local database...
|
||||
Creating shadow database...
|
||||
Applying migration 20230425064254_remote_schema.sql...
|
||||
Applying migration 20230425064254_remote_commit.sql...
|
||||
Diffing schemas: auth,extensions,public,storage
|
||||
Finished supabase db diff on branch main.
|
||||
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ Project settings are managed outside of the Dashboard. If you use docker compose
|
||||
### Developer Quickstart
|
||||
|
||||
```bash
|
||||
# You'll need to be on Node v14
|
||||
# You'll need to be on Node v18
|
||||
# in /studio
|
||||
|
||||
npm i # install dependencies
|
||||
|
||||
@@ -1,52 +1,31 @@
|
||||
import { useEffect, useState, useRef, forwardRef, useImperativeHandle } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { DataGridHandle } from '@supabase/react-data-grid'
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
|
||||
import { DndProvider } from 'react-dnd'
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend'
|
||||
import { useMonaco } from '@monaco-editor/react'
|
||||
import { DataGridHandle } from '@supabase/react-data-grid'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import { useUrlState } from 'hooks'
|
||||
import { Dictionary, SupabaseGridProps, SupabaseGridRef } from './types'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { useTableRowsQuery } from 'data/table-rows/table-rows-query'
|
||||
import { StoreProvider, useDispatch, useTrackedState } from './store'
|
||||
import { Shortcuts } from './components/common'
|
||||
import { Grid } from './components/grid'
|
||||
import Header from './components/header'
|
||||
import Footer from './components/footer'
|
||||
import { RowContextMenu } from './components/menu'
|
||||
import { useUrlState } from 'hooks'
|
||||
import {
|
||||
cleanupProps,
|
||||
initTable,
|
||||
saveStorageDebounced,
|
||||
formatFilterURLParams,
|
||||
formatSortURLParams,
|
||||
initTable,
|
||||
saveStorageDebounced,
|
||||
} from './SupabaseGrid.utils'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { Shortcuts } from './components/common'
|
||||
import Footer from './components/footer'
|
||||
import { Grid } from './components/grid'
|
||||
import Header from './components/header'
|
||||
import { RowContextMenu } from './components/menu'
|
||||
import { StoreProvider, useDispatch, useTrackedState } from './store'
|
||||
import { Dictionary, SupabaseGridProps, SupabaseGridRef } from './types'
|
||||
|
||||
/** Supabase Grid: React component to render database table */
|
||||
|
||||
export const SupabaseGrid = forwardRef<SupabaseGridRef, SupabaseGridProps>((props, ref) => {
|
||||
const monaco = useMonaco()
|
||||
const _props = cleanupProps(props)
|
||||
const { theme } = _props
|
||||
|
||||
useEffect(() => {
|
||||
if (monaco) {
|
||||
const darkTheme = theme && theme === 'dark' ? true : false
|
||||
monaco.editor.defineTheme('supabase', {
|
||||
base: darkTheme ? 'vs-dark' : 'vs',
|
||||
inherit: true, // can also be false to completely replace the builtin rules
|
||||
rules: [
|
||||
{ token: 'string.sql', foreground: '24b47e' },
|
||||
{ token: 'comment', foreground: '666666' },
|
||||
{ token: 'predefined.sql', foreground: '999999' },
|
||||
],
|
||||
colors: {
|
||||
'editor.background': darkTheme ? '#222222' : '#fbfcfd',
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [monaco, theme])
|
||||
|
||||
return (
|
||||
<StoreProvider>
|
||||
|
||||
@@ -46,7 +46,7 @@ const ColumnMenu = ({ column, isEncrypted }: ColumnMenuProps) => {
|
||||
<>
|
||||
{state.editable && onEditColumn !== undefined && (
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger className={`${isEncrypted ? 'opacity-50' : ''}`}>
|
||||
<Tooltip.Trigger asChild className={`${isEncrypted ? 'opacity-50' : ''}`}>
|
||||
<Dropdown.Item
|
||||
onClick={onEditColumn}
|
||||
disabled={isEncrypted}
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import { useMonaco } from '@monaco-editor/react'
|
||||
import { useTheme } from 'common'
|
||||
import IncidentBanner from 'components/layouts/AppLayout/IncidentBanner'
|
||||
import { getTheme } from 'components/ui/CodeEditor'
|
||||
import { useFlag } from 'hooks'
|
||||
import { PropsWithChildren } from 'react'
|
||||
import { PropsWithChildren, useEffect } from 'react'
|
||||
|
||||
const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => {
|
||||
const monaco = useMonaco()
|
||||
const { isDarkMode } = useTheme()
|
||||
const ongoingIncident = useFlag('ongoingIncident')
|
||||
|
||||
useEffect(() => {
|
||||
if (monaco) {
|
||||
const theme: any = getTheme(isDarkMode)
|
||||
monaco.editor.defineTheme('supabase', theme)
|
||||
}
|
||||
}, [isDarkMode, monaco])
|
||||
|
||||
return (
|
||||
<div className="min-h-full flex flex-col">
|
||||
{ongoingIncident && <IncidentBanner />}
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { useStore, useFlag } from 'hooks'
|
||||
import { PROVIDERS_SCHEMAS, PROVIDER_SAML } from 'stores/authConfig/schema'
|
||||
import { useParams } from 'common'
|
||||
import { FormHeader } from 'components/ui/Forms'
|
||||
import { HorizontalShimmerWithIcon } from 'components/ui/Shimmers'
|
||||
import ProviderForm from './ProviderForm'
|
||||
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
|
||||
import { useFlag } from 'hooks'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { PROVIDERS_SCHEMAS, PROVIDER_SAML } from 'stores/authConfig/schema'
|
||||
import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, IconAlertCircle } from 'ui'
|
||||
import { ProviderCollapsibleClasses } from './AuthProvidersForm.constants'
|
||||
import ProviderForm from './ProviderForm'
|
||||
|
||||
const AuthProvidersForm = () => {
|
||||
const { authConfig } = useStore()
|
||||
const { ref: projectRef } = useParams()
|
||||
const {
|
||||
isLoading,
|
||||
error: authConfigError,
|
||||
isError,
|
||||
data: authConfig,
|
||||
isSuccess,
|
||||
} = useAuthConfigQuery({ projectRef })
|
||||
|
||||
const isSamlEnabled = useFlag('isSamlEnabled')
|
||||
const providers = isSamlEnabled
|
||||
? PROVIDERS_SCHEMAS
|
||||
@@ -21,19 +32,32 @@ const AuthProvidersForm = () => {
|
||||
/>
|
||||
|
||||
<div className="-space-y-px">
|
||||
{!authConfig.isLoaded
|
||||
? providers.map((provider) => (
|
||||
<div
|
||||
{isLoading &&
|
||||
providers.map((provider) => (
|
||||
<div
|
||||
key={`provider_${provider.title}`}
|
||||
className={[...ProviderCollapsibleClasses, 'px-6 py-3'].join(' ')}
|
||||
>
|
||||
<HorizontalShimmerWithIcon />
|
||||
</div>
|
||||
))}
|
||||
{isError && (
|
||||
<Alert_Shadcn_ variant="destructive">
|
||||
<IconAlertCircle strokeWidth={2} />
|
||||
<AlertTitle_Shadcn_>Failed to retrieve auth configuration</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>{authConfigError.message}</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
{isSuccess &&
|
||||
providers.map((provider) => {
|
||||
return (
|
||||
<ProviderForm
|
||||
key={`provider_${provider.title}`}
|
||||
className={[...ProviderCollapsibleClasses, 'px-6 py-3'].join(' ')}
|
||||
>
|
||||
<HorizontalShimmerWithIcon />
|
||||
</div>
|
||||
))
|
||||
: providers.map((provider) => {
|
||||
// @ts-expect-error: Fix type to be dynamic
|
||||
return <ProviderForm key={`provider_${provider.title}`} provider={provider} />
|
||||
})}
|
||||
config={authConfig}
|
||||
provider={provider as any}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -5,7 +5,8 @@ import ReactMarkdown from 'react-markdown'
|
||||
import { Alert, Button, Collapsible, Form, IconCheck, IconChevronUp, Input } from 'ui'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { components } from 'data/api'
|
||||
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
|
||||
import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query'
|
||||
import { useCheckPermissions, useStore } from 'hooks'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
@@ -14,44 +15,44 @@ import { Provider } from './AuthProvidersForm.types'
|
||||
import FormField from './FormField'
|
||||
|
||||
export interface ProviderFormProps {
|
||||
config: components['schemas']['GetGoTrueConfigResponse']
|
||||
provider: Provider
|
||||
}
|
||||
|
||||
const ProviderForm = ({ provider }: ProviderFormProps) => {
|
||||
const ProviderForm = ({ config, provider }: ProviderFormProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { authConfig, ui } = useStore()
|
||||
const { project: selectedProject } = useProjectContext()
|
||||
const { ref } = useParams()
|
||||
const { ui } = useStore()
|
||||
const { ref: projectRef } = useParams()
|
||||
const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation()
|
||||
const doubleNegativeKeys = ['MAILER_AUTOCONFIRM', 'SMS_AUTOCONFIRM']
|
||||
const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue')
|
||||
|
||||
const { data: customDomainData } = useCustomDomainsQuery({ projectRef: ref })
|
||||
const { data: customDomainData } = useCustomDomainsQuery({ projectRef })
|
||||
|
||||
const generateInitialValues = () => {
|
||||
const initialValues: { [x: string]: string } = {}
|
||||
// the config is already loaded through the parent component
|
||||
Object.keys(provider.properties).forEach((key) => {
|
||||
// When the key is a 'double negative' key, we must reverse the boolean before adding it to the form
|
||||
const isDoubleNegative = doubleNegativeKeys.includes(key)
|
||||
|
||||
if (provider.title === 'SAML 2.0') {
|
||||
initialValues[key] = authConfig.config[key] ?? false
|
||||
initialValues[key] = (config as any)[key] ?? false
|
||||
} else {
|
||||
initialValues[key] = isDoubleNegative
|
||||
? !authConfig.config[key]
|
||||
: authConfig.config[key] ?? ''
|
||||
initialValues[key] = isDoubleNegative ? !(config as any)[key] : (config as any)[key] ?? ''
|
||||
}
|
||||
})
|
||||
return initialValues
|
||||
}
|
||||
|
||||
// [Joshen] Doing this check as SAML doesn't follow the same naming structure as the other provider options
|
||||
const isActive =
|
||||
const isActive: boolean =
|
||||
provider.title === 'SAML 2.0'
|
||||
? authConfig.config['SAML_ENABLED'] || false
|
||||
: authConfig.config[`EXTERNAL_${provider?.title?.toUpperCase()}_ENABLED`]
|
||||
? (config && (config as any)['SAML_ENABLED']) ?? false
|
||||
: (config && (config as any)[`EXTERNAL_${provider?.title?.toUpperCase()}_ENABLED`]) ?? false
|
||||
const INITIAL_VALUES = generateInitialValues()
|
||||
|
||||
const onSubmit = async (values: any, { setSubmitting, resetForm }: any) => {
|
||||
const onSubmit = (values: any, { resetForm }: any) => {
|
||||
const payload = { ...values }
|
||||
|
||||
// When the key is a 'double negative' key, we must reverse the boolean before the payload can be sent
|
||||
@@ -61,21 +62,23 @@ const ProviderForm = ({ provider }: ProviderFormProps) => {
|
||||
}
|
||||
})
|
||||
|
||||
const { error } = await authConfig.update(payload)
|
||||
|
||||
if (!error) {
|
||||
resetForm({ values: { ...values }, initialValues: { ...values } })
|
||||
setOpen(false)
|
||||
ui.setNotification({ category: 'success', message: 'Successfully updated settings' })
|
||||
} else {
|
||||
ui.setNotification({
|
||||
error,
|
||||
category: 'error',
|
||||
message: `Failed to update settings: ${error?.message}`,
|
||||
})
|
||||
}
|
||||
|
||||
setSubmitting(false)
|
||||
updateAuthConfig(
|
||||
{ projectRef: projectRef!, config: payload },
|
||||
{
|
||||
onError: (error) => {
|
||||
ui.setNotification({
|
||||
error,
|
||||
category: 'error',
|
||||
message: `Failed to update settings: ${error?.message}`,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
resetForm({ values: { ...values }, initialValues: { ...values } })
|
||||
setOpen(false)
|
||||
ui.setNotification({ category: 'success', message: 'Successfully updated settings' })
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -124,7 +127,7 @@ const ProviderForm = ({ provider }: ProviderFormProps) => {
|
||||
validationSchema={provider.validationSchema}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{({ isSubmitting, handleReset, initialValues, values }: any) => {
|
||||
{({ handleReset, initialValues, values }: any) => {
|
||||
const noChanges = JSON.stringify(initialValues) === JSON.stringify(values)
|
||||
return (
|
||||
<Collapsible.Content>
|
||||
@@ -159,7 +162,7 @@ const ProviderForm = ({ provider }: ProviderFormProps) => {
|
||||
value={
|
||||
customDomainData?.customDomain?.status === 'active'
|
||||
? `https://${customDomainData.customDomain?.hostname}/auth/v1/callback`
|
||||
: `https://${selectedProject?.ref}.supabase.co/auth/v1/callback`
|
||||
: `https://${projectRef}.supabase.co/auth/v1/callback`
|
||||
}
|
||||
descriptionText={
|
||||
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
|
||||
@@ -177,7 +180,7 @@ const ProviderForm = ({ provider }: ProviderFormProps) => {
|
||||
handleReset()
|
||||
setOpen(false)
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
disabled={isUpdatingConfig}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -185,8 +188,8 @@ const ProviderForm = ({ provider }: ProviderFormProps) => {
|
||||
<Tooltip.Trigger type="button">
|
||||
<Button
|
||||
htmlType="submit"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting || !canUpdateConfig || noChanges}
|
||||
loading={isUpdatingConfig}
|
||||
disabled={isUpdatingConfig || !canUpdateConfig || noChanges}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { boolean, number, object, string } from 'yup'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { Button, Form, Input, IconEye, IconEyeOff, InputNumber, Toggle, Radio } from 'ui'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
Alert_Shadcn_,
|
||||
Button,
|
||||
Form,
|
||||
IconAlertCircle,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
Input,
|
||||
InputNumber,
|
||||
Radio,
|
||||
Toggle,
|
||||
} from 'ui'
|
||||
import { boolean, number, object, string } from 'yup'
|
||||
|
||||
import { useStore, useCheckPermissions } from 'hooks'
|
||||
import { useParams } from 'common'
|
||||
import {
|
||||
FormActions,
|
||||
FormHeader,
|
||||
@@ -13,25 +26,36 @@ import {
|
||||
FormSectionContent,
|
||||
FormSectionLabel,
|
||||
} from 'components/ui/Forms'
|
||||
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
|
||||
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
|
||||
import { useCheckPermissions, useStore } from 'hooks'
|
||||
|
||||
const AutoSchemaForm = observer(() => {
|
||||
const { authConfig, ui } = useStore()
|
||||
const { isLoaded } = authConfig
|
||||
const { ui } = useStore()
|
||||
const { ref: projectRef } = useParams()
|
||||
const {
|
||||
data: authConfig,
|
||||
error: authConfigError,
|
||||
isLoading,
|
||||
isError,
|
||||
isSuccess,
|
||||
} = useAuthConfigQuery({ projectRef })
|
||||
const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation()
|
||||
|
||||
const formId = 'auth-config-general-form'
|
||||
const [hidden, setHidden] = useState(true)
|
||||
const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue')
|
||||
|
||||
const INITIAL_VALUES = {
|
||||
DISABLE_SIGNUP: !authConfig.config.DISABLE_SIGNUP,
|
||||
SITE_URL: authConfig.config.SITE_URL,
|
||||
JWT_EXP: authConfig.config.JWT_EXP,
|
||||
REFRESH_TOKEN_ROTATION_ENABLED: authConfig.config.REFRESH_TOKEN_ROTATION_ENABLED || false,
|
||||
SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: authConfig.config.SECURITY_REFRESH_TOKEN_REUSE_INTERVAL,
|
||||
SECURITY_CAPTCHA_ENABLED: authConfig.config.SECURITY_CAPTCHA_ENABLED || false,
|
||||
SECURITY_CAPTCHA_SECRET: authConfig.config.SECURITY_CAPTCHA_SECRET || '',
|
||||
SECURITY_CAPTCHA_PROVIDER: authConfig.config.SECURITY_CAPTCHA_PROVIDER || 'hcaptcha',
|
||||
MFA_MAX_ENROLLED_FACTORS: authConfig.config.MFA_MAX_ENROLLED_FACTORS || 10,
|
||||
DISABLE_SIGNUP: !authConfig?.DISABLE_SIGNUP,
|
||||
SITE_URL: authConfig?.SITE_URL,
|
||||
JWT_EXP: authConfig?.JWT_EXP,
|
||||
REFRESH_TOKEN_ROTATION_ENABLED: authConfig?.REFRESH_TOKEN_ROTATION_ENABLED || false,
|
||||
SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: authConfig?.SECURITY_REFRESH_TOKEN_REUSE_INTERVAL,
|
||||
SECURITY_CAPTCHA_ENABLED: authConfig?.SECURITY_CAPTCHA_ENABLED || false,
|
||||
SECURITY_CAPTCHA_SECRET: authConfig?.SECURITY_CAPTCHA_SECRET || '',
|
||||
SECURITY_CAPTCHA_PROVIDER: authConfig?.SECURITY_CAPTCHA_PROVIDER || 'hcaptcha',
|
||||
MFA_MAX_ENROLLED_FACTORS: authConfig?.MFA_MAX_ENROLLED_FACTORS || 10,
|
||||
}
|
||||
|
||||
const schema = object({
|
||||
@@ -60,38 +84,49 @@ const AutoSchemaForm = observer(() => {
|
||||
.max(30, 'Must be a value less than 30'),
|
||||
})
|
||||
|
||||
const onSubmit = async (values: any, { setSubmitting, resetForm }: any) => {
|
||||
const onSubmit = (values: any, { resetForm }: any) => {
|
||||
const payload = { ...values }
|
||||
payload.DISABLE_SIGNUP = !values.DISABLE_SIGNUP
|
||||
|
||||
setSubmitting(true)
|
||||
const { error } = await authConfig.update(payload)
|
||||
updateAuthConfig(
|
||||
{ projectRef: projectRef!, config: payload },
|
||||
{
|
||||
onError: (error) => {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: `Failed to update settings: ${error?.message}`,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
message: `Successfully updated settings`,
|
||||
})
|
||||
resetForm({ values: values, initialValues: values })
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (!error) {
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
message: `Successfully updated settings`,
|
||||
})
|
||||
resetForm({ values: values, initialValues: values })
|
||||
} else {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: `Failed to update settings: ${error?.message}`,
|
||||
})
|
||||
}
|
||||
|
||||
setSubmitting(false)
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert_Shadcn_ variant="destructive">
|
||||
<IconAlertCircle strokeWidth={2} />
|
||||
<AlertTitle_Shadcn_>Failed to retrieve auth configuration</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>{authConfigError.message}</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Form id={formId} initialValues={INITIAL_VALUES} onSubmit={onSubmit} validationSchema={schema}>
|
||||
{({ isSubmitting, handleReset, resetForm, values, initialValues }: any) => {
|
||||
{({ handleReset, resetForm, values, initialValues }: any) => {
|
||||
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
|
||||
|
||||
// Form is reset once remote data is loaded in store
|
||||
useEffect(() => {
|
||||
resetForm({ values: INITIAL_VALUES, initialValues: INITIAL_VALUES })
|
||||
}, [authConfig.isLoaded])
|
||||
if (isSuccess) resetForm({ values: INITIAL_VALUES, initialValues: INITIAL_VALUES })
|
||||
}, [isSuccess])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -105,7 +140,7 @@ const AutoSchemaForm = observer(() => {
|
||||
<div className="flex py-4 px-8">
|
||||
<FormActions
|
||||
form={formId}
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitting={isUpdatingConfig}
|
||||
hasChanges={hasChanges}
|
||||
handleReset={handleReset}
|
||||
disabled={!canUpdateConfig}
|
||||
@@ -119,7 +154,7 @@ const AutoSchemaForm = observer(() => {
|
||||
}
|
||||
>
|
||||
<FormSection header={<FormSectionLabel>User Signups</FormSectionLabel>}>
|
||||
<FormSectionContent loading={!isLoaded}>
|
||||
<FormSectionContent loading={isLoading}>
|
||||
<Toggle
|
||||
id="DISABLE_SIGNUP"
|
||||
size="small"
|
||||
@@ -132,7 +167,7 @@ const AutoSchemaForm = observer(() => {
|
||||
</FormSection>
|
||||
<div className="border-t border-scale-400"></div>
|
||||
<FormSection header={<FormSectionLabel>User Sessions</FormSectionLabel>}>
|
||||
<FormSectionContent loading={!isLoaded}>
|
||||
<FormSectionContent loading={isLoading}>
|
||||
{/* Permitted redirects for anything on that domain */}
|
||||
{/* Check with @kangming about this */}
|
||||
<InputNumber
|
||||
@@ -146,7 +181,7 @@ const AutoSchemaForm = observer(() => {
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
<FormSection header={<FormSectionLabel>Security and Protection</FormSectionLabel>}>
|
||||
<FormSectionContent loading={!isLoaded}>
|
||||
<FormSectionContent loading={isLoading}>
|
||||
<Toggle
|
||||
id="SECURITY_CAPTCHA_ENABLED"
|
||||
size="small"
|
||||
@@ -213,7 +248,7 @@ const AutoSchemaForm = observer(() => {
|
||||
<FormSection
|
||||
header={<FormSectionLabel>Multi Factor Authentication (MFA)</FormSectionLabel>}
|
||||
>
|
||||
<FormSectionContent loading={!isLoaded}>
|
||||
<FormSectionContent loading={isLoading}>
|
||||
<InputNumber
|
||||
id="MFA_MAX_ENROLLED_FACTORS"
|
||||
size="small"
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import { Button, IconExternalLink, Tabs } from 'ui'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import {
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
Alert_Shadcn_,
|
||||
Button,
|
||||
IconAlertCircle,
|
||||
IconExternalLink,
|
||||
Tabs,
|
||||
} from 'ui'
|
||||
|
||||
import { TEMPLATES_SCHEMAS } from 'stores/authConfig/schema'
|
||||
import { useParams } from 'common'
|
||||
import { FormHeader, FormPanel } from 'components/ui/Forms'
|
||||
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
|
||||
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
|
||||
import { TEMPLATES_SCHEMAS } from 'stores/authConfig/schema'
|
||||
import TemplateEditor from './TemplateEditor'
|
||||
|
||||
const EmailTemplates = observer(() => {
|
||||
const EmailTemplates = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const {
|
||||
data: authConfig,
|
||||
error: authConfigError,
|
||||
isLoading,
|
||||
isError,
|
||||
isSuccess,
|
||||
} = useAuthConfigQuery({ projectRef })
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
@@ -25,26 +44,40 @@ const EmailTemplates = observer(() => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<FormPanel>
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
listClassNames="px-8 pt-4"
|
||||
defaultActiveId={TEMPLATES_SCHEMAS[0].title.trim().replace(/\s+/g, '-')}
|
||||
>
|
||||
{TEMPLATES_SCHEMAS.map((template) => {
|
||||
const panelId = template.title.trim().replace(/\s+/g, '-')
|
||||
return (
|
||||
<Tabs.Panel id={panelId} label={template.title} key={panelId}>
|
||||
<TemplateEditor key={template.title} template={template} />
|
||||
</Tabs.Panel>
|
||||
)
|
||||
})}
|
||||
</Tabs>
|
||||
</FormPanel>
|
||||
{isError && (
|
||||
<Alert_Shadcn_ variant="destructive">
|
||||
<IconAlertCircle strokeWidth={2} />
|
||||
<AlertTitle_Shadcn_>Failed to retrieve auth configuration</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>{authConfigError.message}</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
{isLoading && <GenericSkeletonLoader />}
|
||||
{isSuccess && (
|
||||
<FormPanel>
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
listClassNames="px-8 pt-4"
|
||||
defaultActiveId={TEMPLATES_SCHEMAS[0].title.trim().replace(/\s+/g, '-')}
|
||||
>
|
||||
{TEMPLATES_SCHEMAS.map((template) => {
|
||||
const panelId = template.title.trim().replace(/\s+/g, '-')
|
||||
return (
|
||||
<Tabs.Panel id={panelId} label={template.title} key={panelId}>
|
||||
<TemplateEditor
|
||||
key={template.title}
|
||||
template={template}
|
||||
authConfig={authConfig as any}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
)
|
||||
})}
|
||||
</Tabs>
|
||||
</FormPanel>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export default EmailTemplates
|
||||
@@ -1,42 +1,45 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { Form, Input } from 'ui'
|
||||
|
||||
import CodeEditor from 'components/ui/CodeEditor'
|
||||
import { FormActions, FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms'
|
||||
import InformationBox from 'components/ui/InformationBox'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
|
||||
import { useCheckPermissions, useStore } from 'hooks'
|
||||
import { FormSchema } from 'types'
|
||||
|
||||
interface TemplateEditorProps {
|
||||
template: FormSchema
|
||||
authConfig: Record<string, string>
|
||||
}
|
||||
|
||||
const TemplateEditor = ({ template }: TemplateEditorProps) => {
|
||||
const { ui, authConfig } = useStore()
|
||||
const [bodyValue, setBodyValue] = useState('')
|
||||
const TemplateEditor = ({ template, authConfig }: TemplateEditorProps) => {
|
||||
const { ui } = useStore()
|
||||
const { ref: projectRef } = useParams()
|
||||
const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation()
|
||||
|
||||
const { isLoaded } = authConfig
|
||||
const { id, properties } = template
|
||||
|
||||
const formId = `auth-config-email-templates-${id}`
|
||||
const INITIAL_VALUES: { [x: string]: string } = {}
|
||||
const INITIAL_VALUES = useMemo(() => {
|
||||
const result: { [x: string]: string } = {}
|
||||
Object.keys(properties).forEach((key) => {
|
||||
result[key] = (authConfig && authConfig[key]) ?? ''
|
||||
})
|
||||
return result
|
||||
}, [authConfig, properties])
|
||||
const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue')
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoaded) {
|
||||
Object.keys(properties).forEach((key) => {
|
||||
INITIAL_VALUES[key] = authConfig.config[key] ?? ''
|
||||
})
|
||||
}
|
||||
}, [isLoaded])
|
||||
|
||||
const messageSlug = `MAILER_TEMPLATES_${id}_CONTENT`
|
||||
const messageProperty = properties[messageSlug]
|
||||
const [bodyValue, setBodyValue] = useState((authConfig && authConfig[messageSlug]) ?? '')
|
||||
|
||||
const onSubmit = async (values: any, { setSubmitting, resetForm }: any) => {
|
||||
const onSubmit = (values: any, { resetForm }: any) => {
|
||||
const payload = { ...values }
|
||||
|
||||
// Because the template content uses the code editor which is not a form component
|
||||
@@ -44,46 +47,34 @@ const TemplateEditor = ({ template }: TemplateEditorProps) => {
|
||||
delete payload[messageSlug]
|
||||
if (messageProperty) payload[messageSlug] = bodyValue
|
||||
|
||||
setSubmitting(true)
|
||||
const { error } = await authConfig.update(payload)
|
||||
|
||||
if (!error) {
|
||||
ui.setNotification({ category: 'success', message: 'Successfully updated settings' })
|
||||
resetForm({
|
||||
values: values,
|
||||
initialValues: values,
|
||||
})
|
||||
setBodyValue(authConfig?.config?.[messageSlug])
|
||||
} else {
|
||||
ui.setNotification({ category: 'error', message: 'Failed to update settings' })
|
||||
}
|
||||
|
||||
setSubmitting(false)
|
||||
updateAuthConfig(
|
||||
{ projectRef: projectRef!, config: payload },
|
||||
{
|
||||
onError: () => {
|
||||
ui.setNotification({ category: 'error', message: 'Failed to update settings' })
|
||||
},
|
||||
onSuccess: () => {
|
||||
ui.setNotification({ category: 'success', message: 'Successfully updated settings' })
|
||||
resetForm({
|
||||
values: values,
|
||||
initialValues: values,
|
||||
})
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Form id={formId} className="!border-t-0" initialValues={INITIAL_VALUES} onSubmit={onSubmit}>
|
||||
{({ isSubmitting, resetForm, values, initialValues }: any) => {
|
||||
// [Alaister] although this "technically" is breaking the rules of React hooks
|
||||
// it won't error because the hooks are always rendered in the same order
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useEffect(() => {
|
||||
// Form is reset once remote data is loaded in store
|
||||
resetForm({
|
||||
values: INITIAL_VALUES,
|
||||
initialValues: INITIAL_VALUES,
|
||||
})
|
||||
setBodyValue(authConfig?.config?.[messageSlug])
|
||||
}, [authConfig.isLoaded])
|
||||
|
||||
const message = authConfig?.config?.[messageSlug]
|
||||
{({ resetForm, values, initialValues }: any) => {
|
||||
const message = (authConfig && authConfig[messageSlug]) ?? ''
|
||||
const hasChanges =
|
||||
JSON.stringify(values) !== JSON.stringify(initialValues) || message !== bodyValue
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormSection className="!border-t-0 pb-8 pt-4">
|
||||
<FormSectionContent loading={!isLoaded}>
|
||||
<FormSectionContent loading={false}>
|
||||
{Object.keys(properties).map((x: string) => {
|
||||
const property = properties[x]
|
||||
if (property.type === 'string') {
|
||||
@@ -121,7 +112,7 @@ const TemplateEditor = ({ template }: TemplateEditorProps) => {
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
<FormSection className="!mt-0 grid-cols-12 !border-t-0 !pt-0">
|
||||
<FormSectionContent fullWidth loading={!isLoaded}>
|
||||
<FormSectionContent fullWidth loading={false}>
|
||||
{messageProperty && (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
@@ -142,7 +133,6 @@ const TemplateEditor = ({ template }: TemplateEditorProps) => {
|
||||
<div className="relative h-96">
|
||||
<CodeEditor
|
||||
id="code-id"
|
||||
loading={!isLoaded}
|
||||
language="html"
|
||||
isReadOnly={!canUpdateConfig}
|
||||
className="!mb-0 h-96 overflow-hidden rounded border"
|
||||
@@ -157,13 +147,13 @@ const TemplateEditor = ({ template }: TemplateEditorProps) => {
|
||||
<FormActions
|
||||
handleReset={() => {
|
||||
resetForm({
|
||||
values: authConfig.config,
|
||||
initialValues: authConfig.config,
|
||||
values: authConfig,
|
||||
initialValues: authConfig,
|
||||
})
|
||||
setBodyValue(authConfig?.config?.[messageSlug])
|
||||
setBodyValue((authConfig && authConfig[messageSlug]) ?? '')
|
||||
}}
|
||||
form={formId}
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitting={isUpdatingConfig}
|
||||
hasChanges={hasChanges}
|
||||
disabled={!canUpdateConfig}
|
||||
helper={
|
||||
|
||||
@@ -1,37 +1,24 @@
|
||||
import { noop } from 'lodash'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { Button, IconGlobe, IconTrash } from 'ui'
|
||||
|
||||
import { HorizontalShimmerWithIcon } from 'components/ui/Shimmers'
|
||||
import { EmptyListState } from 'components/ui/States'
|
||||
import { useStore } from 'hooks'
|
||||
import ValueContainer from './ValueContainer'
|
||||
|
||||
interface RedirectUrlListProps {
|
||||
URI_ALLOW_LIST_ARRAY: string[]
|
||||
canUpdate: boolean
|
||||
onSelectUrlToDelete: (url: string) => void
|
||||
}
|
||||
|
||||
const RedirectUrlList = ({ canUpdate, onSelectUrlToDelete = noop }: RedirectUrlListProps) => {
|
||||
const { authConfig } = useStore()
|
||||
|
||||
const URI_ALLOW_LIST_ARRAY = authConfig.config.URI_ALLOW_LIST
|
||||
? authConfig.config.URI_ALLOW_LIST.split(',')
|
||||
: []
|
||||
|
||||
const RedirectUrlList = ({
|
||||
URI_ALLOW_LIST_ARRAY,
|
||||
canUpdate,
|
||||
onSelectUrlToDelete = noop,
|
||||
}: RedirectUrlListProps) => {
|
||||
return (
|
||||
<div className="-space-y-px">
|
||||
{!authConfig.isLoaded ? (
|
||||
<>
|
||||
<ValueContainer>
|
||||
<HorizontalShimmerWithIcon />
|
||||
</ValueContainer>
|
||||
<ValueContainer>
|
||||
<HorizontalShimmerWithIcon />
|
||||
</ValueContainer>
|
||||
</>
|
||||
) : URI_ALLOW_LIST_ARRAY.length > 0 ? (
|
||||
URI_ALLOW_LIST_ARRAY.map((url: string) => {
|
||||
{URI_ALLOW_LIST_ARRAY.length > 0 ? (
|
||||
URI_ALLOW_LIST_ARRAY.map((url) => {
|
||||
return (
|
||||
<ValueContainer key={url}>
|
||||
<div className="flex items-center gap-4 font-mono">
|
||||
@@ -69,4 +56,4 @@ const RedirectUrlList = ({ canUpdate, onSelectUrlToDelete = noop }: RedirectUrlL
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(RedirectUrlList)
|
||||
export default RedirectUrlList
|
||||
@@ -1,24 +1,48 @@
|
||||
import { useState } from 'react'
|
||||
import { object, string } from 'yup'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { Button, Form, Input, Modal } from 'ui'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
Alert_Shadcn_,
|
||||
Button,
|
||||
Form,
|
||||
IconAlertCircle,
|
||||
Input,
|
||||
Modal,
|
||||
} from 'ui'
|
||||
import { object, string } from 'yup'
|
||||
|
||||
import { useCheckPermissions, useStore } from 'hooks'
|
||||
import { useParams } from 'common'
|
||||
import { FormHeader } from 'components/ui/Forms'
|
||||
import { HorizontalShimmerWithIcon } from 'components/ui/Shimmers'
|
||||
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
|
||||
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
|
||||
import { useCheckPermissions, useStore } from 'hooks'
|
||||
import { urlRegex } from '../Auth.constants'
|
||||
import RedirectUrlList from './RedirectUrlList'
|
||||
import ValueContainer from './ValueContainer'
|
||||
|
||||
const RedirectUrls = () => {
|
||||
const { authConfig, ui } = useStore()
|
||||
const { ui } = useStore()
|
||||
const { ref: projectRef } = useParams()
|
||||
const {
|
||||
data: authConfig,
|
||||
error: authConfigError,
|
||||
isLoading,
|
||||
isError,
|
||||
isSuccess,
|
||||
} = useAuthConfigQuery({ projectRef })
|
||||
const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation()
|
||||
|
||||
const URI_ALLOW_LIST_ARRAY = authConfig.config.URI_ALLOW_LIST
|
||||
? authConfig.config.URI_ALLOW_LIST.split(/\s*[,]+\s*/).filter((url: string) => url)
|
||||
: []
|
||||
const URI_ALLOW_LIST_ARRAY = useMemo(() => {
|
||||
return authConfig?.URI_ALLOW_LIST
|
||||
? authConfig.URI_ALLOW_LIST.split(/\s*[,]+\s*/).filter((url: string) => url)
|
||||
: []
|
||||
}, [authConfig?.URI_ALLOW_LIST])
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [selectedUrlToDelete, setSelectedUrlToDelete] = useState<string>()
|
||||
|
||||
const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue')
|
||||
@@ -27,12 +51,11 @@ const RedirectUrls = () => {
|
||||
url: string().matches(urlRegex, 'URL is not valid').required(),
|
||||
})
|
||||
|
||||
const onAddNewUrl = async (values: any, { setSubmitting }: any) => {
|
||||
const onAddNewUrl = async (values: any) => {
|
||||
if (!values.url) {
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
const payload = URI_ALLOW_LIST_ARRAY
|
||||
// remove any trailing commas
|
||||
payload.push(values.url.replace(/,\s*$/, ''))
|
||||
@@ -44,48 +67,49 @@ const RedirectUrls = () => {
|
||||
message: 'Too many redirect URLs, please remove some or try to use wildcards',
|
||||
category: 'error',
|
||||
})
|
||||
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
const { error } = await authConfig.update({ URI_ALLOW_LIST: payloadString })
|
||||
if (!error) {
|
||||
setOpen(false)
|
||||
ui.setNotification({ category: 'success', message: 'Successfully added URL' })
|
||||
} else {
|
||||
ui.setNotification({
|
||||
error,
|
||||
category: 'error',
|
||||
message: `Failed to update URL: ${error?.message}`,
|
||||
})
|
||||
}
|
||||
|
||||
setSubmitting(false)
|
||||
updateAuthConfig(
|
||||
{ projectRef: projectRef!, config: { URI_ALLOW_LIST: payloadString } },
|
||||
{
|
||||
onError: (error) => {
|
||||
ui.setNotification({
|
||||
error,
|
||||
category: 'error',
|
||||
message: `Failed to update URL: ${error?.message}`,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
setOpen(false)
|
||||
ui.setNotification({ category: 'success', message: 'Successfully added URL' })
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const onConfirmDeleteUrl = async (url?: string) => {
|
||||
if (!url) return
|
||||
|
||||
setIsDeleting(true)
|
||||
|
||||
// Remove selectedUrl from array and update
|
||||
const payload = URI_ALLOW_LIST_ARRAY.filter((e: string) => e !== url)
|
||||
|
||||
const { error } = await authConfig.update({ URI_ALLOW_LIST: payload.toString() })
|
||||
|
||||
if (!error) {
|
||||
setSelectedUrlToDelete(undefined)
|
||||
ui.setNotification({ category: 'success', message: 'Successfully removed URL' })
|
||||
} else {
|
||||
ui.setNotification({
|
||||
error,
|
||||
category: 'error',
|
||||
message: `Failed to remove URL: ${error?.message}`,
|
||||
})
|
||||
}
|
||||
|
||||
setIsDeleting(false)
|
||||
updateAuthConfig(
|
||||
{ projectRef: projectRef!, config: { URI_ALLOW_LIST: payload.toString() } },
|
||||
{
|
||||
onError: (error) => {
|
||||
ui.setNotification({
|
||||
error,
|
||||
category: 'error',
|
||||
message: `Failed to remove URL: ${error?.message}`,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
setSelectedUrlToDelete(undefined)
|
||||
ui.setNotification({ category: 'success', message: 'Successfully removed URL' })
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -120,7 +144,30 @@ const RedirectUrls = () => {
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<RedirectUrlList canUpdate={canUpdateConfig} onSelectUrlToDelete={setSelectedUrlToDelete} />
|
||||
{isLoading && (
|
||||
<>
|
||||
<ValueContainer>
|
||||
<HorizontalShimmerWithIcon />
|
||||
</ValueContainer>
|
||||
<ValueContainer>
|
||||
<HorizontalShimmerWithIcon />
|
||||
</ValueContainer>
|
||||
</>
|
||||
)}
|
||||
{isError && (
|
||||
<Alert_Shadcn_ variant="destructive">
|
||||
<IconAlertCircle strokeWidth={2} />
|
||||
<AlertTitle_Shadcn_>Failed to retrieve auth configuration</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>{authConfigError.message}</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
{isSuccess && (
|
||||
<RedirectUrlList
|
||||
URI_ALLOW_LIST_ARRAY={URI_ALLOW_LIST_ARRAY}
|
||||
canUpdate={canUpdateConfig}
|
||||
onSelectUrlToDelete={setSelectedUrlToDelete}
|
||||
/>
|
||||
)}
|
||||
<Modal
|
||||
hideFooter
|
||||
size="small"
|
||||
@@ -135,7 +182,7 @@ const RedirectUrls = () => {
|
||||
validationSchema={newUrlSchema}
|
||||
onSubmit={onAddNewUrl}
|
||||
>
|
||||
{({ isSubmitting }: { isSubmitting: boolean }) => {
|
||||
{() => {
|
||||
return (
|
||||
<div className="mb-4 space-y-4 pt-4">
|
||||
<div className="px-5">
|
||||
@@ -155,8 +202,8 @@ const RedirectUrls = () => {
|
||||
form="new-redirect-url-form"
|
||||
htmlType="submit"
|
||||
size="medium"
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
disabled={isUpdatingConfig}
|
||||
loading={isUpdatingConfig}
|
||||
>
|
||||
Add URL
|
||||
</Button>
|
||||
@@ -197,10 +244,10 @@ const RedirectUrls = () => {
|
||||
block
|
||||
size="medium"
|
||||
type="warning"
|
||||
loading={isDeleting}
|
||||
loading={isUpdatingConfig}
|
||||
onClick={() => onConfirmDeleteUrl(selectedUrlToDelete)}
|
||||
>
|
||||
{isDeleting ? 'Removing...' : 'Remove URL'}
|
||||
{isUpdatingConfig ? 'Removing...' : 'Remove URL'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { useEffect } from 'react'
|
||||
import { Form, Input } from 'ui'
|
||||
import {
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
Alert_Shadcn_,
|
||||
Form,
|
||||
IconAlertCircle,
|
||||
Input,
|
||||
} from 'ui'
|
||||
import { boolean, number, object, string } from 'yup'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import {
|
||||
FormActions,
|
||||
FormHeader,
|
||||
@@ -11,75 +19,98 @@ import {
|
||||
FormSection,
|
||||
FormSectionContent,
|
||||
} from 'components/ui/Forms'
|
||||
import { useCheckPermissions, useSelectedProject, useStore } from 'hooks'
|
||||
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
|
||||
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
|
||||
import { useCheckPermissions, useStore } from 'hooks'
|
||||
|
||||
const schema = object({
|
||||
DISABLE_SIGNUP: boolean().required(),
|
||||
SITE_URL: string().required('Must have a Site URL'),
|
||||
JWT_EXP: number()
|
||||
.max(604800, 'Must be less than 604800')
|
||||
.required('Must have a JWT expiry value'),
|
||||
REFRESH_TOKEN_ROTATION_ENABLED: boolean().required(),
|
||||
SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: number()
|
||||
.min(0, 'Must be a value more than 0')
|
||||
.required('Must have a Reuse Interval value'),
|
||||
SECURITY_CAPTCHA_ENABLED: boolean().required(),
|
||||
SECURITY_CAPTCHA_SECRET: string().when('SECURITY_CAPTCHA_ENABLED', {
|
||||
is: true,
|
||||
then: string().required('Must have a Captcha secret'),
|
||||
}),
|
||||
})
|
||||
|
||||
const SiteUrl = observer(() => {
|
||||
const { authConfig, ui } = useStore()
|
||||
const { isLoaded } = authConfig
|
||||
const { ui } = useStore()
|
||||
const { ref: projectRef } = useParams()
|
||||
const {
|
||||
data: authConfig,
|
||||
error: authConfigError,
|
||||
isLoading,
|
||||
isError,
|
||||
isSuccess,
|
||||
} = useAuthConfigQuery({ projectRef })
|
||||
const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation()
|
||||
|
||||
const formId = 'auth-config-general-form'
|
||||
const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue')
|
||||
|
||||
const INITIAL_VALUES = {
|
||||
DISABLE_SIGNUP: !authConfig.config.DISABLE_SIGNUP,
|
||||
JWT_EXP: authConfig.config.JWT_EXP,
|
||||
SITE_URL: authConfig.config.SITE_URL,
|
||||
REFRESH_TOKEN_ROTATION_ENABLED: authConfig.config.REFRESH_TOKEN_ROTATION_ENABLED || false,
|
||||
SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: authConfig.config.SECURITY_REFRESH_TOKEN_REUSE_INTERVAL,
|
||||
SECURITY_CAPTCHA_ENABLED: authConfig.config.SECURITY_CAPTCHA_ENABLED || false,
|
||||
SECURITY_CAPTCHA_PROVIDER: authConfig.config.SECURITY_CAPTCHA_PROVIDER || 'hcaptcha',
|
||||
SECURITY_CAPTCHA_SECRET: authConfig.config.SECURITY_CAPTCHA_SECRET || '',
|
||||
DISABLE_SIGNUP: authConfig?.DISABLE_SIGNUP,
|
||||
JWT_EXP: authConfig?.JWT_EXP,
|
||||
SITE_URL: authConfig?.SITE_URL,
|
||||
REFRESH_TOKEN_ROTATION_ENABLED: authConfig?.REFRESH_TOKEN_ROTATION_ENABLED || false,
|
||||
SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: authConfig?.SECURITY_REFRESH_TOKEN_REUSE_INTERVAL,
|
||||
SECURITY_CAPTCHA_ENABLED: authConfig?.SECURITY_CAPTCHA_ENABLED || false,
|
||||
SECURITY_CAPTCHA_PROVIDER: authConfig?.SECURITY_CAPTCHA_PROVIDER || 'hcaptcha',
|
||||
SECURITY_CAPTCHA_SECRET: authConfig?.SECURITY_CAPTCHA_SECRET || '',
|
||||
}
|
||||
|
||||
const schema = object({
|
||||
DISABLE_SIGNUP: boolean().required(),
|
||||
SITE_URL: string().required('Must have a Site URL'),
|
||||
JWT_EXP: number()
|
||||
.max(604800, 'Must be less than 604800')
|
||||
.required('Must have a JWT expiry value'),
|
||||
REFRESH_TOKEN_ROTATION_ENABLED: boolean().required(),
|
||||
SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: number()
|
||||
.min(0, 'Must be a value more than 0')
|
||||
.required('Must have a Reuse Interval value'),
|
||||
SECURITY_CAPTCHA_ENABLED: boolean().required(),
|
||||
SECURITY_CAPTCHA_SECRET: string().when('SECURITY_CAPTCHA_ENABLED', {
|
||||
is: true,
|
||||
then: string().required('Must have a Captcha secret'),
|
||||
}),
|
||||
})
|
||||
|
||||
const onSubmit = async (values: any, { setSubmitting, resetForm }: any) => {
|
||||
const onSubmit = (values: any, { resetForm }: any) => {
|
||||
const payload = { ...values }
|
||||
payload.DISABLE_SIGNUP = !values.DISABLE_SIGNUP
|
||||
|
||||
setSubmitting(true)
|
||||
const { error } = await authConfig.update(payload)
|
||||
updateAuthConfig(
|
||||
{ projectRef: projectRef!, config: payload },
|
||||
{
|
||||
onError: () => {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: `Failed to update settings`,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
message: `Successfully updated settings`,
|
||||
})
|
||||
resetForm({ values: values, initialValues: values })
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (!error) {
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
message: `Successfully updated settings`,
|
||||
})
|
||||
resetForm({ values: values, initialValues: values })
|
||||
} else {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: `Failed to update settings`,
|
||||
})
|
||||
}
|
||||
|
||||
setSubmitting(false)
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert_Shadcn_ variant="destructive">
|
||||
<IconAlertCircle strokeWidth={2} />
|
||||
<AlertTitle_Shadcn_>Failed to retrieve auth configuration</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>{authConfigError.message}</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Form id={formId} initialValues={INITIAL_VALUES} onSubmit={onSubmit} validationSchema={schema}>
|
||||
{({ isSubmitting, handleReset, resetForm, values, initialValues }: any) => {
|
||||
{({ handleReset, resetForm, values, initialValues }: any) => {
|
||||
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
|
||||
|
||||
// Form is reset once remote data is loaded in store
|
||||
useEffect(() => {
|
||||
resetForm({ values: INITIAL_VALUES, initialValues: INITIAL_VALUES })
|
||||
}, [authConfig.isLoaded])
|
||||
if (isSuccess) {
|
||||
resetForm({ values: INITIAL_VALUES, initialValues: INITIAL_VALUES })
|
||||
}
|
||||
}, [isSuccess])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -93,7 +124,7 @@ const SiteUrl = observer(() => {
|
||||
<div className="flex py-4 px-8">
|
||||
<FormActions
|
||||
form={formId}
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitting={isUpdatingConfig}
|
||||
hasChanges={hasChanges}
|
||||
handleReset={handleReset}
|
||||
disabled={!canUpdateConfig}
|
||||
@@ -107,7 +138,7 @@ const SiteUrl = observer(() => {
|
||||
}
|
||||
>
|
||||
<FormSection>
|
||||
<FormSectionContent loading={!isLoaded}>
|
||||
<FormSectionContent loading={isLoading}>
|
||||
<Input id="SITE_URL" size="small" label="Site URL" disabled={!canUpdateConfig} />
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Alert_Shadcn_,
|
||||
Button,
|
||||
Form,
|
||||
IconAlertCircle,
|
||||
IconAlertTriangle,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
} from 'ui'
|
||||
import { number, object, string } from 'yup'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import {
|
||||
FormActions,
|
||||
FormHeader,
|
||||
@@ -25,27 +27,37 @@ import {
|
||||
FormSectionContent,
|
||||
FormSectionLabel,
|
||||
} from 'components/ui/Forms'
|
||||
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
|
||||
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
|
||||
import { useCheckPermissions, useStore } from 'hooks'
|
||||
import { urlRegex } from './../Auth.constants'
|
||||
import { defaultDisabledSmtpFormValues } from './SmtpForm.constants'
|
||||
import { generateFormValues, isSmtpEnabled } from './SmtpForm.utils'
|
||||
|
||||
const SmtpForm = () => {
|
||||
const { authConfig, ui } = useStore()
|
||||
const { config, isLoaded } = authConfig
|
||||
const { ui } = useStore()
|
||||
const { ref: projectRef } = useParams()
|
||||
const {
|
||||
data: authConfig,
|
||||
error: authConfigError,
|
||||
isLoading,
|
||||
isError,
|
||||
isSuccess,
|
||||
} = useAuthConfigQuery({ projectRef })
|
||||
const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation()
|
||||
|
||||
const [enableSmtp, setEnableSmtp] = useState(false)
|
||||
const [hidden, setHidden] = useState(true)
|
||||
|
||||
const formId = 'auth-config-smtp-form'
|
||||
const initialValues = generateFormValues(authConfig.config)
|
||||
const initialValues = generateFormValues(authConfig)
|
||||
const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue')
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoaded && isSmtpEnabled(config)) {
|
||||
if (isSuccess && isSmtpEnabled(authConfig)) {
|
||||
setEnableSmtp(true)
|
||||
}
|
||||
}, [isLoaded, config])
|
||||
}, [isSuccess, authConfig])
|
||||
|
||||
const schema = object({
|
||||
SMTP_ADMIN_EMAIL: string().when([], {
|
||||
@@ -110,31 +122,42 @@ const SmtpForm = () => {
|
||||
}),
|
||||
})
|
||||
|
||||
const onSubmit = async (values: any, { setSubmitting, resetForm }: any) => {
|
||||
const onSubmit = (values: any, { resetForm }: any) => {
|
||||
const payload = enableSmtp ? values : defaultDisabledSmtpFormValues
|
||||
|
||||
// Format payload: Remove redundant value + convert port to string
|
||||
delete payload.ENABLE_SMTP
|
||||
payload.SMTP_PORT = payload.SMTP_PORT ? payload.SMTP_PORT.toString() : payload.SMTP_PORT
|
||||
|
||||
setSubmitting(true)
|
||||
const { error } = await authConfig.update(payload)
|
||||
updateAuthConfig(
|
||||
{ projectRef: projectRef!, config: payload },
|
||||
{
|
||||
onError: (error) => {
|
||||
ui.setNotification({ category: 'error', message: 'Failed to update settings', error })
|
||||
},
|
||||
onSuccess: () => {
|
||||
setHidden(true)
|
||||
const updatedFormValues = generateFormValues(payload)
|
||||
resetForm({ values: updatedFormValues, initialValues: updatedFormValues })
|
||||
ui.setNotification({ category: 'success', message: 'Successfully updated settings' })
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (!error) {
|
||||
setHidden(true)
|
||||
const updatedFormValues = generateFormValues(payload)
|
||||
resetForm({ values: updatedFormValues, initialValues: updatedFormValues })
|
||||
ui.setNotification({ category: 'success', message: 'Successfully updated settings' })
|
||||
} else {
|
||||
ui.setNotification({ category: 'error', message: 'Failed to update settings', error })
|
||||
}
|
||||
|
||||
setSubmitting(false)
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert_Shadcn_ variant="destructive">
|
||||
<IconAlertCircle strokeWidth={2} />
|
||||
<AlertTitle_Shadcn_>Failed to retrieve auth configuration</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>{authConfigError.message}</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Form id={formId} initialValues={initialValues} onSubmit={onSubmit} validationSchema={schema}>
|
||||
{({ isSubmitting, resetForm, values }: any) => {
|
||||
{({ resetForm, values }: any) => {
|
||||
const isValidSmtpConfig = isSmtpEnabled(values)
|
||||
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
|
||||
|
||||
@@ -142,11 +165,11 @@ const SmtpForm = () => {
|
||||
// it won't error because the hooks are always rendered in the same order
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useEffect(() => {
|
||||
if (isLoaded) {
|
||||
const formValues = generateFormValues(config)
|
||||
if (isSuccess) {
|
||||
const formValues = generateFormValues(authConfig)
|
||||
resetForm({ values: formValues, initialValues: formValues })
|
||||
}
|
||||
}, [isLoaded, config])
|
||||
}, [isSuccess, authConfig])
|
||||
|
||||
const onResetForm = () => {
|
||||
setEnableSmtp(isSmtpEnabled(initialValues))
|
||||
@@ -164,7 +187,7 @@ const SmtpForm = () => {
|
||||
<div className="flex py-4 px-8">
|
||||
<FormActions
|
||||
form={formId}
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitting={isUpdatingConfig}
|
||||
hasChanges={hasChanges}
|
||||
handleReset={onResetForm}
|
||||
disabled={!canUpdateConfig}
|
||||
@@ -178,7 +201,7 @@ const SmtpForm = () => {
|
||||
}
|
||||
>
|
||||
<FormSection>
|
||||
<FormSectionContent loading={!isLoaded}>
|
||||
<FormSectionContent loading={isLoading}>
|
||||
<Toggle
|
||||
name="ENABLE_SMTP"
|
||||
size="small"
|
||||
@@ -217,7 +240,7 @@ const SmtpForm = () => {
|
||||
<a
|
||||
href="https://supabase.com/docs/guides/platform/going-into-prod#auth-rate-limits"
|
||||
className="underline"
|
||||
target="_blank"
|
||||
target="_blank"
|
||||
>
|
||||
documentation
|
||||
</a>{' '}
|
||||
@@ -233,7 +256,7 @@ const SmtpForm = () => {
|
||||
header={<FormSectionLabel>Sender details</FormSectionLabel>}
|
||||
disabled={!enableSmtp}
|
||||
>
|
||||
<FormSectionContent loading={!isLoaded}>
|
||||
<FormSectionContent loading={isLoading}>
|
||||
<Input
|
||||
name="SMTP_ADMIN_EMAIL"
|
||||
id="SMTP_ADMIN_EMAIL"
|
||||
@@ -265,7 +288,7 @@ const SmtpForm = () => {
|
||||
</FormSectionLabel>
|
||||
}
|
||||
>
|
||||
<FormSectionContent loading={!isLoaded}>
|
||||
<FormSectionContent loading={isLoading}>
|
||||
<Input
|
||||
name="SMTP_HOST"
|
||||
placeholder="your.smtp.host.com"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { AuthConfig } from '../Auth.types'
|
||||
import { components } from 'data/api'
|
||||
|
||||
type AuthConfig = components['schemas']['GoTrueConfig']
|
||||
|
||||
interface AuthConfigForm extends AuthConfig {
|
||||
ENABLE_SMTP: boolean
|
||||
@@ -24,7 +26,7 @@ export const generateFormValues = (config?: Partial<AuthConfig>): Partial<AuthCo
|
||||
SMTP_USER: config?.SMTP_USER ?? '',
|
||||
SMTP_HOST: config?.SMTP_HOST ?? '',
|
||||
SMTP_PASS: config?.SMTP_PASS ?? '',
|
||||
SMTP_PORT: config?.SMTP_PORT ?? 465,
|
||||
SMTP_PORT: config?.SMTP_PORT ?? '465',
|
||||
SMTP_MAX_FREQUENCY: config?.SMTP_MAX_FREQUENCY ?? 60,
|
||||
RATE_LIMIT_EMAIL_SENT: config?.RATE_LIMIT_EMAIL_SENT ?? 30,
|
||||
}
|
||||
|
||||
+31
-31
@@ -1,14 +1,14 @@
|
||||
import HCaptcha from '@hcaptcha/react-hcaptcha'
|
||||
import { Elements } from '@stripe/react-stripe-js'
|
||||
import { loadStripe } from '@stripe/stripe-js'
|
||||
import { FC, useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { useTheme } from 'common'
|
||||
import { useSelectedOrganization, useStore } from 'hooks'
|
||||
import { post } from 'lib/common/fetch'
|
||||
import { API_URL, STRIPE_PUBLIC_KEY } from 'lib/constants'
|
||||
import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Modal } from 'ui'
|
||||
|
||||
import { useOrganizationPaymentMethodSetupIntent } from 'data/organizations/organization-payment-method-setup-intent-mutation'
|
||||
import { useSelectedOrganization, useStore } from 'hooks'
|
||||
import { STRIPE_PUBLIC_KEY } from 'lib/constants'
|
||||
import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store'
|
||||
import AddPaymentMethodForm from './AddPaymentMethodForm'
|
||||
|
||||
interface AddNewPaymentMethodModalProps {
|
||||
@@ -27,18 +27,41 @@ const AddNewPaymentMethodModal = ({
|
||||
onConfirm,
|
||||
}: AddNewPaymentMethodModalProps) => {
|
||||
const { ui } = useStore()
|
||||
const { isDarkMode } = 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)
|
||||
|
||||
const { mutate: setupIntent } = useOrganizationPaymentMethodSetupIntent({
|
||||
onSuccess: (intent) => {
|
||||
setIntent(intent)
|
||||
},
|
||||
onError: (error) => {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
error: intent.error,
|
||||
message: `Failed to setup intent: ${error.message}`,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const captchaRefCallback = useCallback((node) => {
|
||||
setCaptchaRef(node)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const initSetupIntent = async (hcaptchaToken: string | undefined) => {
|
||||
const slug = selectedOrganization?.slug
|
||||
if (!slug) return console.error('Slug is required')
|
||||
if (!hcaptchaToken) return console.error('HCaptcha token required')
|
||||
|
||||
setIntent(undefined)
|
||||
setupIntent({ slug, hcaptchaToken })
|
||||
}
|
||||
|
||||
const loadPaymentForm = async () => {
|
||||
if (visible && captchaRef && captchaLoaded) {
|
||||
let token = captchaToken
|
||||
@@ -52,7 +75,7 @@ const AddNewPaymentMethodModal = ({
|
||||
return
|
||||
}
|
||||
|
||||
await setupIntent(token ?? undefined)
|
||||
await initSetupIntent(token ?? undefined)
|
||||
resetCaptcha()
|
||||
}
|
||||
}
|
||||
@@ -65,29 +88,6 @@ const AddNewPaymentMethodModal = ({
|
||||
captchaRef?.resetCaptcha()
|
||||
}
|
||||
|
||||
const selectedOrganization = useSelectedOrganization()
|
||||
|
||||
const setupIntent = async (hcaptchaToken: string | undefined) => {
|
||||
setIntent(undefined)
|
||||
|
||||
const orgSlug = selectedOrganization?.slug ?? ''
|
||||
const intent = await post(`${API_URL}/organizations/${orgSlug}/payments/setup-intent`, {
|
||||
hcaptchaToken,
|
||||
})
|
||||
|
||||
if (intent.error) {
|
||||
return ui.setNotification({
|
||||
category: 'error',
|
||||
error: intent.error,
|
||||
message: `Failed to setup intent: ${intent.error.message}`,
|
||||
})
|
||||
} else {
|
||||
setIntent(intent)
|
||||
}
|
||||
}
|
||||
|
||||
const { isDarkMode } = useTheme()
|
||||
|
||||
const options = {
|
||||
clientSecret: intent ? intent.client_secret : '',
|
||||
appearance: { theme: isDarkMode ? 'night' : 'flat', labels: 'floating' },
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Modal } from 'ui'
|
||||
import { useStripe, useElements, PaymentElement } from '@stripe/react-stripe-js'
|
||||
import { useStore } from 'hooks'
|
||||
|
||||
interface AddNewPaymentMethodFormProps {
|
||||
returnUrl: string
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
// [Joshen] Directly brought over from old Billing folder, so we can deprecate that folder easily next time
|
||||
|
||||
// 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 AddNewPaymentMethodForm = ({
|
||||
returnUrl,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: AddNewPaymentMethodFormProps) => {
|
||||
const { ui } = useStore()
|
||||
const stripe = useStripe()
|
||||
const elements = useElements()
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const handleSubmit = async (event: any) => {
|
||||
event.preventDefault()
|
||||
|
||||
if (!stripe || !elements) {
|
||||
console.error('Stripe.js has not loaded')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
|
||||
if (document !== undefined) {
|
||||
// [Joshen] This is to ensure that any 3DS popup from Stripe remains clickable
|
||||
document.body.classList.add('!pointer-events-auto')
|
||||
}
|
||||
|
||||
const { error } = await stripe.confirmSetup({
|
||||
elements,
|
||||
redirect: 'if_required',
|
||||
confirmParams: { return_url: returnUrl },
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setIsSaving(false)
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: error?.message ?? ' Failed to save card details',
|
||||
})
|
||||
} else {
|
||||
setIsSaving(false)
|
||||
onConfirm()
|
||||
}
|
||||
|
||||
if (document !== undefined) {
|
||||
document.body.classList.remove('!pointer-events-auto')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Modal.Content>
|
||||
<div
|
||||
className={`transition ${isSaving ? 'pointer-events-none opacity-75' : 'opacity-100'}`}
|
||||
>
|
||||
<PaymentElement />
|
||||
</div>
|
||||
</Modal.Content>
|
||||
<Modal.Separator />
|
||||
<Modal.Content>
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<Button block htmlType="submit" type="primary" loading={isSaving} disabled={isSaving}>
|
||||
Save
|
||||
</Button>
|
||||
<Button htmlType="button" type="default" onClick={onCancel} block disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddNewPaymentMethodForm
|
||||
-162
@@ -1,162 +0,0 @@
|
||||
import HCaptcha from '@hcaptcha/react-hcaptcha'
|
||||
import { Elements } from '@stripe/react-stripe-js'
|
||||
import { loadStripe } from '@stripe/stripe-js'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { useTheme } from 'common'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import { useSelectedOrganization, useStore } from 'hooks'
|
||||
import { post } from 'lib/common/fetch'
|
||||
import { API_URL, STRIPE_PUBLIC_KEY } from 'lib/constants'
|
||||
import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store'
|
||||
import { Modal } from 'ui'
|
||||
import AddNewPaymentMethodForm from './AddNewPaymentMethodForm'
|
||||
|
||||
// [Joshen] Directly brought over from old Billing folder, so we can deprecate that folder easily next time
|
||||
|
||||
interface AddNewPaymentMethodModalProps {
|
||||
visible: boolean
|
||||
returnUrl: string
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
|
||||
|
||||
const AddNewPaymentMethodModal = ({
|
||||
visible,
|
||||
returnUrl,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: AddNewPaymentMethodModalProps) => {
|
||||
const { ui } = useStore()
|
||||
const [intent, setIntent] = useState<any>()
|
||||
|
||||
const captchaLoaded = useIsHCaptchaLoaded()
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
|
||||
const [captchaRef, setCaptchaRef] = useState<HCaptcha | null>(null)
|
||||
|
||||
const captchaRefCallback = useCallback((node) => {
|
||||
setCaptchaRef(node)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const loadPaymentForm = async () => {
|
||||
if (visible && captchaRef && captchaLoaded) {
|
||||
let token = captchaToken
|
||||
|
||||
try {
|
||||
if (!token) {
|
||||
const captchaResponse = await captchaRef.execute({ async: true })
|
||||
token = captchaResponse?.response ?? null
|
||||
}
|
||||
} catch (error) {
|
||||
return
|
||||
}
|
||||
|
||||
await setupIntent(token ?? undefined)
|
||||
resetCaptcha()
|
||||
}
|
||||
}
|
||||
|
||||
loadPaymentForm()
|
||||
}, [visible, captchaRef, captchaLoaded])
|
||||
|
||||
const resetCaptcha = () => {
|
||||
setCaptchaToken(null)
|
||||
captchaRef?.resetCaptcha()
|
||||
}
|
||||
|
||||
const selectedOrganization = useSelectedOrganization()
|
||||
|
||||
const setupIntent = async (hcaptchaToken: string | undefined) => {
|
||||
setIntent(undefined)
|
||||
|
||||
const orgSlug = selectedOrganization?.slug ?? ''
|
||||
const intent = await post(`${API_URL}/organizations/${orgSlug}/payments/setup-intent`, {
|
||||
hcaptchaToken,
|
||||
})
|
||||
|
||||
if (intent.error) {
|
||||
return ui.setNotification({
|
||||
category: 'error',
|
||||
error: intent.error,
|
||||
message: `Failed to setup intent: ${intent.error.message}`,
|
||||
})
|
||||
} else {
|
||||
setIntent(intent)
|
||||
}
|
||||
}
|
||||
|
||||
const { isDarkMode } = useTheme()
|
||||
|
||||
const options = {
|
||||
clientSecret: intent ? intent.client_secret : '',
|
||||
appearance: { theme: isDarkMode ? 'night' : 'flat', labels: 'floating' },
|
||||
} as any
|
||||
|
||||
const onLocalCancel = () => {
|
||||
setIntent(undefined)
|
||||
return onCancel()
|
||||
}
|
||||
|
||||
const onLocalConfirm = () => {
|
||||
setIntent(undefined)
|
||||
return onConfirm()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<HCaptcha
|
||||
id="supabase-captcha"
|
||||
ref={captchaRefCallback}
|
||||
sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY!}
|
||||
size="invisible"
|
||||
onOpen={() => {
|
||||
// [Joshen] This is to ensure that hCaptcha popup remains clickable
|
||||
if (document !== undefined) document.body.classList.add('!pointer-events-auto')
|
||||
}}
|
||||
onClose={() => {
|
||||
onLocalCancel()
|
||||
if (document !== undefined) document.body.classList.remove('!pointer-events-auto')
|
||||
}}
|
||||
onVerify={(token) => {
|
||||
setCaptchaToken(token)
|
||||
if (document !== undefined) document.body.classList.remove('!pointer-events-auto')
|
||||
}}
|
||||
onExpire={() => {
|
||||
setCaptchaToken(null)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
hideFooter
|
||||
size="medium"
|
||||
visible={visible}
|
||||
header="Add new payment method"
|
||||
onCancel={() => onLocalCancel()}
|
||||
className="PAYMENT"
|
||||
>
|
||||
<div className="py-4">
|
||||
{intent === undefined ? (
|
||||
<div className="px-6 space-y-2">
|
||||
<ShimmeringLoader className="w-full" />
|
||||
<ShimmeringLoader className="w-3/4" />
|
||||
<ShimmeringLoader className="w-1/2" />
|
||||
</div>
|
||||
) : (
|
||||
<Elements stripe={stripePromise} options={options}>
|
||||
<AddNewPaymentMethodForm
|
||||
returnUrl={returnUrl}
|
||||
onCancel={onLocalCancel}
|
||||
onConfirm={onLocalConfirm}
|
||||
/>
|
||||
</Elements>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddNewPaymentMethodModal
|
||||
@@ -9,7 +9,7 @@ import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { getURL } from 'lib/helpers'
|
||||
import { Button, IconAlertCircle, IconCreditCard, IconLoader, IconPlus, Listbox } from 'ui'
|
||||
import AddNewPaymentMethodModal from './AddNewPaymentMethodModal'
|
||||
import { AddNewPaymentMethodModal } from 'components/interfaces/BillingV2'
|
||||
|
||||
export interface PaymentMethodSelectionProps {
|
||||
selectedPaymentMethod?: string
|
||||
|
||||
@@ -2,14 +2,14 @@ import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
import { Badge, Button, IconDownload } from 'ui'
|
||||
|
||||
import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmModal'
|
||||
import { useBackupDownloadMutation } from 'data/database/backup-download-mutation'
|
||||
import { useBackupRestoreMutation } from 'data/database/backup-restore-mutation'
|
||||
import { setProjectStatus } from 'data/projects/projects-query'
|
||||
import { useCheckPermissions, useStore } from 'hooks'
|
||||
import { post } from 'lib/common/fetch'
|
||||
import { API_URL, PROJECT_STATUS } from 'lib/constants'
|
||||
import { Badge, Button, IconDownload } from 'ui'
|
||||
import { PROJECT_STATUS } from 'lib/constants'
|
||||
|
||||
interface BackupItemProps {
|
||||
projectRef: string
|
||||
@@ -18,48 +18,33 @@ interface BackupItemProps {
|
||||
}
|
||||
|
||||
const BackupItem = ({ projectRef, backup, index }: BackupItemProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const router = useRouter()
|
||||
const { ui } = useStore()
|
||||
|
||||
const [isDownloading, setDownloading] = useState<boolean>(false)
|
||||
const [isRestoring, setRestoring] = useState<boolean>(false)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const canTriggerScheduledBackups = useCheckPermissions(
|
||||
PermissionAction.INFRA_EXECUTE,
|
||||
'queue_job.restore.prepare'
|
||||
)
|
||||
|
||||
async function restore(backup: any) {
|
||||
setRestoring(true)
|
||||
try {
|
||||
post(`${API_URL}/database/${projectRef}/backups/restore`, backup).then(() => {
|
||||
setTimeout(() => {
|
||||
setProjectStatus(queryClient, projectRef, PROJECT_STATUS.RESTORING)
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
message: `Restoring database back to ${dayjs(backup.inserted_at).format(
|
||||
'DD MMM YYYY HH:mm:ss'
|
||||
)}`,
|
||||
})
|
||||
router.push(`/project/${projectRef}`)
|
||||
}, 3000)
|
||||
})
|
||||
} catch (error: any) {
|
||||
ui.setNotification({
|
||||
error,
|
||||
category: 'error',
|
||||
message: `Failed to restore from backup: ${error.message}`,
|
||||
})
|
||||
setRestoring(false)
|
||||
}
|
||||
}
|
||||
const { mutate: restoreFromBackup, isLoading: isRestoring } = useBackupRestoreMutation({
|
||||
onSuccess: () => {
|
||||
setTimeout(() => {
|
||||
setProjectStatus(queryClient, projectRef, PROJECT_STATUS.RESTORING)
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
message: `Restoring database back to ${dayjs(backup.inserted_at).format(
|
||||
'DD MMM YYYY HH:mm:ss'
|
||||
)}`,
|
||||
})
|
||||
router.push(`/project/${projectRef}`)
|
||||
}, 3000)
|
||||
},
|
||||
})
|
||||
|
||||
async function download(backup: any) {
|
||||
setDownloading(true)
|
||||
try {
|
||||
const res = await post(`${API_URL}/database/${projectRef}/backups/download`, backup)
|
||||
const { fileUrl } = await res
|
||||
const { mutate: downloadBackup, isLoading: isDownloading } = useBackupDownloadMutation({
|
||||
onSuccess: (res) => {
|
||||
const { fileUrl } = res
|
||||
|
||||
// Trigger browser download by create,trigger and remove tempLink
|
||||
const tempLink = document.createElement('a')
|
||||
@@ -67,27 +52,16 @@ const BackupItem = ({ projectRef, backup, index }: BackupItemProps) => {
|
||||
document.body.appendChild(tempLink)
|
||||
tempLink.click()
|
||||
document.body.removeChild(tempLink)
|
||||
},
|
||||
})
|
||||
|
||||
setDownloading(false)
|
||||
} catch (error: any) {
|
||||
ui.setNotification({
|
||||
error,
|
||||
category: 'error',
|
||||
message: `Failed to download backup: ${error.message}`,
|
||||
})
|
||||
setDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function onRestoreClick() {
|
||||
const onRestoreClick = () => {
|
||||
confirmAlert({
|
||||
title: 'Confirm to restore',
|
||||
message: `Are you sure you want to restore from ${dayjs(backup.inserted_at).format(
|
||||
'DD MMM YYYY'
|
||||
)}? This will destroy any new data written since this backup was made.`,
|
||||
onAsyncConfirm: async () => {
|
||||
await restore(backup)
|
||||
},
|
||||
onAsyncConfirm: async () => restoreFromBackup({ ref: projectRef, backup }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -108,7 +82,7 @@ const BackupItem = ({ projectRef, backup, index }: BackupItemProps) => {
|
||||
<Button
|
||||
type="default"
|
||||
disabled={!canTriggerScheduledBackups || isRestoring || isDownloading}
|
||||
onClick={() => download(backup)}
|
||||
onClick={() => downloadBackup({ ref: projectRef, backup })}
|
||||
loading={isDownloading}
|
||||
icon={<IconDownload />}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import { format } from 'date-fns'
|
||||
import dayjs from 'dayjs'
|
||||
import { useRouter } from 'next/router'
|
||||
@@ -7,13 +8,12 @@ import { useState } from 'react'
|
||||
import DatePicker from 'react-datepicker'
|
||||
import { Alert, Button, IconChevronLeft, IconChevronRight, IconHelpCircle, Modal } from 'ui'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { FormHeader, FormPanel } from 'components/ui/Forms'
|
||||
import InformationBox from 'components/ui/InformationBox'
|
||||
import { usePitrRestoreMutation } from 'data/database/pitr-restore-mutation'
|
||||
import { setProjectStatus } from 'data/projects/projects-query'
|
||||
import { useStore } from 'hooks'
|
||||
import { post } from 'lib/common/fetch'
|
||||
import { API_URL, PROJECT_STATUS } from 'lib/constants'
|
||||
import { PROJECT_STATUS } from 'lib/constants'
|
||||
import BackupsEmpty from '../BackupsEmpty'
|
||||
import { Timezone } from './PITR.types'
|
||||
import {
|
||||
@@ -27,14 +27,25 @@ import TimeInput from './TimeInput'
|
||||
import TimezoneSelection from './TimezoneSelection'
|
||||
|
||||
const PITRSelection = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const router = useRouter()
|
||||
const { ref } = useParams()
|
||||
const { backups } = useStore()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { ui, backups } = useStore()
|
||||
|
||||
const [showConfiguration, setShowConfiguration] = useState(false)
|
||||
const [showConfirmation, setShowConfirmation] = useState(false)
|
||||
const [selectedTimezone, setSelectedTimezone] = useState<Timezone>(getClientTimezone())
|
||||
|
||||
const { mutate: restoreFromPitr, isLoading: isRestoring } = usePitrRestoreMutation({
|
||||
onSuccess: (res, variables) => {
|
||||
setTimeout(() => {
|
||||
setShowConfirmation(false)
|
||||
setProjectStatus(queryClient, variables.ref, PROJECT_STATUS.RESTORING)
|
||||
router.push(`/project/${variables.ref}`)
|
||||
}, 3000)
|
||||
},
|
||||
})
|
||||
|
||||
const { earliestPhysicalBackupDateUnix, latestPhysicalBackupDateUnix } =
|
||||
backups?.configuration?.physicalBackupData ?? {}
|
||||
const hasNoBackupsAvailable = !earliestPhysicalBackupDateUnix || !latestPhysicalBackupDateUnix
|
||||
@@ -46,10 +57,6 @@ const PITRSelection = () => {
|
||||
const [selectedDateRaw, setSelectedDateRaw] = useState<Date>(latestAvailableBackup.toDate())
|
||||
const selectedDate = dayjs(selectedDateRaw).tz(selectedTimezone.utc[0], true) // true to keep local time and just change +whatever
|
||||
|
||||
const [showConfiguration, setShowConfiguration] = useState(false)
|
||||
const [showConfirmation, setShowConfirmation] = useState(false)
|
||||
const [isRestoring, setIsRestoring] = useState(false)
|
||||
|
||||
const isSelectedOnEarliestDay = selectedDate.isSame(earliestAvailableBackup, 'day')
|
||||
const isSelectedOnLatestDay = selectedDate.isSame(latestAvailableBackup, 'day')
|
||||
const availableDates = getDatesBetweenRange(earliestAvailableBackup, latestAvailableBackup)
|
||||
@@ -98,28 +105,9 @@ const PITRSelection = () => {
|
||||
}
|
||||
|
||||
const onConfirmRestore = async () => {
|
||||
if (!recoveryTimeTargetUnix) return
|
||||
|
||||
setIsRestoring(true)
|
||||
const projectRef = ref as string
|
||||
const { error } = await post(`${API_URL}/database/${projectRef}/backups/pitr`, {
|
||||
recovery_time_target_unix: recoveryTimeTargetUnix,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
ui.setNotification({
|
||||
error,
|
||||
category: 'error',
|
||||
message: `Failed to start restoration: ${error.message}`,
|
||||
})
|
||||
setIsRestoring(false)
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
setShowConfirmation(false)
|
||||
setProjectStatus(queryClient, projectRef, PROJECT_STATUS.RESTORING)
|
||||
router.push(`/project/${projectRef}`)
|
||||
}, 3000)
|
||||
}
|
||||
if (!ref) return console.error('Project ref is required')
|
||||
if (!recoveryTimeTargetUnix) return console.error('Recovery time target unix is required')
|
||||
restoreFromPitr({ ref, recovery_time_target_unix: recoveryTimeTargetUnix })
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Button, Input, Listbox, SidePanel } from 'ui'
|
||||
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import CodeEditor from 'components/ui/CodeEditor'
|
||||
import MultiSelect, { MultiSelectOption } from 'components/ui/MultiSelect'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import { useSchemasQuery } from 'data/database/schemas-query'
|
||||
import { useTableColumnsQuery } from 'data/database/table-columns-query'
|
||||
import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-query'
|
||||
import { INDEX_TYPES } from './Indexes.constants'
|
||||
import { useExecuteSqlMutation } from 'data/sql/execute-sql-mutation'
|
||||
import { useIndexesQuery } from 'data/database/indexes-query'
|
||||
import { useStore } from 'hooks'
|
||||
|
||||
interface CreateIndexSidePanelProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelProps) => {
|
||||
const { ui } = useStore()
|
||||
const { project } = useProjectContext()
|
||||
const [selectedSchema, setSelectedSchema] = useState('public')
|
||||
const [selectedEntity, setSelectedEntity] = useState('---')
|
||||
const [selectedColumns, setSelectedColumns] = useState<string[]>([])
|
||||
const [selectedIndexType, setSelectedIndexType] = useState<string>(INDEX_TYPES[0].value)
|
||||
|
||||
const { refetch: refetchIndexes } = useIndexesQuery({
|
||||
schema: selectedSchema,
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
const { data: schemas } = useSchemasQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
const { data: entities } = useEntityTypesQuery(
|
||||
{
|
||||
schema: selectedSchema,
|
||||
sort: 'alphabetical',
|
||||
search: undefined,
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
},
|
||||
{ keepPreviousData: true }
|
||||
)
|
||||
const {
|
||||
data: tableColumns,
|
||||
isLoading: isLoadingTableColumns,
|
||||
isSuccess: isSuccessTableColumns,
|
||||
} = useTableColumnsQuery({
|
||||
table: selectedEntity,
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
const { mutate: execute, isLoading: isExecuting } = useExecuteSqlMutation({
|
||||
onSuccess: async () => {
|
||||
await refetchIndexes()
|
||||
onClose()
|
||||
ui.setNotification({ category: 'success', message: `Successfully created index` })
|
||||
},
|
||||
onError: (error) => {
|
||||
ui.setNotification({
|
||||
error,
|
||||
category: 'error',
|
||||
message: `Failed to create index: ${error.message}`,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const entityTypes = useMemo(
|
||||
() => entities?.pages.flatMap((page) => page.data.entities) || [],
|
||||
[entities?.pages]
|
||||
)
|
||||
const columns = tableColumns?.result[0]?.columns ?? []
|
||||
const columnOptions: MultiSelectOption[] = columns.map((column) => {
|
||||
return { id: column.attname, value: column.attname, name: column.attname, disabled: false }
|
||||
})
|
||||
|
||||
const generatedSQL = `
|
||||
CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexType} (${selectedColumns.join(
|
||||
', '
|
||||
)});
|
||||
`.trim()
|
||||
|
||||
const onSaveIndex = () => {
|
||||
if (!project) return console.error('Project is required')
|
||||
|
||||
execute({
|
||||
projectRef: project.ref,
|
||||
connectionString: project.connectionString,
|
||||
sql: generatedSQL,
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setSelectedSchema('public')
|
||||
setSelectedEntity('---')
|
||||
setSelectedColumns([])
|
||||
setSelectedIndexType(INDEX_TYPES[0].value)
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedEntity('---')
|
||||
setSelectedColumns([])
|
||||
setSelectedIndexType(INDEX_TYPES[0].value)
|
||||
}, [selectedSchema])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedColumns([])
|
||||
setSelectedIndexType(INDEX_TYPES[0].value)
|
||||
}, [selectedEntity])
|
||||
|
||||
return (
|
||||
<SidePanel
|
||||
size="large"
|
||||
header="Create new index"
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
onConfirm={() => onSaveIndex()}
|
||||
loading={isExecuting}
|
||||
confirmText="Create index"
|
||||
>
|
||||
<div className="py-6 space-y-6">
|
||||
<SidePanel.Content className="space-y-6">
|
||||
<Listbox
|
||||
size="small"
|
||||
label="Select a schema"
|
||||
value={selectedSchema}
|
||||
onChange={setSelectedSchema}
|
||||
>
|
||||
{(schemas?.result ?? []).map((schema) => (
|
||||
<Listbox.Option key={schema.name} value={schema.name} label={schema.name}>
|
||||
{schema.name}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox>
|
||||
|
||||
{entityTypes.length === 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-scale-1100 leading-4">Select a table</p>
|
||||
<Input
|
||||
disabled
|
||||
placeholder="No tables available in schema"
|
||||
descriptionText="Create a table in this schema via the Table or SQL editor first"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Listbox
|
||||
size="small"
|
||||
label="Select a table"
|
||||
value={selectedEntity}
|
||||
onChange={setSelectedEntity}
|
||||
>
|
||||
<Listbox.Option key="default-option-table" value="---" label="---">
|
||||
---
|
||||
</Listbox.Option>
|
||||
{(entityTypes ?? []).map((entity) => (
|
||||
<Listbox.Option key={entity.name} value={entity.name} label={entity.name}>
|
||||
{entity.name}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox>
|
||||
)}
|
||||
|
||||
{selectedEntity !== '---' && (
|
||||
<div>
|
||||
<p className="text-sm text-scale-1100 mb-2">Select up to 32 columns</p>
|
||||
{isLoadingTableColumns && <ShimmeringLoader className="py-4" />}
|
||||
{isSuccessTableColumns && (
|
||||
<MultiSelect
|
||||
options={columnOptions}
|
||||
placeholder=""
|
||||
searchPlaceholder="Search for a column"
|
||||
value={selectedColumns}
|
||||
onChange={setSelectedColumns}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SidePanel.Content>
|
||||
|
||||
{selectedColumns.length > 0 && (
|
||||
<>
|
||||
<SidePanel.Separator />
|
||||
<SidePanel.Content className="space-y-6">
|
||||
<Listbox
|
||||
size="small"
|
||||
label="Select an index type"
|
||||
value={selectedIndexType}
|
||||
onChange={setSelectedIndexType}
|
||||
>
|
||||
{INDEX_TYPES.map((index) => (
|
||||
<Listbox.Option key={index.name} value={index.value} label={index.name}>
|
||||
<p>{index.name}</p>
|
||||
{index.description.split('\n').map((x, idx) => (
|
||||
<p key={`${index.value}-description-${idx}`}>{x}</p>
|
||||
))}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox>
|
||||
</SidePanel.Content>
|
||||
<SidePanel.Separator />
|
||||
<SidePanel.Content>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm">Preview of SQL statement</p>
|
||||
<Link
|
||||
passHref
|
||||
href={
|
||||
project !== undefined
|
||||
? `/project/${project.ref}/sql/new?content=${generatedSQL}`
|
||||
: '/'
|
||||
}
|
||||
>
|
||||
<a>
|
||||
<Button type="default">Open in SQL Editor</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</SidePanel.Content>
|
||||
<div className="h-[200px] !mt-2">
|
||||
<div className="relative h-full">
|
||||
<CodeEditor
|
||||
isReadOnly
|
||||
autofocus={false}
|
||||
id={`${selectedSchema}-${selectedEntity}-${selectedColumns.join(
|
||||
','
|
||||
)}-${selectedIndexType}`}
|
||||
language="pgsql"
|
||||
defaultValue={generatedSQL}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SidePanel>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreateIndexSidePanel
|
||||
@@ -0,0 +1,33 @@
|
||||
export const INDEX_TYPES = [
|
||||
{
|
||||
name: 'B-Tree',
|
||||
value: 'btree',
|
||||
description:
|
||||
'For querying data with equality or range conditions on columns.\nThis is the default index type that Postgres uses.',
|
||||
},
|
||||
{
|
||||
name: 'Hash',
|
||||
value: 'hash',
|
||||
description: 'For querying exact matches on columns',
|
||||
},
|
||||
{
|
||||
name: 'GiST',
|
||||
value: 'gist',
|
||||
description: 'For querying complex data types or custom-defined operators',
|
||||
},
|
||||
{
|
||||
name: 'SP-GiST',
|
||||
value: 'spgist',
|
||||
description: 'Similar to GiST, but is more specialized and customizable',
|
||||
},
|
||||
{
|
||||
name: 'GIN',
|
||||
value: 'gin',
|
||||
description: 'For querying multi-valued data such as arrays or full-text search scenarios',
|
||||
},
|
||||
{
|
||||
name: 'BRIN',
|
||||
value: 'brin',
|
||||
description: 'For querying large tables with sorted data',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,312 @@
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
Alert_Shadcn_,
|
||||
Button,
|
||||
IconAlertCircle,
|
||||
IconAlertTriangle,
|
||||
IconExternalLink,
|
||||
IconSearch,
|
||||
IconTrash,
|
||||
Input,
|
||||
Listbox,
|
||||
Modal,
|
||||
SidePanel,
|
||||
} from 'ui'
|
||||
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import ShimmeringLoader, { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { DatabaseIndex, useIndexesQuery } from 'data/database/indexes-query'
|
||||
import { useSchemasQuery } from 'data/database/schemas-query'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import CodeEditor from 'components/ui/CodeEditor'
|
||||
import CreateIndexSidePanel from './CreateIndexSidePanel'
|
||||
import ConfirmationModal from 'components/ui/ConfirmationModal'
|
||||
import { useExecuteSqlMutation } from 'data/sql/execute-sql-mutation'
|
||||
import { useStore } from 'hooks'
|
||||
import MonacoEditor from 'components/interfaces/SQLEditor/MonacoEditor'
|
||||
|
||||
const Indexes = () => {
|
||||
const { ui } = useStore()
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedSchema, setSelectedSchema] = useState('public')
|
||||
const [showCreateIndex, setShowCreateIndex] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState<DatabaseIndex>()
|
||||
const [selectedIndexToDelete, setSelectedIndexToDelete] = useState<DatabaseIndex>()
|
||||
|
||||
const { project } = useProjectContext()
|
||||
const {
|
||||
data: allIndexes,
|
||||
refetch: refetchIndexes,
|
||||
error: indexesError,
|
||||
isLoading: isLoadingIndexes,
|
||||
isSuccess: isSuccessIndexes,
|
||||
isError: isErrorIndexes,
|
||||
} = useIndexesQuery({
|
||||
schema: selectedSchema,
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
const {
|
||||
data: schemas,
|
||||
isLoading: isLoadingSchemas,
|
||||
isSuccess: isSuccessSchemas,
|
||||
isError: isErrorSchemas,
|
||||
} = useSchemasQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
const { mutate: execute, isLoading: isExecuting } = useExecuteSqlMutation({
|
||||
onSuccess() {
|
||||
refetchIndexes()
|
||||
setSelectedIndexToDelete(undefined)
|
||||
ui.setNotification({ category: 'success', message: `Successfully deleted index` })
|
||||
},
|
||||
onError(error) {
|
||||
ui.setNotification({
|
||||
error,
|
||||
category: 'error',
|
||||
message: `Failed to delete index: ${error.message}`,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const sortedIndexes = (allIndexes?.result ?? []).sort(
|
||||
(a, b) => a.table.localeCompare(b.table) || a.name.localeCompare(b.name)
|
||||
)
|
||||
const indexes =
|
||||
search.length > 0
|
||||
? sortedIndexes.filter((index) => index.name.includes(search) || index.table.includes(search))
|
||||
: sortedIndexes
|
||||
|
||||
const onConfirmDeleteIndex = (index: DatabaseIndex) => {
|
||||
if (!project) return console.error('Project is required')
|
||||
execute({
|
||||
projectRef: project.ref,
|
||||
connectionString: project.connectionString,
|
||||
sql: `drop index if exists "${index.name}"`,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="pb-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="mb-2 text-xl text-scale-1200">Indexes</h3>
|
||||
<div className="text-sm text-scale-900">
|
||||
Improve query performance against your database
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Link href="https://supabase.com/docs/guides/database/query-optimization">
|
||||
<a target="_blank" rel="noreferrer">
|
||||
<Button type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
|
||||
Documentation
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
<Link href="https://supabase.com/docs/guides/database/extensions/index_advisor">
|
||||
<a target="_blank" rel="noreferrer">
|
||||
<Button type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
|
||||
Optimization with index_advisor
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
{isLoadingSchemas && <ShimmeringLoader className="w-[260px]" />}
|
||||
{isErrorSchemas && (
|
||||
<div className="w-[260px] text-light text-sm border px-3 py-1.5 rounded flex items-center space-x-2">
|
||||
<IconAlertCircle strokeWidth={2} size={16} />
|
||||
<p>Failed to load schemas</p>
|
||||
</div>
|
||||
)}
|
||||
{isSuccessSchemas && (
|
||||
<Listbox
|
||||
size="small"
|
||||
value={selectedSchema}
|
||||
onChange={setSelectedSchema}
|
||||
className="w-[260px]"
|
||||
>
|
||||
{(schemas?.result ?? []).map((schema) => (
|
||||
<Listbox.Option
|
||||
key={schema.name}
|
||||
value={schema.name}
|
||||
label={schema.name}
|
||||
addOnBefore={() => <span className="text-scale-900">schema</span>}
|
||||
>
|
||||
{schema.name}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox>
|
||||
)}
|
||||
<Input
|
||||
size="small"
|
||||
value={search}
|
||||
className="w-[250px]"
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search for an index"
|
||||
icon={<IconSearch size={14} />}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => setShowCreateIndex(true)}
|
||||
disabled={!isSuccessSchemas}
|
||||
>
|
||||
Create index
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoadingIndexes && <GenericSkeletonLoader />}
|
||||
|
||||
{isErrorIndexes && (
|
||||
<AlertError error={indexesError as any} subject="Failed to retrieve database indexes" />
|
||||
)}
|
||||
|
||||
{isSuccessIndexes && (
|
||||
<Table
|
||||
head={[
|
||||
<Table.th key="schema">Schema</Table.th>,
|
||||
<Table.th key="table">Table</Table.th>,
|
||||
<Table.th key="name">Name</Table.th>,
|
||||
<Table.th key="buttons"></Table.th>,
|
||||
]}
|
||||
body={
|
||||
<>
|
||||
{sortedIndexes.length === 0 && search.length === 0 && (
|
||||
<Table.tr>
|
||||
<Table.td colSpan={4}>
|
||||
<p className="text-sm text-scale-1200">No indexes created yet</p>
|
||||
<p className="text-sm text-light">
|
||||
There are no indexes found in the schema "{selectedSchema}"
|
||||
</p>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)}
|
||||
{sortedIndexes.length === 0 && search.length > 0 && (
|
||||
<Table.tr>
|
||||
<Table.td colSpan={4}>
|
||||
<p className="text-sm text-scale-1200">No results found</p>
|
||||
<p className="text-sm text-light">
|
||||
Your search for "{search}" did not return any results
|
||||
</p>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)}
|
||||
{indexes.length > 0 &&
|
||||
indexes.map((index) => (
|
||||
<Table.tr key={index.name}>
|
||||
<Table.td>
|
||||
<p title={index.schema}>{index.schema}</p>
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
<p title={index.table}>{index.table}</p>
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
<p title={index.name}>{index.name}</p>
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
<div className="flex justify-end items-center space-x-2">
|
||||
<Button type="default" onClick={() => setSelectedIndex(index)}>
|
||||
View definition
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
className="px-1"
|
||||
icon={<IconTrash />}
|
||||
onClick={() => setSelectedIndexToDelete(index)}
|
||||
/>
|
||||
</div>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SidePanel
|
||||
size="xlarge"
|
||||
visible={selectedIndex !== undefined}
|
||||
header={
|
||||
<>
|
||||
<span>Index:</span>
|
||||
<code className="text-sm ml-2">{selectedIndex?.name}</code>
|
||||
</>
|
||||
}
|
||||
onCancel={() => setSelectedIndex(undefined)}
|
||||
>
|
||||
<div className="h-full">
|
||||
<div className="relative h-full">
|
||||
<CodeEditor
|
||||
isReadOnly
|
||||
id={selectedIndex?.name ?? ''}
|
||||
language="pgsql"
|
||||
defaultValue={selectedIndex?.definition ?? ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SidePanel>
|
||||
|
||||
<CreateIndexSidePanel visible={showCreateIndex} onClose={() => setShowCreateIndex(false)} />
|
||||
|
||||
<ConfirmationModal
|
||||
danger
|
||||
size="medium"
|
||||
loading={isExecuting}
|
||||
visible={selectedIndexToDelete !== undefined}
|
||||
header={
|
||||
<>
|
||||
Confirm to delete index <code className="text-sm">{selectedIndexToDelete?.name}</code>
|
||||
</>
|
||||
}
|
||||
buttonLabel="Confirm delete"
|
||||
buttonLoadingLabel="Deleting..."
|
||||
onSelectConfirm={() =>
|
||||
selectedIndexToDelete !== undefined ? onConfirmDeleteIndex(selectedIndexToDelete) : {}
|
||||
}
|
||||
onSelectCancel={() => setSelectedIndexToDelete(undefined)}
|
||||
>
|
||||
<Modal.Content>
|
||||
<div className="py-6">
|
||||
<Alert_Shadcn_ variant="warning">
|
||||
<IconAlertTriangle strokeWidth={2} />
|
||||
<AlertTitle_Shadcn_>This action cannot be undone</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
Deleting an index that is still in use will cause queries to slow down, and in some
|
||||
cases causing significant performance issues.
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
<ul className="mt-4 space-y-5">
|
||||
<li className="flex gap-3">
|
||||
<div>
|
||||
<strong className="text-sm">Before deleting this index, consider:</strong>
|
||||
<ul className="space-y-2 mt-2 text-sm text-light">
|
||||
<li className="list-disc ml-6">This index is no longer in use</li>
|
||||
<li className="list-disc ml-6">
|
||||
The table which the index is on is not currently in use, as dropping an index
|
||||
requires a short exclusive access lock on the table.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
</ConfirmationModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(Indexes)
|
||||
@@ -11,7 +11,7 @@ import { DatabaseMigration, useMigrationsQuery } from 'data/database/migrations-
|
||||
import MigrationsEmptyState from './MigrationsEmptyState'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const Wrappers = () => {
|
||||
const Migrations = () => {
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedMigration, setSelectedMigration] = useState<DatabaseMigration>()
|
||||
|
||||
@@ -183,4 +183,4 @@ const Wrappers = () => {
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(Wrappers)
|
||||
export default observer(Migrations)
|
||||
@@ -750,49 +750,48 @@ export const WRAPPERS: WrapperMeta[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
// Commented out until the airtable is available in the postgres image
|
||||
// {
|
||||
// name: 'airtable_wrapper',
|
||||
// handlerName: 'airtable_fdw_handler',
|
||||
// validatorName: 'airtable_fdw_validator',
|
||||
// icon: `${BASE_PATH}/img/icons/airtable-icon.svg`,
|
||||
// extensionName: 'airtableFdw',
|
||||
// label: 'Airtable',
|
||||
// docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/airtable',
|
||||
// server: {
|
||||
// options: [
|
||||
// {
|
||||
// name: 'api_key_id',
|
||||
// label: 'API Key ID',
|
||||
// required: true,
|
||||
// encrypted: true,
|
||||
// hidden: true,
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// tables: [
|
||||
// {
|
||||
// label: 'Airtable Table',
|
||||
// description: 'Map to an Airtable Table',
|
||||
// options: [
|
||||
// {
|
||||
// name: 'base_id',
|
||||
// label: 'Base ID',
|
||||
// editable: true,
|
||||
// required: true,
|
||||
// type: 'text',
|
||||
// },
|
||||
// {
|
||||
// name: 'table_id',
|
||||
// label: 'Table ID',
|
||||
// editable: true,
|
||||
// required: true,
|
||||
// type: 'text',
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
{
|
||||
name: 'airtable_wrapper',
|
||||
handlerName: 'airtable_fdw_handler',
|
||||
validatorName: 'airtable_fdw_validator',
|
||||
icon: `${BASE_PATH}/img/icons/airtable-icon.svg`,
|
||||
extensionName: 'airtableFdw',
|
||||
label: 'Airtable',
|
||||
docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/airtable',
|
||||
server: {
|
||||
options: [
|
||||
{
|
||||
name: 'api_key_id',
|
||||
label: 'API Key ID',
|
||||
required: true,
|
||||
encrypted: true,
|
||||
hidden: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
tables: [
|
||||
{
|
||||
label: 'Airtable Table',
|
||||
description: 'Map to an Airtable Table',
|
||||
options: [
|
||||
{
|
||||
name: 'base_id',
|
||||
label: 'Base ID',
|
||||
editable: true,
|
||||
required: true,
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'table_id',
|
||||
label: 'Table ID',
|
||||
editable: true,
|
||||
required: true,
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'logflare_wrapper',
|
||||
handlerName: 'logflare_fdw_handler',
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Button, Dropdown, IconPlus } from 'ui'
|
||||
|
||||
import { useParams } from 'common/hooks'
|
||||
import { useCheckPermissions } from 'hooks'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { WRAPPERS } from './Wrappers.constants'
|
||||
|
||||
interface WrapperDropdownProps {
|
||||
@@ -78,22 +77,6 @@ const WrapperDropdown = ({ buttonText = 'Add wrapper', align = 'end' }: WrapperD
|
||||
{idx !== WRAPPERS.length - 1 && <Dropdown.Separator />}
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
<Dropdown.Separator />
|
||||
|
||||
<Dropdown.Item
|
||||
disabled
|
||||
icon={
|
||||
<Image
|
||||
src={`${BASE_PATH}/img/icons/airtable-icon.svg`}
|
||||
width={20}
|
||||
height={20}
|
||||
alt="Airtable wrapper icon"
|
||||
/>
|
||||
}
|
||||
>
|
||||
Airtable <span className="text-xs">(soon)</span>
|
||||
</Dropdown.Item>
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -18,7 +18,6 @@ import { makeRandomString } from 'lib/helpers'
|
||||
import { Organization, Project, ResponseError } from 'types'
|
||||
import ProjectCard from './ProjectCard'
|
||||
import ShimmeringCard from './ShimmeringCard'
|
||||
import { useOrgIntegrationsQuery } from 'data/integrations/integrations-query-org-only'
|
||||
|
||||
export interface ProjectListProps {
|
||||
rewriteHref?: (projectRef: string) => string
|
||||
@@ -99,10 +98,7 @@ const OrganizationProjects = ({
|
||||
return (
|
||||
<div className="space-y-3" key={makeRandomString(5)}>
|
||||
<div className="flex space-x-4 items-center">
|
||||
<h4 className="text-lg flex items-center">
|
||||
{name}
|
||||
{subscription_id ? <Badge className="ml-3">V2</Badge> : <></>}
|
||||
</h4>
|
||||
<h4 className="text-lg flex items-center">{name}</h4>
|
||||
|
||||
{!!overdueInvoices.length && (
|
||||
<div>
|
||||
|
||||
+17
-20
@@ -1,17 +1,13 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
import { Button, Form, Input, Modal } from 'ui'
|
||||
|
||||
import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query'
|
||||
import { useOrganizationDeleteMutation } from 'data/organizations/organization-delete-mutation'
|
||||
import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks'
|
||||
import { delete_, isResponseOk } from 'lib/common/fetch'
|
||||
import { API_URL } from 'lib/constants'
|
||||
|
||||
const DeleteOrganizationButton = () => {
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const { ui } = useStore()
|
||||
|
||||
const selectedOrganization = useSelectedOrganization()
|
||||
@@ -21,6 +17,7 @@ const DeleteOrganizationButton = () => {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
const canDeleteOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations')
|
||||
const { mutateAsync: deleteOrganization, isLoading: isDeleting } = useOrganizationDeleteMutation()
|
||||
|
||||
const onValidate = (values: any) => {
|
||||
const errors: any = {}
|
||||
@@ -33,30 +30,23 @@ const DeleteOrganizationButton = () => {
|
||||
return errors
|
||||
}
|
||||
|
||||
const onConfirmDelete = async (values: any, { setSubmitting }: any) => {
|
||||
const onConfirmDelete = async (values: any) => {
|
||||
if (!canDeleteOrganization) {
|
||||
return ui.setNotification({
|
||||
category: 'error',
|
||||
message: 'You do not have the required permissions to delete this organization',
|
||||
})
|
||||
}
|
||||
if (!orgSlug) return console.error('Org slug is required')
|
||||
|
||||
setSubmitting(true)
|
||||
const response = await delete_<void>(`${API_URL}/organizations/${orgSlug}`)
|
||||
if (!isResponseOk(response)) {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: `Failed to delete organization: ${response.error.message}`,
|
||||
})
|
||||
setSubmitting(false)
|
||||
} else {
|
||||
invalidateOrganizationsQuery(queryClient)
|
||||
setSubmitting(false)
|
||||
router.push('/')
|
||||
try {
|
||||
await deleteOrganization({ slug: orgSlug })
|
||||
} finally {
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
message: `Successfully deleted ${orgName}`,
|
||||
})
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +76,7 @@ const DeleteOrganizationButton = () => {
|
||||
onSubmit={onConfirmDelete}
|
||||
validate={onValidate}
|
||||
>
|
||||
{({ isSubmitting }: { isSubmitting: boolean }) => (
|
||||
{() => (
|
||||
<div className="space-y-4 py-3">
|
||||
<Modal.Content>
|
||||
<p className="text-sm text-scale-900">
|
||||
@@ -112,7 +102,14 @@ const DeleteOrganizationButton = () => {
|
||||
</Modal.Content>
|
||||
<Modal.Separator />
|
||||
<Modal.Content>
|
||||
<Button block size="small" type="danger" htmlType="submit" loading={isSubmitting}>
|
||||
<Button
|
||||
block
|
||||
size="small"
|
||||
type="danger"
|
||||
htmlType="submit"
|
||||
loading={isDeleting}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
I understand, delete this organization
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
|
||||
+73
-3
@@ -23,6 +23,7 @@ import { useOrganizationBillingMigrationPreview } from 'data/organizations/organ
|
||||
import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks'
|
||||
import { PRICING_TIER_LABELS_ORG } from 'lib/constants'
|
||||
import PaymentMethodSelection from '../BillingSettingsV2/Subscription/PaymentMethodSelection'
|
||||
import InformationBox from 'components/ui/InformationBox'
|
||||
|
||||
const MigrateOrganizationBillingButton = observer(() => {
|
||||
const { ui } = useStore()
|
||||
@@ -114,7 +115,7 @@ const MigrateOrganizationBillingButton = observer(() => {
|
||||
<Modal
|
||||
closable
|
||||
hideFooter
|
||||
size="large"
|
||||
size="xlarge"
|
||||
visible={isOpen}
|
||||
onCancel={toggle}
|
||||
header={
|
||||
@@ -173,7 +174,7 @@ const MigrateOrganizationBillingButton = observer(() => {
|
||||
|
||||
<p>
|
||||
For a detailed breakdown of changes, see{' '}
|
||||
<Link href="https://www.notion.so/supabase/Organization-Level-Billing-9c159d69375b4af095f0b67881276582?pvs=4">
|
||||
<Link href="https://www.notion.so/supabase/Org-Level-Billing-Public-Docs-f059a154beb743a19199d05bab4acb08">
|
||||
<a target="_blank" rel="noreferrer" className="underline">
|
||||
Billing Migration Docs
|
||||
</a>
|
||||
@@ -229,7 +230,7 @@ const MigrateOrganizationBillingButton = observer(() => {
|
||||
<p className="text-sm text-scale-1000">
|
||||
Paid plans come with one compute instance included. Additional projects will at
|
||||
least cost the compute instance hours used (min $7/month). See{' '}
|
||||
<Link href="https://www.notion.so/supabase/Organization-Level-Billing-9c159d69375b4af095f0b67881276582?pvs=4">
|
||||
<Link href="https://www.notion.so/supabase/Organization-Level-Billing-707638e35c92489995dc3ac991a324d1">
|
||||
<a target="_blank" rel="noreferrer" className="underline">
|
||||
Compute Instance Usage Billing
|
||||
</a>
|
||||
@@ -318,6 +319,75 @@ const MigrateOrganizationBillingButton = observer(() => {
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
</Modal.Content>
|
||||
|
||||
{!migrationPreviewIsLoading && migrationPreviewData && dbTier !== 'tier_free' && (
|
||||
<Modal.Content>
|
||||
<InformationBox
|
||||
defaultVisibility={false}
|
||||
title={
|
||||
<span>
|
||||
Estimated monthly price is $
|
||||
{migrationPreviewData.monthly_invoice_breakdown.reduce(
|
||||
(prev, cur) => prev + cur.total_price,
|
||||
0
|
||||
)}{' '}
|
||||
+ usage
|
||||
</span>
|
||||
}
|
||||
hideCollapse={false}
|
||||
description={
|
||||
<div>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="py-2 font-normal text-left text-sm text-scale-1000 w-1/2">
|
||||
Item
|
||||
</th>
|
||||
<th className="py-2 font-normal text-left text-sm text-scale-1000">
|
||||
Count
|
||||
</th>
|
||||
<th className="py-2 font-normal text-left text-sm text-scale-1000">
|
||||
Unit price
|
||||
</th>
|
||||
<th className="py-2 font-normal text-right text-sm text-scale-1000">
|
||||
Price
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{migrationPreviewData.monthly_invoice_breakdown.map((item) => (
|
||||
<tr key={item.description} className="border-b">
|
||||
<td className="py-2 text-sm">{item.description ?? 'Unknown'}</td>
|
||||
<td className="py-2 text-sm">{item.quantity}</td>
|
||||
<td className="py-2 text-sm">
|
||||
{item.unit_price === 0 ? 'FREE' : `$${item.unit_price}`}
|
||||
</td>
|
||||
<td className="py-2 text-sm text-right">${item.total_price}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="py-2 text-sm">Total</td>
|
||||
<td className="py-2 text-sm" />
|
||||
<td className="py-2 text-sm" />
|
||||
<td className="py-2 text-sm text-right">
|
||||
$
|
||||
{migrationPreviewData.monthly_invoice_breakdown.reduce(
|
||||
(prev, cur) => prev + cur.total_price,
|
||||
0
|
||||
) ?? 0}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Modal.Content>
|
||||
)}
|
||||
|
||||
<Modal.Content>
|
||||
<Button
|
||||
block
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ const OrganizationBillingMigrationPanel = observer(() => {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 ml-12">
|
||||
<Link href="https://www.notion.so/supabase/Organization-Level-Billing-9c159d69375b4af095f0b67881276582?pvs=4">
|
||||
<Link href="https://www.notion.so/supabase/Org-Level-Billing-Public-Docs-f059a154beb743a19199d05bab4acb08">
|
||||
<a target="_blank" rel="noreferrer">
|
||||
<Button type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
|
||||
Documentation
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js'
|
||||
import type { PaymentMethod } from '@stripe/stripe-js'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
@@ -12,18 +15,14 @@ import {
|
||||
Toggle,
|
||||
} from 'ui'
|
||||
|
||||
import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js'
|
||||
import type { PaymentMethod } from '@stripe/stripe-js'
|
||||
import { SpendCapModal } from 'components/interfaces/BillingV2'
|
||||
import InformationBox from 'components/ui/InformationBox'
|
||||
import Panel from 'components/ui/Panel'
|
||||
import { useOrganizationCreateMutation } from 'data/organizations/organization-create-mutation'
|
||||
import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query'
|
||||
import { useStore } from 'hooks'
|
||||
import { isResponseOk, post } from 'lib/common/fetch'
|
||||
import { API_URL, BASE_PATH, PRICING_TIER_LABELS_ORG } from 'lib/constants'
|
||||
import { BASE_PATH, PRICING_TIER_LABELS_ORG } from 'lib/constants'
|
||||
import { getURL } from 'lib/helpers'
|
||||
import Link from 'next/link'
|
||||
import { SpendCapModal } from 'components/interfaces/BillingV2'
|
||||
import { Organization } from 'types'
|
||||
|
||||
const ORG_KIND_TYPES = {
|
||||
PERSONAL: 'Personal',
|
||||
@@ -61,6 +60,7 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {
|
||||
const [orgName, setOrgName] = useState('')
|
||||
const [orgKind, setOrgKind] = useState(ORG_KIND_DEFAULT)
|
||||
const [orgSize, setOrgSize] = useState(ORG_SIZE_DEFAULT)
|
||||
// [Joshen] Separate loading state here as there's 2 async processes
|
||||
const [newOrgLoading, setNewOrgLoading] = useState(false)
|
||||
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>()
|
||||
|
||||
@@ -69,6 +69,13 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {
|
||||
const [showSpendCapHelperModal, setShowSpendCapHelperModal] = useState(false)
|
||||
const [isSpendCapEnabled, setIsSpendCapEnabled] = useState(true)
|
||||
|
||||
const { mutateAsync: createOrganization } = useOrganizationCreateMutation({
|
||||
onSuccess: async (org: any) => {
|
||||
await invalidateOrganizationsQuery(queryClient)
|
||||
router.push(`/new/${org.slug}`)
|
||||
},
|
||||
})
|
||||
|
||||
function validateOrgName(name: any) {
|
||||
const value = name ? name.trim() : ''
|
||||
return value.length >= 1
|
||||
@@ -92,36 +99,18 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {
|
||||
|
||||
async function createOrg(paymentMethodId?: string) {
|
||||
const dbTier = dbPricingTierKey === 'PRO' && !isSpendCapEnabled ? 'PAYG' : dbPricingTierKey
|
||||
|
||||
const response = await post<Organization>(
|
||||
`${API_URL}/organizations`,
|
||||
{
|
||||
try {
|
||||
await createOrganization({
|
||||
name: orgName,
|
||||
kind: orgKind,
|
||||
payment_method: paymentMethodId,
|
||||
tier: 'tier_' + dbTier.toLowerCase(),
|
||||
...(orgKind == 'COMPANY' ? { size: orgSize } : {}),
|
||||
},
|
||||
// Call new V2 endpoint from API
|
||||
{
|
||||
headers: {
|
||||
Version: '2',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!isResponseOk(response)) {
|
||||
ui.setNotification({
|
||||
error: response.error,
|
||||
category: 'error',
|
||||
message: `Failed to create organization: ${response.error?.message ?? response.error}`,
|
||||
payment_method: paymentMethodId,
|
||||
V2: true,
|
||||
})
|
||||
} else {
|
||||
const org = response
|
||||
await invalidateOrganizationsQuery(queryClient)
|
||||
router.push(`/new/${org.slug}`)
|
||||
} finally {
|
||||
setNewOrgLoading(false)
|
||||
}
|
||||
setNewOrgLoading(false)
|
||||
}
|
||||
|
||||
const handleSubmit = async (event: any) => {
|
||||
@@ -129,13 +118,11 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {
|
||||
|
||||
const isOrgNameValid = validateOrgName(orgName)
|
||||
if (!isOrgNameValid) {
|
||||
ui.setNotification({ category: 'error', message: 'Organization name is empty' })
|
||||
return
|
||||
return ui.setNotification({ category: 'error', message: 'Organization name is empty' })
|
||||
}
|
||||
|
||||
if (!stripe || !elements) {
|
||||
console.error('Stripe.js has not loaded')
|
||||
return
|
||||
return console.error('Stripe.js has not loaded')
|
||||
}
|
||||
setNewOrgLoading(true)
|
||||
|
||||
@@ -384,7 +371,7 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {
|
||||
subscriptions per project.{' '}
|
||||
</p>
|
||||
<div>
|
||||
<Link href="https://www.notion.so/supabase/Organization-Level-Billing-9c159d69375b4af095f0b67881276582?pvs=4">
|
||||
<Link href="https://www.notion.so/supabase/Org-Level-Billing-Public-Docs-f059a154beb743a19199d05bab4acb08">
|
||||
<a target="_blank" rel="noreferrer">
|
||||
<Button type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
|
||||
Documentation
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ScaffoldActionsContainer,
|
||||
ScaffoldSectionContent,
|
||||
} from 'components/layouts/Scaffold'
|
||||
import { useOrganizationMemberDeleteMutation } from 'data/organizations/organization-member-delete-mutation'
|
||||
|
||||
const TeamSettings = () => {
|
||||
const { ui } = useStore()
|
||||
@@ -49,6 +50,8 @@ const TeamSettings = () => {
|
||||
const canAddMembers = rolesAddable.length > 0
|
||||
const canLeave = !isOwner || (isOwner && hasMultipleOwners(members, roles))
|
||||
|
||||
const { mutateAsync: deleteMember } = useOrganizationMemberDeleteMutation()
|
||||
|
||||
const leaveTeam = async () => {
|
||||
setIsLeaving(true)
|
||||
try {
|
||||
@@ -56,13 +59,11 @@ const TeamSettings = () => {
|
||||
title: 'Are you sure?',
|
||||
message: 'Are you sure you want to leave this organization? This is permanent.',
|
||||
onAsyncConfirm: async () => {
|
||||
const response = await delete_<void>(
|
||||
`${API_URL}/organizations/${slug}/members/${profile!.gotrue_id}`
|
||||
)
|
||||
if (!isResponseOk(response)) {
|
||||
throw response.error
|
||||
} else {
|
||||
try {
|
||||
if (!slug) return console.error('Org slug is required')
|
||||
await deleteMember({ slug, gotrueId: profile!.gotrue_id })
|
||||
window?.location.replace('/') // Force reload to clear Store
|
||||
} finally {
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -171,7 +171,7 @@ const Usage = () => {
|
||||
"All Projects".
|
||||
</p>
|
||||
<div>
|
||||
<Link href="https://www.notion.so/supabase/Organization-Level-Billing-9c159d69375b4af095f0b67881276582?pvs=4">
|
||||
<Link href="https://www.notion.so/supabase/Org-Level-Billing-Public-Docs-f059a154beb743a19199d05bab4acb08">
|
||||
<a target="_blank" rel="noreferrer">
|
||||
<Button type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
|
||||
Documentation
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Editor, { Monaco, OnMount } from '@monaco-editor/react'
|
||||
import { useParams } from 'common'
|
||||
import { useRouter } from 'next/router'
|
||||
import { MutableRefObject, useRef, useState } from 'react'
|
||||
import { MutableRefObject, useRef } from 'react'
|
||||
import { cn } from 'ui'
|
||||
|
||||
import { SqlSnippet } from 'data/content/sql-snippets-query'
|
||||
@@ -27,7 +27,7 @@ const MonacoEditor = ({
|
||||
className,
|
||||
executeQuery,
|
||||
}: MonacoEditorProps) => {
|
||||
const { ref } = useParams()
|
||||
const { ref, content } = useParams()
|
||||
const router = useRouter()
|
||||
const { profile } = useProfile()
|
||||
const project = useSelectedProject()
|
||||
@@ -102,7 +102,7 @@ const MonacoEditor = ({
|
||||
onMount={handleEditorOnMount}
|
||||
onChange={handleEditorChange}
|
||||
defaultLanguage="pgsql"
|
||||
defaultValue={snippet?.snippet.content.sql}
|
||||
defaultValue={snippet?.snippet.content.sql ?? content}
|
||||
path={id}
|
||||
options={{
|
||||
tabSize: 2,
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
useStore,
|
||||
} from 'hooks'
|
||||
import { IS_PLATFORM } from 'lib/constants'
|
||||
import { uuidv4 } from 'lib/helpers'
|
||||
import { removeCommentsFromSql, uuidv4 } from 'lib/helpers'
|
||||
import { useProfile } from 'lib/profile'
|
||||
import Telemetry from 'lib/telemetry'
|
||||
import { getSqlEditorStateSnapshot, useSqlEditorStateSnapshot } from 'state/sql-editor'
|
||||
@@ -221,7 +221,9 @@ const SQLEditor = () => {
|
||||
? (selectedValue || editorRef.current?.getValue()) ?? snippet.snippet.content.sql
|
||||
: selectedValue || editorRef.current?.getValue()
|
||||
|
||||
const containsDestructiveOperations = destructiveSqlRegex.some((regex) => regex.test(sql))
|
||||
const containsDestructiveOperations = destructiveSqlRegex.some((regex) =>
|
||||
regex.test(removeCommentsFromSql(sql))
|
||||
)
|
||||
|
||||
if (!force && containsDestructiveOperations) {
|
||||
setIsConfirmModalOpen(true)
|
||||
|
||||
@@ -385,7 +385,7 @@ const ComputeInstanceSidePanel = () => {
|
||||
There are no immediate charges when changing compute. Compute Hours are a
|
||||
usage-based item and you're billed at the end of your billing cycle based on your
|
||||
compute usage. Read more about{' '}
|
||||
<Link href="https://www.notion.so/supabase/Organization-Level-Billing-9c159d69375b4af095f0b67881276582?pvs=4">
|
||||
<Link href="https://www.notion.so/supabase/Org-Level-Billing-Public-Docs-f059a154beb743a19199d05bab4acb08">
|
||||
<a target="_blank" rel="noreferrer" className="underline">
|
||||
Compute Billing
|
||||
</a>
|
||||
|
||||
@@ -125,7 +125,7 @@ const General = () => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{project && <RestartServerButton />}
|
||||
<RestartServerButton />
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between px-8 py-4">
|
||||
<div>
|
||||
@@ -136,7 +136,7 @@ const General = () => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{project && <PauseProjectButton />}
|
||||
<PauseProjectButton />
|
||||
</div>
|
||||
</FormPanel>
|
||||
</div>
|
||||
|
||||
+20
-35
@@ -3,32 +3,26 @@ import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
import { Button, IconPause } from 'ui'
|
||||
|
||||
import {
|
||||
useIsProjectActive,
|
||||
useProjectContext,
|
||||
} from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import ConfirmModal from 'components/ui/Dialogs/ConfirmDialog'
|
||||
import { useProjectPauseMutation } from 'data/projects/project-pause-mutation'
|
||||
import { setProjectStatus } from 'data/projects/projects-query'
|
||||
import { useCheckPermissions, useStore } from 'hooks'
|
||||
import { post } from 'lib/common/fetch'
|
||||
import { API_URL, PROJECT_STATUS } from 'lib/constants'
|
||||
import { Button, IconPause } from 'ui'
|
||||
|
||||
export interface PauseProjectButtonProps {}
|
||||
import { PROJECT_STATUS } from 'lib/constants'
|
||||
|
||||
const PauseProjectButton = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { ui } = useStore()
|
||||
const { project } = useProjectContext()
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const { project } = useProjectContext()
|
||||
const isProjectActive = useIsProjectActive()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
|
||||
const openModal = () => setIsModalOpen(true)
|
||||
const closeModal = () => setIsModalOpen(false)
|
||||
|
||||
const projectRef = project?.ref ?? ''
|
||||
const isPaused = project?.status === PROJECT_STATUS.INACTIVE
|
||||
const canPauseProject = useCheckPermissions(
|
||||
@@ -36,31 +30,22 @@ const PauseProjectButton = () => {
|
||||
'queue_jobs.projects.pause'
|
||||
)
|
||||
|
||||
const requestPauseProject = async () => {
|
||||
const { mutate: pauseProject, isLoading: isPausing } = useProjectPauseMutation({
|
||||
onSuccess: (res, variables) => {
|
||||
setProjectStatus(queryClient, variables.ref, PROJECT_STATUS.PAUSING)
|
||||
ui.setNotification({ category: 'success', message: 'Pausing project...' })
|
||||
router.push(`/project/${projectRef}`)
|
||||
},
|
||||
})
|
||||
|
||||
const requestPauseProject = () => {
|
||||
if (!canPauseProject) {
|
||||
return ui.setNotification({
|
||||
category: 'error',
|
||||
message: 'You do not have the required permissions to pause this project',
|
||||
})
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
const res = await post(`${API_URL}/projects/${projectRef}/pause`, {})
|
||||
|
||||
if (res.error) {
|
||||
ui.setNotification({
|
||||
error: res.error,
|
||||
category: 'error',
|
||||
message: `Failed to pause project: ${res.error.message}`,
|
||||
})
|
||||
setLoading(false)
|
||||
} else {
|
||||
setProjectStatus(queryClient, projectRef, PROJECT_STATUS.PAUSING)
|
||||
|
||||
ui.setNotification({ category: 'success', message: 'Pausing project' })
|
||||
router.push(`/project/${projectRef}`)
|
||||
}
|
||||
closeModal()
|
||||
pauseProject({ ref: projectRef })
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -70,14 +55,14 @@ const PauseProjectButton = () => {
|
||||
<Button
|
||||
type="default"
|
||||
icon={<IconPause />}
|
||||
onClick={openModal}
|
||||
loading={loading}
|
||||
disabled={isPaused || !canPauseProject || !isProjectActive}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
loading={isPausing}
|
||||
disabled={project === undefined || isPaused || !canPauseProject || !isProjectActive}
|
||||
>
|
||||
Pause Project
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
{isPaused || !canPauseProject || !isProjectActive ? (
|
||||
{project !== undefined && (isPaused || !canPauseProject || !isProjectActive) ? (
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
@@ -108,7 +93,7 @@ const PauseProjectButton = () => {
|
||||
description="Are you sure you want to pause this project? It will not be accessible until you unpause it."
|
||||
buttonLabel="Pause project"
|
||||
buttonLoadingLabel="Pausing project"
|
||||
onSelectCancel={closeModal}
|
||||
onSelectCancel={() => setIsModalOpen(false)}
|
||||
onSelectConfirm={requestPauseProject}
|
||||
/>
|
||||
</>
|
||||
|
||||
+16
-32
@@ -16,17 +16,17 @@ import {
|
||||
import { useParams } from 'common/hooks'
|
||||
import InformationBox from 'components/ui/InformationBox'
|
||||
import { useProjectUpgradeEligibilityQuery } from 'data/config/project-upgrade-eligibility-query'
|
||||
import { useProjectUpgradeMutation } from 'data/projects/project-upgrade-mutation'
|
||||
import { setProjectStatus } from 'data/projects/projects-query'
|
||||
import { useStore } from 'hooks'
|
||||
import { post } from 'lib/common/fetch'
|
||||
import { API_ADMIN_URL, PROJECT_STATUS } from 'lib/constants'
|
||||
import { PROJECT_STATUS } from 'lib/constants'
|
||||
import { BREAKING_CHANGES } from './ProjectUpgradeAlert.constants'
|
||||
|
||||
const ProjectUpgradeAlert = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const router = useRouter()
|
||||
const { ui } = useStore()
|
||||
const { ref } = useParams()
|
||||
const queryClient = useQueryClient()
|
||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false)
|
||||
|
||||
const formId = 'project-upgrade-form'
|
||||
@@ -35,33 +35,17 @@ const ProjectUpgradeAlert = () => {
|
||||
const latestPgVersion = (data?.latest_app_version ?? '').split('supabase-postgres-')[1]
|
||||
|
||||
const initialValues = { version: data?.target_upgrade_versions?.[0]?.postgres_version ?? 0 }
|
||||
|
||||
const onConfirmUpgrade = async (values: any, { setSubmitting }: any) => {
|
||||
setSubmitting(true)
|
||||
|
||||
if (!ref) {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: 'Project ref not found',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const res = await post(`${API_ADMIN_URL}/projects/${ref}/upgrade`, {
|
||||
target_version: values.version,
|
||||
})
|
||||
if (res.error) {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
error: res.error,
|
||||
message: `Failed to upgrade: ${res.error.message}`,
|
||||
})
|
||||
setSubmitting(false)
|
||||
} else {
|
||||
setProjectStatus(queryClient, ref, PROJECT_STATUS.UPGRADING)
|
||||
const { mutate: upgradeProject, isLoading: isUpgrading } = useProjectUpgradeMutation({
|
||||
onSuccess: (res, variables) => {
|
||||
setProjectStatus(queryClient, variables.ref, PROJECT_STATUS.UPGRADING)
|
||||
ui.setNotification({ category: 'success', message: 'Upgrading project' })
|
||||
router.push(`/project/${ref}?upgradeInitiated=true`)
|
||||
}
|
||||
router.push(`/project/${variables.ref}?upgradeInitiated=true`)
|
||||
},
|
||||
})
|
||||
|
||||
const onConfirmUpgrade = async (values: any) => {
|
||||
if (!ref) return ui.setNotification({ category: 'error', message: 'Project ref not found' })
|
||||
upgradeProject({ ref, target_version: values.version })
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -85,7 +69,7 @@ const ProjectUpgradeAlert = () => {
|
||||
header={<h3>Upgrade Postgres</h3>}
|
||||
>
|
||||
<Form id={formId} initialValues={initialValues} onSubmit={onConfirmUpgrade}>
|
||||
{({ values, isSubmitting }: { values: any; isSubmitting: boolean }) => {
|
||||
{({ values }: { values: any }) => {
|
||||
const selectedVersion = (data?.target_upgrade_versions ?? []).find(
|
||||
(x) => x.postgres_version === values.version
|
||||
)
|
||||
@@ -166,11 +150,11 @@ const ProjectUpgradeAlert = () => {
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() => setShowUpgradeModal(false)}
|
||||
disabled={isSubmitting}
|
||||
disabled={isUpgrading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button htmlType="submit" disabled={isSubmitting} loading={isSubmitting}>
|
||||
<Button htmlType="submit" disabled={isUpgrading} loading={isUpgrading}>
|
||||
Confirm upgrade
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
+35
-29
@@ -6,41 +6,57 @@ import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
import { Button, Dropdown, IconChevronDown } from 'ui'
|
||||
|
||||
import ConfirmModal from 'components/ui/Dialogs/ConfirmDialog'
|
||||
import { setProjectPostgrestStatus } from 'data/projects/projects-query'
|
||||
import { useCheckPermissions, useStore } from 'hooks'
|
||||
import { post } from 'lib/common/fetch'
|
||||
import { API_URL } from 'lib/constants'
|
||||
import {
|
||||
useIsProjectActive,
|
||||
useProjectContext,
|
||||
} from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import ConfirmModal from 'components/ui/Dialogs/ConfirmDialog'
|
||||
import { useProjectRestartMutation } from 'data/projects/project-restart-mutation'
|
||||
import { useProjectRestartServicesMutation } from 'data/projects/project-restart-services-mutation'
|
||||
import { setProjectPostgrestStatus } from 'data/projects/projects-query'
|
||||
import { useCheckPermissions, useStore } from 'hooks'
|
||||
|
||||
const RestartServerButton = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { project } = useProjectContext()
|
||||
const { ui } = useStore()
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const { project } = useProjectContext()
|
||||
const isProjectActive = useIsProjectActive()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [serviceToRestart, setServiceToRestart] = useState<'project' | 'database'>()
|
||||
|
||||
const projectRef = project?.ref ?? ''
|
||||
const projectRegion = project?.region
|
||||
const projectRegion = project?.region ?? ''
|
||||
|
||||
const canRestartProject = useCheckPermissions(PermissionAction.INFRA_EXECUTE, 'reboot')
|
||||
|
||||
const requestProjectRestart = async () => {
|
||||
const { mutate: restartProject, isLoading: isRestartingProject } = useProjectRestartMutation({
|
||||
onSuccess: () => {
|
||||
onRestartSuccess()
|
||||
},
|
||||
onError: (error) => {
|
||||
onRestartFailed(error, 'project')
|
||||
},
|
||||
})
|
||||
const { mutate: restartProjectServices, isLoading: isRestartingServices } =
|
||||
useProjectRestartServicesMutation({
|
||||
onSuccess: () => {
|
||||
onRestartSuccess()
|
||||
},
|
||||
onError: (error) => {
|
||||
onRestartFailed(error, 'database')
|
||||
},
|
||||
})
|
||||
|
||||
const isLoading = isRestartingProject || isRestartingServices
|
||||
|
||||
const requestProjectRestart = () => {
|
||||
if (!canRestartProject) {
|
||||
return ui.setNotification({
|
||||
category: 'error',
|
||||
message: 'You do not have the required permissions to restart this project',
|
||||
})
|
||||
}
|
||||
setLoading(true)
|
||||
const res = await post(`${API_URL}/projects/${projectRef}/restart`, {})
|
||||
if (res.error) onRestartFailed(res.error, 'project')
|
||||
else onRestartSuccess()
|
||||
restartProject({ ref: projectRef })
|
||||
}
|
||||
|
||||
const requestDatabaseRestart = async () => {
|
||||
@@ -50,15 +66,7 @@ const RestartServerButton = () => {
|
||||
message: 'You do not have the required permissions to restart this project',
|
||||
})
|
||||
}
|
||||
setLoading(true)
|
||||
const res = await post(`${API_URL}/projects/${projectRef}/restart-services`, {
|
||||
restartRequest: {
|
||||
region: projectRegion,
|
||||
services: ['postgresql'],
|
||||
},
|
||||
})
|
||||
if (res.error) onRestartFailed(res.error, 'database')
|
||||
else onRestartSuccess()
|
||||
restartProjectServices({ ref: projectRef, region: projectRegion, services: ['postgresql'] })
|
||||
}
|
||||
|
||||
const onRestartFailed = (error: any, type: string) => {
|
||||
@@ -67,15 +75,13 @@ const RestartServerButton = () => {
|
||||
category: 'error',
|
||||
message: `Unable to restart ${type}: ${error.message}`,
|
||||
})
|
||||
setLoading(false)
|
||||
setServiceToRestart(undefined)
|
||||
}
|
||||
|
||||
const onRestartSuccess = () => {
|
||||
setProjectPostgrestStatus(queryClient, projectRef, 'OFFLINE')
|
||||
ui.setNotification({ category: 'success', message: 'Restarting server' })
|
||||
ui.setNotification({ category: 'success', message: 'Restarting server...' })
|
||||
router.push(`/project/${projectRef}`)
|
||||
setLoading(false)
|
||||
setServiceToRestart(undefined)
|
||||
}
|
||||
|
||||
@@ -87,7 +93,7 @@ const RestartServerButton = () => {
|
||||
<Button
|
||||
type="default"
|
||||
className={`px-3 ${canRestartProject && isProjectActive ? 'rounded-r-none' : ''}`}
|
||||
disabled={!canRestartProject || !isProjectActive}
|
||||
disabled={project === undefined || !canRestartProject || !isProjectActive}
|
||||
onClick={() => setServiceToRestart('project')}
|
||||
>
|
||||
Restart project
|
||||
@@ -99,7 +105,7 @@ const RestartServerButton = () => {
|
||||
overlay={[
|
||||
<Dropdown.Item
|
||||
key="database"
|
||||
disabled={loading}
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
setServiceToRestart('database')
|
||||
}}
|
||||
@@ -124,7 +130,7 @@ const RestartServerButton = () => {
|
||||
)}
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
{(!canRestartProject || !isProjectActive) && (
|
||||
{project !== undefined && (!canRestartProject || !isProjectActive) && (
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
|
||||
+1
-1
@@ -214,7 +214,7 @@ const TransferProjectButton = () => {
|
||||
|
||||
<p className="text-scale-1000 text-sm">
|
||||
The target organization needs to use{' '}
|
||||
<Link href="https://www.notion.so/supabase/Organization-Level-Billing-9c159d69375b4af095f0b67881276582?pvs=4">
|
||||
<Link href="https://www.notion.so/supabase/Org-Level-Billing-Public-Docs-f059a154beb743a19199d05bab4acb08">
|
||||
<a target="_blank" rel="noreferrer" className="underline">
|
||||
organization-level-billing
|
||||
</a>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import HCaptcha from '@hcaptcha/react-hcaptcha'
|
||||
import { useStore } from 'hooks'
|
||||
import { isResponseOk, post } from 'lib/common/fetch'
|
||||
import { API_URL, BASE_PATH } from 'lib/constants'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useRef, useState } from 'react'
|
||||
import { Button, Form, Input } from 'ui'
|
||||
import { object, string } from 'yup'
|
||||
|
||||
import { useResetPasswordMutation } from 'data/misc/reset-password-mutation'
|
||||
import { useStore } from 'hooks'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
|
||||
const forgotPasswordSchema = object({
|
||||
email: string().email('Must be a valid email').required('Email is required'),
|
||||
})
|
||||
@@ -18,46 +19,41 @@ const ForgotPasswordForm = () => {
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
|
||||
const captchaRef = useRef<HCaptcha>(null)
|
||||
|
||||
const onForgotPassword = async ({ email }: { email: string }) => {
|
||||
const toastId = ui.setNotification({
|
||||
category: 'loading',
|
||||
message: `Sending password reset email...`,
|
||||
})
|
||||
const { mutate: resetPassword, isLoading } = useResetPasswordMutation({
|
||||
onSuccess: async () => {
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
message: `If you registered using your email and password, you will receive a password reset email.`,
|
||||
})
|
||||
await router.push('/sign-in')
|
||||
},
|
||||
onError: (error) => {
|
||||
setCaptchaToken(null)
|
||||
captchaRef.current?.resetCaptcha()
|
||||
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: `Failed to send reset email: ${error.message}`,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const onForgotPassword = async ({ email }: { email: string }) => {
|
||||
let token = captchaToken
|
||||
if (!token) {
|
||||
const captchaResponse = await captchaRef.current?.execute({ async: true })
|
||||
token = captchaResponse?.response ?? null
|
||||
}
|
||||
|
||||
const response = await post<void>(`${API_URL}/reset-password`, {
|
||||
resetPassword({
|
||||
email,
|
||||
hcaptchaToken: token ?? undefined,
|
||||
hcaptchaToken: token,
|
||||
redirectTo: `${
|
||||
process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview'
|
||||
? location.origin
|
||||
: process.env.NEXT_PUBLIC_SITE_URL
|
||||
}${BASE_PATH}/reset-password`,
|
||||
})
|
||||
|
||||
if (isResponseOk(response)) {
|
||||
ui.setNotification({
|
||||
id: toastId,
|
||||
category: 'success',
|
||||
message: `If you registered using your email and password, you will receive a password reset email.`,
|
||||
})
|
||||
|
||||
await router.push('/sign-in')
|
||||
} else {
|
||||
setCaptchaToken(null)
|
||||
captchaRef.current?.resetCaptcha()
|
||||
|
||||
ui.setNotification({
|
||||
id: toastId,
|
||||
category: 'error',
|
||||
message: `Failed to send reset email: ${response.error.message}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -68,7 +64,7 @@ const ForgotPasswordForm = () => {
|
||||
validationSchema={forgotPasswordSchema}
|
||||
onSubmit={onForgotPassword}
|
||||
>
|
||||
{({ isSubmitting }: { isSubmitting: boolean }) => {
|
||||
{() => {
|
||||
return (
|
||||
<div className="flex flex-col pt-4 space-y-4">
|
||||
<Input
|
||||
@@ -77,7 +73,7 @@ const ForgotPasswordForm = () => {
|
||||
type="email"
|
||||
label="Email"
|
||||
placeholder="you@example.com"
|
||||
disabled={isSubmitting}
|
||||
disabled={isLoading}
|
||||
autoComplete="email"
|
||||
/>
|
||||
|
||||
@@ -102,8 +98,8 @@ const ForgotPasswordForm = () => {
|
||||
form="forgot-password-form"
|
||||
htmlType="submit"
|
||||
size="medium"
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
>
|
||||
Send Reset Email
|
||||
</Button>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as yup from 'yup'
|
||||
import { useRef, useState } from 'react'
|
||||
import HCaptcha from '@hcaptcha/react-hcaptcha'
|
||||
import { useRef, useState } from 'react'
|
||||
import { Alert, Button, Form, IconEye, IconEyeOff, Input } from 'ui'
|
||||
import * as yup from 'yup'
|
||||
|
||||
import { useSignUpMutation } from 'data/misc/signup-mutation'
|
||||
import { useStore } from 'hooks'
|
||||
import { isResponseOk, post } from 'lib/common/fetch'
|
||||
import { API_URL, BASE_PATH } from 'lib/constants'
|
||||
import { passwordSchema } from 'lib/schemas'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { resetSignInClicks } from 'lib/local-storage'
|
||||
import { passwordSchema } from 'lib/schemas'
|
||||
import PasswordConditionsHelper from './PasswordConditionsHelper'
|
||||
|
||||
const signUpSchema = passwordSchema.shape({
|
||||
@@ -22,12 +22,25 @@ const SignUpForm = () => {
|
||||
const [passwordHidden, setPasswordHidden] = useState(true)
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
|
||||
|
||||
const onSignUp = async ({ email, password }: { email: string; password: string }) => {
|
||||
const toastId = ui.setNotification({
|
||||
category: 'loading',
|
||||
message: `Signing up...`,
|
||||
})
|
||||
const { mutate: signup, isLoading: isSigningUp } = useSignUpMutation({
|
||||
onSuccess: () => {
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
message: `Signed up successfully!`,
|
||||
})
|
||||
setIsSubmitted(true)
|
||||
},
|
||||
onError: (error) => {
|
||||
setCaptchaToken(null)
|
||||
captchaRef.current?.resetCaptcha()
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: `Failed to sign up: ${error.message}`,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const onSignUp = async ({ email, password }: { email: string; password: string }) => {
|
||||
let token = captchaToken
|
||||
if (!token) {
|
||||
const captchaResponse = await captchaRef.current?.execute({ async: true })
|
||||
@@ -35,8 +48,7 @@ const SignUpForm = () => {
|
||||
}
|
||||
|
||||
resetSignInClicks()
|
||||
|
||||
const response = await post<void>(`${API_URL}/signup`, {
|
||||
signup({
|
||||
email,
|
||||
password,
|
||||
hcaptchaToken: token ?? null,
|
||||
@@ -46,25 +58,6 @@ const SignUpForm = () => {
|
||||
: process.env.NEXT_PUBLIC_SITE_URL
|
||||
}${BASE_PATH}/sign-in`,
|
||||
})
|
||||
|
||||
if (isResponseOk(response)) {
|
||||
ui.setNotification({
|
||||
id: toastId,
|
||||
category: 'success',
|
||||
message: `Signed up successfully!`,
|
||||
})
|
||||
|
||||
setIsSubmitted(true)
|
||||
} else {
|
||||
setCaptchaToken(null)
|
||||
captchaRef.current?.resetCaptcha()
|
||||
|
||||
ui.setNotification({
|
||||
id: toastId,
|
||||
category: 'error',
|
||||
message: `Failed to sign up: ${response.error.message}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -89,7 +82,7 @@ const SignUpForm = () => {
|
||||
validationSchema={signUpSchema}
|
||||
onSubmit={onSignUp}
|
||||
>
|
||||
{({ isSubmitting, values }: { isSubmitting: boolean; values: any }) => {
|
||||
{({ values }: { values: any }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
@@ -98,7 +91,7 @@ const SignUpForm = () => {
|
||||
type="email"
|
||||
label="Email"
|
||||
placeholder="you@example.com"
|
||||
disabled={isSubmitting}
|
||||
disabled={isSigningUp}
|
||||
autoComplete="email"
|
||||
/>
|
||||
|
||||
@@ -108,7 +101,7 @@ const SignUpForm = () => {
|
||||
type={passwordHidden ? 'password' : 'text'}
|
||||
label="Password"
|
||||
placeholder="••••••••"
|
||||
disabled={isSubmitting}
|
||||
disabled={isSigningUp}
|
||||
autoComplete="new-password"
|
||||
onFocus={() => setShowConditions(true)}
|
||||
actions={
|
||||
@@ -148,8 +141,8 @@ const SignUpForm = () => {
|
||||
form="signUp-form"
|
||||
htmlType="submit"
|
||||
size="large"
|
||||
disabled={values.password.length === 0 || isSubmitting}
|
||||
loading={isSubmitting}
|
||||
disabled={values.password.length === 0 || isSigningUp}
|
||||
loading={isSigningUp}
|
||||
>
|
||||
Sign Up
|
||||
</Button>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useParams } from 'common'
|
||||
import { CLIENT_LIBRARIES } from 'common/constants'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import Link from 'next/link'
|
||||
@@ -16,17 +17,17 @@ import {
|
||||
Listbox,
|
||||
} from 'ui'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import Divider from 'components/ui/Divider'
|
||||
import InformationBox from 'components/ui/InformationBox'
|
||||
import MultiSelect from 'components/ui/MultiSelect'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import { useSendSupportTicketMutation } from 'data/feedback/support-ticket-send'
|
||||
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
|
||||
import { useProjectsQuery } from 'data/projects/projects-query'
|
||||
import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query'
|
||||
import { useFlag, useStore } from 'hooks'
|
||||
import useLatest from 'hooks/misc/useLatest'
|
||||
import { get, isResponseOk, post } from 'lib/common/fetch'
|
||||
import { get } from 'lib/common/fetch'
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { detectBrowser } from 'lib/helpers'
|
||||
import { useProfile } from 'lib/profile'
|
||||
@@ -48,6 +49,7 @@ const SupportForm = ({ setSentCategory }: SupportFormProps) => {
|
||||
|
||||
const uploadButtonRef = useRef()
|
||||
const enableFreeSupport = useFlag('enableFreeSupport')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [uploadedFiles, setUploadedFiles] = useState<File[]>([])
|
||||
const [uploadedDataUrls, setUploadedDataUrls] = useState<string[]>([])
|
||||
const [selectedServices, setSelectedServices] = useState<string[]>([])
|
||||
@@ -68,6 +70,21 @@ const SupportForm = ({ setSentCategory }: SupportFormProps) => {
|
||||
isSuccess: isSuccessProjects,
|
||||
} = useProjectsQuery()
|
||||
|
||||
const { mutate: submitSupportTicket } = useSendSupportTicketMutation({
|
||||
onSuccess: (res, variables) => {
|
||||
ui.setNotification({ category: 'success', message: 'Support request sent. Thank you!' })
|
||||
setSentCategory(variables.category)
|
||||
},
|
||||
onError: (error) => {
|
||||
ui.setNotification({
|
||||
error,
|
||||
category: 'error',
|
||||
message: `Failed to submit support ticket: ${error.message}`,
|
||||
})
|
||||
setIsSubmitting(false)
|
||||
},
|
||||
})
|
||||
|
||||
const projectDefaults: Partial<Project>[] = [{ ref: 'no-project', name: 'No specific project' }]
|
||||
|
||||
const projects = [...(allProjects ?? []), ...projectDefaults]
|
||||
@@ -160,8 +177,8 @@ const SupportForm = ({ setSentCategory }: SupportFormProps) => {
|
||||
return errors
|
||||
}
|
||||
|
||||
const onSubmit = async (values: any, { setSubmitting }: any) => {
|
||||
setSubmitting(true)
|
||||
const onSubmit = async (values: any) => {
|
||||
setIsSubmitting(true)
|
||||
const attachments =
|
||||
uploadedFiles.length > 0 ? await uploadAttachments(values.projectRef, uploadedFiles) : []
|
||||
const selectedLibrary = CLIENT_LIBRARIES.find((library) => library.language === values.library)
|
||||
@@ -183,6 +200,8 @@ const SupportForm = ({ setSentCategory }: SupportFormProps) => {
|
||||
|
||||
if (values.projectRef !== 'no-project') {
|
||||
const URL = `${API_URL}/auth/${values.projectRef}/config`
|
||||
// [Joshen] Will refactor another time once
|
||||
// https://github.com/supabase/supabase/pull/16227 goes in
|
||||
const authConfig = await get(URL)
|
||||
if (!authConfig.error) {
|
||||
payload.siteUrl = authConfig.SITE_URL
|
||||
@@ -190,22 +209,12 @@ const SupportForm = ({ setSentCategory }: SupportFormProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
const response = await post<void>(`${API_URL}/feedback/send`, payload)
|
||||
if (!isResponseOk(response)) {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: `Failed to submit support ticket: ${response.error.message}`,
|
||||
})
|
||||
setSubmitting(false)
|
||||
} else {
|
||||
ui.setNotification({ category: 'success', message: 'Support request sent. Thank you!' })
|
||||
setSentCategory(values.category)
|
||||
}
|
||||
submitSupportTicket(payload)
|
||||
}
|
||||
|
||||
return (
|
||||
<Form id="support-form" initialValues={initialValues} validate={onValidate} onSubmit={onSubmit}>
|
||||
{({ isSubmitting, resetForm, values }: any) => {
|
||||
{({ resetForm, values }: any) => {
|
||||
const selectedCategory = CATEGORY_OPTIONS.find(
|
||||
(category) => category.value === values.category
|
||||
)
|
||||
|
||||
+14
-12
@@ -243,18 +243,20 @@ const ColumnEditor = ({
|
||||
<FormSectionLabel
|
||||
className="lg:!col-span-4"
|
||||
description={
|
||||
<Link href="https://supabase.com/docs/guides/database/tables#data-types" passHref>
|
||||
<Button
|
||||
asChild
|
||||
type="default"
|
||||
size="tiny"
|
||||
icon={<IconExternalLink size={14} strokeWidth={2} />}
|
||||
>
|
||||
<a target="_blank" rel="noreferrer">
|
||||
About data types
|
||||
</a>
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<Link href="https://supabase.com/docs/guides/database/tables#data-types" passHref>
|
||||
<Button
|
||||
asChild
|
||||
type="default"
|
||||
size="tiny"
|
||||
icon={<IconExternalLink size={14} strokeWidth={2} />}
|
||||
>
|
||||
<a target="_blank" rel="noreferrer">
|
||||
About data types
|
||||
</a>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
Data Type
|
||||
|
||||
+5
-28
@@ -1,7 +1,8 @@
|
||||
import Editor, { useMonaco } from '@monaco-editor/react'
|
||||
import { useTheme } from 'common'
|
||||
import Editor from '@monaco-editor/react'
|
||||
import { noop } from 'lodash'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useRef } from 'react'
|
||||
|
||||
// [Joshen] Should just use CodeEditor instead of declaring Editor here so that all the mount logic is consistent
|
||||
|
||||
interface JsonEditorProps {
|
||||
queryId?: string
|
||||
@@ -16,31 +17,7 @@ const JsonEditor = ({
|
||||
readOnly = false,
|
||||
onInputChange = noop,
|
||||
}: JsonEditorProps) => {
|
||||
const monaco = useMonaco()
|
||||
const editorRef = useRef()
|
||||
const { isDarkMode } = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
if (monaco) {
|
||||
// Supabase theming (Can't seem to get it to work for now)
|
||||
monaco.editor.defineTheme('supabase', {
|
||||
base: 'vs-dark', // can also be vs-dark or hc-black
|
||||
inherit: true, // can also be false to completely replace the builtin rules
|
||||
rules: [
|
||||
// @ts-ignore
|
||||
{ background: isDarkMode ? '1f1f1f' : '30313f' },
|
||||
{ token: 'string.sql', foreground: '24b47e' },
|
||||
{ token: 'comment', foreground: '666666' },
|
||||
{ token: 'predefined.sql', foreground: 'D4D4D4' },
|
||||
],
|
||||
colors: {
|
||||
'editor.background': isDarkMode ? '#1f1f1f' : '#30313f',
|
||||
// 'editorGutter.background': '#30313f',
|
||||
// 'editorLineNumber.foreground': '#555671',
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [isDarkMode, monaco])
|
||||
|
||||
const onMount = (editor: any, monaco: any) => {
|
||||
editorRef.current = editor
|
||||
@@ -60,7 +37,7 @@ const JsonEditor = ({
|
||||
return (
|
||||
<Editor
|
||||
className="monaco-editor"
|
||||
theme="vs-dark"
|
||||
theme="supabase"
|
||||
defaultLanguage="json"
|
||||
defaultValue={defaultValue}
|
||||
path={queryId}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PropsWithChildren, useEffect, useState } from 'react'
|
||||
import { useParams } from 'common'
|
||||
import Error from 'components/ui/Error'
|
||||
import ProductMenu from 'components/ui/ProductMenu'
|
||||
import { useAuthConfigPrefetch } from 'data/auth/auth-config-query'
|
||||
import { useStore, withAuth } from 'hooks'
|
||||
import ProjectLayout from '../'
|
||||
import { generateAuthMenu } from './AuthLayout.utils'
|
||||
@@ -14,9 +15,10 @@ export interface AuthLayoutProps {
|
||||
}
|
||||
|
||||
const AuthLayout = ({ title, children }: PropsWithChildren<AuthLayoutProps>) => {
|
||||
const { ui, meta, authConfig } = useStore()
|
||||
const { ui, meta } = useStore()
|
||||
const { isInitialized, isLoading, error } = meta.tables
|
||||
const { ref: projectRef = 'default' } = useParams()
|
||||
useAuthConfigPrefetch({ projectRef })
|
||||
|
||||
const router = useRouter()
|
||||
const page = router.pathname.split('/')[4]
|
||||
@@ -29,7 +31,6 @@ const AuthLayout = ({ title, children }: PropsWithChildren<AuthLayoutProps>) =>
|
||||
meta.tables.load()
|
||||
meta.roles.load()
|
||||
meta.schemas.load()
|
||||
authConfig.load()
|
||||
}
|
||||
}, [ui.selectedProjectRef])
|
||||
|
||||
|
||||
@@ -90,6 +90,12 @@ export const generateDatabaseMenu = (
|
||||
url: `/project/${ref}/database/migrations`,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
name: 'Indexes',
|
||||
key: 'indexes',
|
||||
url: `/project/${ref}/database/indexes`,
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -11,12 +11,15 @@ import { invalidateProjectsQuery } from 'data/projects/projects-query'
|
||||
import { getWithTimeout } from 'lib/common/fetch'
|
||||
import { API_URL, PROJECT_STATUS } from 'lib/constants'
|
||||
import { Project } from 'types'
|
||||
import { invalidateProjectDetailsQuery } from 'data/projects/project-detail-query'
|
||||
import { useParams } from 'common'
|
||||
|
||||
export interface BuildingStateProps {
|
||||
project: Project
|
||||
}
|
||||
|
||||
const BuildingState = ({ project }: BuildingStateProps) => {
|
||||
const { ref } = useParams()
|
||||
const queryClient = useQueryClient()
|
||||
const checkServerInterval = useRef<number>()
|
||||
|
||||
@@ -31,7 +34,7 @@ const BuildingState = ({ project }: BuildingStateProps) => {
|
||||
const { status } = projectStatus
|
||||
if (status === PROJECT_STATUS.ACTIVE_HEALTHY) {
|
||||
clearInterval(checkServerInterval.current)
|
||||
|
||||
if (ref) await invalidateProjectDetailsQuery(queryClient, ref)
|
||||
await invalidateProjectsQuery(queryClient)
|
||||
}
|
||||
}
|
||||
|
||||
+22
-28
@@ -21,6 +21,7 @@ import { delete_, post } from 'lib/common/fetch'
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { Project } from 'types'
|
||||
import NotificationRow from './NotificationRow'
|
||||
import { useProjectRestartServicesMutation } from 'data/projects/project-restart-services-mutation'
|
||||
|
||||
interface NotificationsPopoverProps {
|
||||
alt?: boolean
|
||||
@@ -40,6 +41,17 @@ const NotificationsPopover = ({ alt = false }: NotificationsPopoverProps) => {
|
||||
const [projectToRollbackMigration, setProjectToRollbackMigration] = useState<Project>()
|
||||
const [targetNotification, setTargetNotification] = useState<Notification>()
|
||||
|
||||
const { mutate: restartProjectServices } = useProjectRestartServicesMutation({
|
||||
onSuccess: (res, variables) => {
|
||||
setProjectToRestart(undefined)
|
||||
setTargetNotification(undefined)
|
||||
|
||||
setProjectPostgrestStatus(queryClient, variables.ref, 'OFFLINE')
|
||||
ui.setNotification({ category: 'success', message: `Restarting services` })
|
||||
router.push(`/project/${variables.ref}`)
|
||||
},
|
||||
})
|
||||
|
||||
const newNotifications =
|
||||
notifications?.filter(
|
||||
(notification) => notification.notification_status === NotificationStatus.New
|
||||
@@ -72,41 +84,22 @@ const NotificationsPopover = ({ alt = false }: NotificationsPopoverProps) => {
|
||||
.map((action) => action.action_type)
|
||||
.filter((actionName) => Object.keys(serviceNamesByActionName).indexOf(actionName) !== -1)
|
||||
.map((actionName) => serviceNamesByActionName[actionName])
|
||||
const { error } = await post(`${API_URL}/projects/${ref}/restart-services`, {
|
||||
restartRequest: {
|
||||
region,
|
||||
source_notification_id: id,
|
||||
services: services,
|
||||
},
|
||||
})
|
||||
|
||||
if (error) {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: `Failed to restart project: ${error.message}`,
|
||||
error,
|
||||
})
|
||||
} else {
|
||||
setProjectPostgrestStatus(queryClient, ref, 'OFFLINE')
|
||||
ui.setNotification({ category: 'success', message: `Restarting services` })
|
||||
router.push(`/project/${ref}`)
|
||||
}
|
||||
|
||||
setProjectToRestart(undefined)
|
||||
setTargetNotification(undefined)
|
||||
restartProjectServices({ ref, region, services: services as any, source_notification_id: id })
|
||||
}
|
||||
|
||||
const onConfirmProjectApplyMigration = async () => {
|
||||
if (!projectToApplyMigration) return
|
||||
|
||||
const data = targetNotification?.data as PostgresqlUpgradeData
|
||||
const { resource } = data.additional as any
|
||||
if (!resource) return
|
||||
|
||||
// [Joshen] Leaving this as an exception for RQ due to dynamic URL
|
||||
const res = await post(`${API_URL}/database/${projectToApplyMigration.ref}/${resource}`, {})
|
||||
if (!res.error) {
|
||||
const project = await getProjectDetail({ ref: projectToApplyMigration.ref })
|
||||
if (project) {
|
||||
meta.setProjectDetails(project)
|
||||
}
|
||||
if (project) meta.setProjectDetails(project)
|
||||
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
@@ -124,18 +117,19 @@ const NotificationsPopover = ({ alt = false }: NotificationsPopoverProps) => {
|
||||
|
||||
const onConfirmProjectRollbackMigration = async () => {
|
||||
if (!projectToRollbackMigration) return
|
||||
|
||||
const data = targetNotification?.data as PostgresqlUpgradeData
|
||||
const { resource } = data.additional as any
|
||||
if (!resource) return
|
||||
|
||||
// [Joshen] Leaving this as an exception for RQ due to dynamic URL
|
||||
const res = await delete_(
|
||||
`${API_URL}/database/${projectToRollbackMigration.ref}/${resource}`,
|
||||
{}
|
||||
)
|
||||
if (!res.error) {
|
||||
const project = await getProjectDetail({ ref: projectToRollbackMigration.ref })
|
||||
if (project) {
|
||||
meta.setProjectDetails(project)
|
||||
}
|
||||
if (project) meta.setProjectDetails(project)
|
||||
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
@@ -224,7 +218,7 @@ const NotificationsPopover = ({ alt = false }: NotificationsPopoverProps) => {
|
||||
className={alt ? 'px-1' : ''}
|
||||
icon={
|
||||
hasNewNotifications ? (
|
||||
<div className="-mr-3.5 z-10 h-4 w-4 flex items-center justify-center rounded-full bg-white">
|
||||
<div className="-mr-3.5 z-10 h-4 w-4 flex items-center justify-center rounded-full bg-black dark:bg-white">
|
||||
<p className="text-xs text-scale-100">{newNotifications.length}</p>
|
||||
</div>
|
||||
) : alt ? (
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
} from './NavigationBar.utils'
|
||||
import NavigationIconButton from './NavigationIconButton'
|
||||
|
||||
const NavigationBar = ({}) => {
|
||||
const NavigationBar = () => {
|
||||
const router = useRouter()
|
||||
const { isDarkMode, toggleTheme } = useTheme()
|
||||
const { ref: projectRef } = useParams()
|
||||
|
||||
@@ -1,11 +1,43 @@
|
||||
import { Project } from 'types'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import { Badge, IconCircle, IconLoader } from 'ui'
|
||||
|
||||
import { useProjectStatusQuery } from 'data/projects/project-status-query'
|
||||
import { invalidateProjectsQuery } from 'data/projects/projects-query'
|
||||
import { PROJECT_STATUS } from 'lib/constants'
|
||||
import { Project } from 'types'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { invalidateProjectDetailsQuery } from 'data/projects/project-detail-query'
|
||||
|
||||
export interface PausingStateProps {
|
||||
project: Project
|
||||
}
|
||||
|
||||
const PausingState = ({ project }: PausingStateProps) => {
|
||||
const { ref } = useParams()
|
||||
const queryClient = useQueryClient()
|
||||
const [startPolling, setStartPolling] = useState(false)
|
||||
|
||||
useProjectStatusQuery(
|
||||
{ projectRef: ref },
|
||||
{
|
||||
enabled: startPolling,
|
||||
refetchInterval: (res) => {
|
||||
return res?.status === PROJECT_STATUS.INACTIVE ? false : 2000
|
||||
},
|
||||
onSuccess: async (res) => {
|
||||
if (res.status === PROJECT_STATUS.INACTIVE) {
|
||||
if (ref) await invalidateProjectDetailsQuery(queryClient, ref)
|
||||
await invalidateProjectsQuery(queryClient)
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => setStartPolling(true), 4000)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="mx-auto my-16 w-full max-w-7xl space-y-16">
|
||||
<div className="mx-6 space-y-16">
|
||||
|
||||
Loaded 100 of 177 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user