mirror of
https://github.com/supabase/supabase.git
synced 2026-10-07 18:35:07 +03:00
Pull master and resolve conflicts
This commit is contained in:
commit
9be1ee3a4e
147 files changed
+32263
-8152
No files matched your search
+10
-5
@@ -4,11 +4,12 @@
|
||||
* [Installing Dependencies](#Installing-Dependencies)
|
||||
* [Building Supabase](#Building-Supabase)
|
||||
* [Start a Development Server](#Start-a-Development-Server)
|
||||
* [Build in Public](#Build-in-Public)
|
||||
* [Finally](#Finally)
|
||||
* [Community Channels](#Community-Channels)
|
||||
|
||||
## Development Setup
|
||||
|
||||
This document describes how to set up your development environment to build and test Supabase.
|
||||
First off, thanks for your interest in Supabase and for wanting to contribute! before you begin, read the [code of conduct](https://github.com/supabase/.github/blob/main/CODE_OF_CONDUCT.md) and check out the [existing issues](https://github.com/supabase/supabase/issues). This document describes how to set up your development environment to build and test Supabase.
|
||||
|
||||
### Installing Dependencies
|
||||
|
||||
@@ -150,8 +151,12 @@ To debug code, and to see changes in real time, it is often useful to have a loc
|
||||
|
||||
For more information on Supabase Studio, see the [Supabase Studio readme](./studio/README.md).
|
||||
|
||||
## Build in Public
|
||||
## Finally
|
||||
|
||||
Videos of building supabase made in public.
|
||||
After making your changes to the file(s) you'd like to update, it's time to open a pull request. Once you submit your pull request, others from the Supabase team/community will review it with you.
|
||||
|
||||
- [Contributing to open source as a beginner - Supabase](https://youtu.be/OAqhsylUuWg) - [@imskr](https://github.com/imskr)
|
||||
Did you have an issue, like a merge conflict, or don't know how to open a pull request? Check out [GitHub's pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests) tutorial on how to resolve merge conflicts and other issues. Once your PR has been merged, you will be proudly listed as a contributor in the [contributor chart](https://github.com/supabase/supabase/graphs/contributors)
|
||||
|
||||
## Community Channels
|
||||
|
||||
Stuck somewhere? Have any questions? please join the [Discord Community Server](https://discord.supabase.com/) or the [Github Discussions](https://github.com/supabase/supabase/discussions). We are here to help!
|
||||
@@ -194,7 +194,7 @@ Our approach for client libraries is modular. Each sub-library is a standalone i
|
||||
- [Hungarian / Magyar](/i18n/README.hu.md)
|
||||
- [Nepali / नेपाली](/i18n/README.ne.md)
|
||||
- [Indonesian / Bahasa Indonesia](/i18n/README.id.md)
|
||||
- [Italiano / Italian](/i18n/README.it.md)
|
||||
- [Italian / Italiano](/i18n/README.it.md)
|
||||
- [Japanese / 日本語](/i18n/README.jp.md)
|
||||
- [Korean / 한국어](/i18n/README.ko.md)
|
||||
- [Malay / Bahasa Malaysia](/i18n/README.ms.md)
|
||||
|
||||
+1
-25
@@ -4,28 +4,4 @@ title: Work at Supabase
|
||||
description: We're hiring. Start your career at Supabase.
|
||||
---
|
||||
|
||||
import Roles from '@site/docs/careers/_snippets/roles.mdx'
|
||||
import About from '@site/docs/careers/_snippets/about.mdx'
|
||||
import Team from '@site/docs/careers/_snippets/team.mdx'
|
||||
import Apply from '@site/docs/careers/_snippets/apply.mdx'
|
||||
import Process from '@site/docs/careers/_snippets/process.mdx'
|
||||
|
||||
## Roles
|
||||
|
||||
<Roles />
|
||||
|
||||
## About Supabase
|
||||
|
||||
<About />
|
||||
|
||||
## About the team
|
||||
|
||||
<Team />
|
||||
|
||||
## Apply
|
||||
|
||||
<Apply />
|
||||
|
||||
## Process
|
||||
|
||||
<Process />
|
||||
[Browse open roles](https://boards.greenhouse.io/supabase).
|
||||
Generated
+3
-44
@@ -3552,17 +3552,6 @@
|
||||
"resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz",
|
||||
"integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw=="
|
||||
},
|
||||
"clipboard": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.6.tgz",
|
||||
"integrity": "sha512-g5zbiixBRk/wyKakSwCKd7vQXDjFnAMGHoEyBogG/bw9kTD9GvdAvaoRR1ALcEzt3pVKxZR0pViekPMIS0QyGg==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"good-listener": "^1.2.2",
|
||||
"select": "^1.1.2",
|
||||
"tiny-emitter": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"cliui": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz",
|
||||
@@ -5248,12 +5237,6 @@
|
||||
"robust-predicates": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"delegate": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz",
|
||||
"integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==",
|
||||
"optional": true
|
||||
},
|
||||
"depd": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
|
||||
@@ -6673,15 +6656,6 @@
|
||||
"slash": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"good-listener": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz",
|
||||
"integrity": "sha1-1TswzfkxPf+33JoNR3CWqm0UXFA=",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"delegate": "^3.1.2"
|
||||
}
|
||||
},
|
||||
"got": {
|
||||
"version": "9.6.0",
|
||||
"resolved": "https://registry.npmjs.org/got/-/got-9.6.0.tgz",
|
||||
@@ -10542,12 +10516,9 @@
|
||||
"integrity": "sha512-MgMhSdHuHymNRqD6KM3eGS0PNqgK9q4QF5P0yoQQvpB6jNjeSAi3jcSAz0Sua/t9fa4xDOMar9HJbLa08gl9ug=="
|
||||
},
|
||||
"prismjs": {
|
||||
"version": "1.22.0",
|
||||
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.22.0.tgz",
|
||||
"integrity": "sha512-lLJ/Wt9yy0AiSYBf212kK3mM5L8ycwlyTlSxHBAneXLR0nzFMlZ5y7riFPF3E33zXOF2IH95xdY5jIyZbM9z/w==",
|
||||
"requires": {
|
||||
"clipboard": "^2.0.0"
|
||||
}
|
||||
"version": "1.27.0",
|
||||
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.27.0.tgz",
|
||||
"integrity": "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA=="
|
||||
},
|
||||
"process": {
|
||||
"version": "0.11.10",
|
||||
@@ -11939,12 +11910,6 @@
|
||||
"integrity": "sha1-CbqfG9W0b7sTRxjrGaaDgsuxuck=",
|
||||
"dev": true
|
||||
},
|
||||
"select": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz",
|
||||
"integrity": "sha1-DnNQrN7ICxEIUoeG7B1EGNEbOW0=",
|
||||
"optional": true
|
||||
},
|
||||
"select-hose": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
|
||||
@@ -13111,12 +13076,6 @@
|
||||
"resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz",
|
||||
"integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q="
|
||||
},
|
||||
"tiny-emitter": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
|
||||
"integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==",
|
||||
"optional": true
|
||||
},
|
||||
"tiny-invariant": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz",
|
||||
|
||||
+5
-2
@@ -10,8 +10,6 @@ SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZX
|
||||
|
||||
## General
|
||||
SITE_URL=http://localhost:3000
|
||||
KONG_URL=http://kong:8000
|
||||
META_URL=http://meta:8080
|
||||
ADDITIONAL_REDIRECT_URLS=
|
||||
JWT_EXPIRY=3600
|
||||
DISABLE_SIGNUP=false
|
||||
@@ -41,3 +39,8 @@ KONG_HTTPS_PORT=8443
|
||||
|
||||
## DB port
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# Studio
|
||||
|
||||
## Public REST endpoint - replace this if you intend to use Studio outside of localhost
|
||||
PUBLIC_REST_URL=http://localhost:8000/rest/v1/
|
||||
@@ -14,8 +14,11 @@ services:
|
||||
ports:
|
||||
- ${STUDIO_PORT}:3000/tcp
|
||||
environment:
|
||||
SUPABASE_URL: ${KONG_URL}
|
||||
STUDIO_PG_META_URL: ${META_URL}
|
||||
STUDIO_PG_META_URL: http://meta:8080
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
|
||||
SUPABASE_URL: http://kong:8000
|
||||
SUPABASE_REST_URL: ${PUBLIC_REST_URL}
|
||||
SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
||||
|
||||
@@ -52,9 +55,11 @@ services:
|
||||
GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS}
|
||||
GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP}
|
||||
|
||||
GOTRUE_JWT_SECRET: ${JWT_SECRET}
|
||||
GOTRUE_JWT_EXP: ${JWT_EXPIRY}
|
||||
GOTRUE_JWT_ADMIN_ROLES: service_role
|
||||
GOTRUE_JWT_AUD: authenticated
|
||||
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
|
||||
GOTRUE_JWT_EXP: ${JWT_EXPIRY}
|
||||
GOTRUE_JWT_SECRET: ${JWT_SECRET}
|
||||
|
||||
GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP}
|
||||
GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM}
|
||||
|
||||
+3
-3
@@ -10312,9 +10312,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"nanoid": {
|
||||
"version": "3.1.23",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.23.tgz",
|
||||
"integrity": "sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw==",
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.1.tgz",
|
||||
"integrity": "sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw==",
|
||||
"dev": true
|
||||
},
|
||||
"nanomatch": {
|
||||
|
||||
+8
-14
@@ -6035,13 +6035,12 @@
|
||||
}
|
||||
},
|
||||
"plist": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/plist/-/plist-3.0.1.tgz",
|
||||
"integrity": "sha512-GpgvHHocGRyQm74b6FWEZZVRroHKE1I0/BTjAmySaohK+cUn+hZpbqXkc3KWgW3gQYkqcQej35FohcT0FRlkRQ==",
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/plist/-/plist-3.0.4.tgz",
|
||||
"integrity": "sha512-ksrr8y9+nXOxQB2osVNqrgvX/XQPOXaU4BQMKjYq8PvaY1U18mo+fKgBSwzK+luSyinOuPae956lSVcBwxlAMg==",
|
||||
"requires": {
|
||||
"base64-js": "^1.2.3",
|
||||
"xmlbuilder": "^9.0.7",
|
||||
"xmldom": "0.1.x"
|
||||
"base64-js": "^1.5.1",
|
||||
"xmlbuilder": "^9.0.7"
|
||||
}
|
||||
},
|
||||
"plugin-error": {
|
||||
@@ -7538,9 +7537,9 @@
|
||||
"integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI="
|
||||
},
|
||||
"url-parse": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.3.tgz",
|
||||
"integrity": "sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ==",
|
||||
"version": "1.5.10",
|
||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
|
||||
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
|
||||
"requires": {
|
||||
"querystringify": "^2.1.1",
|
||||
"requires-port": "^1.0.0"
|
||||
@@ -7852,11 +7851,6 @@
|
||||
"sax": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"xmldom": {
|
||||
"version": "0.1.31",
|
||||
"resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.31.tgz",
|
||||
"integrity": "sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ=="
|
||||
},
|
||||
"xpipe": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/xpipe/-/xpipe-1.0.5.tgz",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true,
|
||||
"40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
.env
|
||||
npm-debug.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
web-build/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'react-native-url-polyfill/auto'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { supabase } from './lib/supabase'
|
||||
import Auth from './components/Auth'
|
||||
import Account from './components/Account'
|
||||
import { View } from 'react-native'
|
||||
import { Session } from '@supabase/supabase-js'
|
||||
|
||||
export default function App() {
|
||||
const [session, setSession] = useState<Session | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setSession(supabase.auth.session())
|
||||
|
||||
supabase.auth.onAuthStateChange((_event, session) => {
|
||||
setSession(session)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View>
|
||||
{session && session.user ? <Account key={session.user.id} session={session} /> : <Auth />}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# React Native User Management example with Expo
|
||||
|
||||
## Requirements
|
||||
|
||||
- Install the [Expo CLI](https://docs.expo.io/get-started/installation/)
|
||||
|
||||
## Setup & run locally
|
||||
|
||||
### 1. Create new project
|
||||
|
||||
Sign up to Supabase - [https://app.supabase.io](https://app.supabase.io) and create a new project. Wait for your database to start.
|
||||
|
||||
### 2. Run "User Management Starter" Quickstart
|
||||
|
||||
Once your database has started, run the "User Management Starter" quickstart. Inside of your project, enter the `SQL editor` tab and scroll down until you see `User Management Starter`.
|
||||
|
||||
### 3. Get the URL and Key
|
||||
|
||||
Go to the Project Settings (the cog icon), open the API tab, and find your API URL and `anon` key, you'll need these in the next step.
|
||||
|
||||
The `anon` key is your client-side API key. It allows "anonymous access" to your database, until the user has logged in. Once they have logged in, the keys will switch to the user's own login token. This enables row level security for your data. Read more about this [below](#postgres-row-level-security).
|
||||
|
||||

|
||||
|
||||
**_NOTE_**: The `service_role` key has full access to your data, bypassing any security policies. These keys have to be kept secret and are meant to be used in server environments and never on a client or browser.
|
||||
|
||||
Set the details in the `/lib/supabase.js` file.
|
||||
|
||||
### 4. Install the dependencies & run the project:
|
||||
|
||||
Install the dependencies:
|
||||
|
||||
```bash
|
||||
yarn
|
||||
```
|
||||
|
||||
Run the project:
|
||||
|
||||
```bash
|
||||
yarn start
|
||||
```
|
||||
|
||||
## Authors
|
||||
|
||||
- [Supabase](https://supabase.com)
|
||||
|
||||
Supabase is open source, we'd love for you to follow along and get involved at https://github.com/supabase/supabase
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "expo-user-management",
|
||||
"slug": "expo-user-management",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"updates": {
|
||||
"fallbackToCacheTimeout": 0
|
||||
},
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#FFFFFF"
|
||||
}
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
@@ -0,0 +1,6 @@
|
||||
module.exports = function(api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { supabase } from '../lib/supabase'
|
||||
import { StyleSheet, View, Alert } from 'react-native'
|
||||
import { Button, Input } from 'react-native-elements'
|
||||
import { ApiError, Session } from '@supabase/supabase-js'
|
||||
|
||||
export default function Account({ session }: { session: Session }) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [username, setUsername] = useState('')
|
||||
const [website, setWebsite] = useState('')
|
||||
const [avatar_url, setAvatarUrl] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (session) getProfile()
|
||||
}, [session])
|
||||
|
||||
async function getProfile() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const user = supabase.auth.user()
|
||||
if (!user) throw new Error('No user on the session!')
|
||||
|
||||
let { data, error, status } = await supabase
|
||||
.from('profiles')
|
||||
.select(`username, website, avatar_url`)
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
if (error && status !== 406) {
|
||||
throw error
|
||||
}
|
||||
|
||||
if (data) {
|
||||
setUsername(data.username)
|
||||
setWebsite(data.website)
|
||||
setAvatarUrl(data.avatar_url)
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert((error as ApiError).message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProfile({
|
||||
username,
|
||||
website,
|
||||
avatar_url,
|
||||
}: {
|
||||
username: string
|
||||
website: string
|
||||
avatar_url: string
|
||||
}) {
|
||||
try {
|
||||
setLoading(true)
|
||||
const user = supabase.auth.user()
|
||||
if (!user) throw new Error('No user on the session!')
|
||||
|
||||
const updates = {
|
||||
id: user.id,
|
||||
username,
|
||||
website,
|
||||
avatar_url,
|
||||
updated_at: new Date(),
|
||||
}
|
||||
|
||||
let { error } = await supabase.from('profiles').upsert(updates, { returning: 'minimal' })
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert((error as ApiError).message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={[styles.verticallySpaced, styles.mt20]}>
|
||||
<Input label="Email" value={session?.user?.email} disabled />
|
||||
</View>
|
||||
<View style={styles.verticallySpaced}>
|
||||
<Input label="Username" value={username || ''} onChangeText={(text) => setUsername(text)} />
|
||||
</View>
|
||||
<View style={styles.verticallySpaced}>
|
||||
<Input label="Website" value={website || ''} onChangeText={(text) => setWebsite(text)} />
|
||||
</View>
|
||||
|
||||
<View style={[styles.verticallySpaced, styles.mt20]}>
|
||||
<Button
|
||||
title={loading ? 'Loading ...' : 'Update'}
|
||||
onPress={() => updateProfile({ username, website, avatar_url })}
|
||||
disabled={loading}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.verticallySpaced}>
|
||||
<Button title="Sign Out" onPress={() => supabase.auth.signOut()} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginTop: 40,
|
||||
padding: 12,
|
||||
},
|
||||
verticallySpaced: {
|
||||
paddingTop: 4,
|
||||
paddingBottom: 4,
|
||||
alignSelf: 'stretch',
|
||||
},
|
||||
mt20: {
|
||||
marginTop: 20,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Alert, StyleSheet, View } from 'react-native'
|
||||
import { supabase } from '../lib/supabase'
|
||||
import { Button, Input } from 'react-native-elements'
|
||||
|
||||
export default function Auth() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function signInWithEmail() {
|
||||
setLoading(true)
|
||||
const { user, error } = await supabase.auth.signIn({
|
||||
email: email,
|
||||
password: password,
|
||||
})
|
||||
|
||||
if (error) Alert.alert(error.message)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
async function signUpWithEmail() {
|
||||
setLoading(true)
|
||||
const { user, error } = await supabase.auth.signUp({
|
||||
email: email,
|
||||
password: password,
|
||||
})
|
||||
|
||||
if (error) Alert.alert(error.message)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={[styles.verticallySpaced, styles.mt20]}>
|
||||
<Input
|
||||
label="Email"
|
||||
leftIcon={{ type: 'font-awesome', name: 'envelope' }}
|
||||
onChangeText={(text) => setEmail(text)}
|
||||
value={email}
|
||||
placeholder="email@address.com"
|
||||
autoCapitalize={'none'}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.verticallySpaced}>
|
||||
<Input
|
||||
label="Password"
|
||||
leftIcon={{ type: 'font-awesome', name: 'lock' }}
|
||||
onChangeText={(text) => setPassword(text)}
|
||||
value={password}
|
||||
secureTextEntry={true}
|
||||
placeholder="Password"
|
||||
autoCapitalize={'none'}
|
||||
/>
|
||||
</View>
|
||||
<View style={[styles.verticallySpaced, styles.mt20]}>
|
||||
<Button title="Sign in" disabled={loading} onPress={() => signInWithEmail()} />
|
||||
</View>
|
||||
<View style={styles.verticallySpaced}>
|
||||
<Button title="Sign up" disabled={loading} onPress={() => signUpWithEmail()} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginTop: 40,
|
||||
padding: 12,
|
||||
},
|
||||
verticallySpaced: {
|
||||
paddingTop: 4,
|
||||
paddingBottom: 4,
|
||||
alignSelf: 'stretch',
|
||||
},
|
||||
mt20: {
|
||||
marginTop: 20,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
const supabaseUrl = 'https://project.supabase.co'
|
||||
const supabaseAnonKey = 'your-anon-key'
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
localStorage: AsyncStorage as any,
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
detectSessionInUrl: false,
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "expo-user-management",
|
||||
"version": "1.0.0",
|
||||
"main": "node_modules/expo/AppEntry.js",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"eject": "expo eject"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "^1.17.0",
|
||||
"@supabase/supabase-js": "^1.31.1",
|
||||
"expo": "~44.0.0",
|
||||
"expo-status-bar": "~1.2.0",
|
||||
"react": "17.0.1",
|
||||
"react-dom": "17.0.1",
|
||||
"react-native": "0.64.3",
|
||||
"react-native-elements": "^3.4.2",
|
||||
"react-native-safe-area-context": "^4.2.2",
|
||||
"react-native-url-polyfill": "^1.3.0",
|
||||
"react-native-web": "0.17.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.12.9",
|
||||
"@types/jest": "^27.4.1",
|
||||
"@types/react": "~17.0.21",
|
||||
"@types/react-native": "^0.64.24",
|
||||
"@types/react-native-elements": "^0.18.0",
|
||||
"@types/react-test-renderer": "^17.0.1",
|
||||
"typescript": "~4.3.5"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1558,9 +1558,9 @@ ms@2.0.0:
|
||||
integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=
|
||||
|
||||
nanoid@^3.1.23:
|
||||
version "3.1.23"
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.23.tgz#f744086ce7c2bc47ee0a8472574d5c78e4183a81"
|
||||
integrity sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw==
|
||||
version "3.3.1"
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.1.tgz#6347a18cac88af88f58af0b3594b723d5e99bb35"
|
||||
integrity sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw==
|
||||
|
||||
native-url@0.3.4:
|
||||
version "0.3.4"
|
||||
|
||||
Generated
+8
-13
@@ -10,7 +10,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^1.2.1",
|
||||
"next": "latest",
|
||||
"next": "*",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"swr": "0.2.3"
|
||||
@@ -1176,7 +1176,6 @@
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.1",
|
||||
"braces": "~3.0.2",
|
||||
"fsevents": "~2.1.2",
|
||||
"glob-parent": "~5.1.0",
|
||||
"is-binary-path": "~2.1.0",
|
||||
"is-glob": "~4.0.1",
|
||||
@@ -3685,9 +3684,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.1.20",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.20.tgz",
|
||||
"integrity": "sha512-a1cQNyczgKbLX9jwbS/+d7W8fX/RfgYR7lVWwWOGIPNgK2m0MWvrGF6/m4kk6U3QcFMnZf3RIhL0v2Jgh/0Uxw==",
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.1.tgz",
|
||||
"integrity": "sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw==",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
@@ -3778,7 +3777,6 @@
|
||||
"resolve-url-loader": "3.1.2",
|
||||
"sass-loader": "10.0.5",
|
||||
"schema-utils": "2.7.1",
|
||||
"sharp": "0.26.3",
|
||||
"stream-browserify": "3.0.0",
|
||||
"style-loader": "1.2.1",
|
||||
"styled-jsx": "3.3.2",
|
||||
@@ -6713,7 +6711,6 @@
|
||||
"anymatch": "^2.0.0",
|
||||
"async-each": "^1.0.1",
|
||||
"braces": "^2.3.2",
|
||||
"fsevents": "^1.2.7",
|
||||
"glob-parent": "^3.1.0",
|
||||
"inherits": "^2.0.3",
|
||||
"is-binary-path": "^1.0.0",
|
||||
@@ -7017,10 +7014,8 @@
|
||||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.7.5.tgz",
|
||||
"integrity": "sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ==",
|
||||
"dependencies": {
|
||||
"chokidar": "^3.4.1",
|
||||
"graceful-fs": "^4.1.2",
|
||||
"neo-async": "^2.5.0",
|
||||
"watchpack-chokidar2": "^2.0.1"
|
||||
"neo-async": "^2.5.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"chokidar": "^3.4.1",
|
||||
@@ -10198,9 +10193,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"nanoid": {
|
||||
"version": "3.1.20",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.20.tgz",
|
||||
"integrity": "sha512-a1cQNyczgKbLX9jwbS/+d7W8fX/RfgYR7lVWwWOGIPNgK2m0MWvrGF6/m4kk6U3QcFMnZf3RIhL0v2Jgh/0Uxw=="
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.1.tgz",
|
||||
"integrity": "sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw=="
|
||||
},
|
||||
"nanomatch": {
|
||||
"version": "1.2.13",
|
||||
|
||||
+3003
-3790
File diff suppressed because it is too large.
Load diff
+3
-3
@@ -1743,9 +1743,9 @@
|
||||
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
|
||||
},
|
||||
"nanoid": {
|
||||
"version": "3.1.30",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.30.tgz",
|
||||
"integrity": "sha512-zJpuPDwOv8D2zq2WRoMe1HsfZthVewpel9CAvTfc/2mBD1uUT/agc5f7GHGWXlYkFvi1mVxe4IjvP2HNrop7nQ=="
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.1.tgz",
|
||||
"integrity": "sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw=="
|
||||
},
|
||||
"native-url": {
|
||||
"version": "0.3.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Example: Supabase authentication client- and server-side (API routes), and SSR with auth cookie.
|
||||
|
||||
This example shows how to use Supabase auth both on the client and server in both [API routes](https://nextjs.org/docs/api-routes/introduction) and when using [server side rendering (SSR)](https://nextjs.org/docs/basic-features/pages#server-side-rendering).
|
||||
This example shows how to use Supabase auth both on the client and server in both [API routes](https://nextjs.org/docs/api-routes/introduction) and when using [server side rendering (SSR)](https://nextjs.org/docs/basic-features/pages#server-side-rendering). This example uses helper functions that can be viewed at [supabase auth helpers repository](https://github.com/supabase-community/supabase-auth-helpers).
|
||||
|
||||
## Deploy with Vercel
|
||||
|
||||
|
||||
+3
-3
@@ -6309,9 +6309,9 @@
|
||||
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="
|
||||
},
|
||||
"nanoid": {
|
||||
"version": "3.1.22",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.22.tgz",
|
||||
"integrity": "sha512-/2ZUaJX2ANuLtTvqTlgqBQNJoQO398KyJgZloL0PZkC0dpysjncRUPsFe3DUPzz/y3h+u7C46np8RMuvF3jsSQ=="
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.1.tgz",
|
||||
"integrity": "sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw=="
|
||||
},
|
||||
"nanomatch": {
|
||||
"version": "1.2.13",
|
||||
|
||||
+3
-3
@@ -11623,9 +11623,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"nanoid": {
|
||||
"version": "3.1.30",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.30.tgz",
|
||||
"integrity": "sha512-zJpuPDwOv8D2zq2WRoMe1HsfZthVewpel9CAvTfc/2mBD1uUT/agc5f7GHGWXlYkFvi1mVxe4IjvP2HNrop7nQ=="
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.1.tgz",
|
||||
"integrity": "sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw=="
|
||||
},
|
||||
"nanomatch": {
|
||||
"version": "1.2.13",
|
||||
|
||||
@@ -7425,9 +7425,9 @@ nan@^2.12.1:
|
||||
integrity sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==
|
||||
|
||||
nanoid@^3.1.22:
|
||||
version "3.1.23"
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.23.tgz#f744086ce7c2bc47ee0a8472574d5c78e4183a81"
|
||||
integrity sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw==
|
||||
version "3.3.1"
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.1.tgz#6347a18cac88af88f58af0b3594b723d5e99bb35"
|
||||
integrity sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw==
|
||||
|
||||
nanomatch@^1.2.9:
|
||||
version "1.2.13"
|
||||
|
||||
+6
-6
@@ -13279,9 +13279,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.1.23",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.23.tgz",
|
||||
"integrity": "sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw==",
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.1.tgz",
|
||||
"integrity": "sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw==",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
@@ -31862,9 +31862,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"nanoid": {
|
||||
"version": "3.1.23",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.23.tgz",
|
||||
"integrity": "sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw=="
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.1.tgz",
|
||||
"integrity": "sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw=="
|
||||
},
|
||||
"nanomatch": {
|
||||
"version": "1.2.13",
|
||||
|
||||
+9
-9
@@ -9774,9 +9774,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"nanoid": {
|
||||
"version": "3.1.23",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.23.tgz",
|
||||
"integrity": "sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw==",
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.1.tgz",
|
||||
"integrity": "sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw==",
|
||||
"dev": true
|
||||
},
|
||||
"nanomatch": {
|
||||
@@ -10276,9 +10276,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"postcss": {
|
||||
"version": "8.2.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.2.10.tgz",
|
||||
"integrity": "sha512-b/h7CPV7QEdrqIxtAf2j31U5ef05uBDuvoXv6L51Q4rcS1jdlXAVKJv+atCFdUXYl9dyTHGyoMzIepwowRJjFw==",
|
||||
"version": "8.2.13",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.2.13.tgz",
|
||||
"integrity": "sha512-FCE5xLH+hjbzRdpbRb1IMCvPv9yZx2QnDarBEYSN0N0HYk+TcXsEhwdFcFb+SRWOKzKGErhIEbBK2ogyLdTtfQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"colorette": "^1.2.2",
|
||||
@@ -10287,9 +10287,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"colorette": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz",
|
||||
"integrity": "sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==",
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
|
||||
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"husky": "^4.2.5",
|
||||
"jest": "^25.4.0",
|
||||
"lint-staged": "^10.2.10",
|
||||
"postcss": "^8.2.10",
|
||||
"postcss": "^8.2.13",
|
||||
"postcss-cli": "^7.1.1",
|
||||
"postcss-load-config": "^2.1.0",
|
||||
"prettier": "2.0.5",
|
||||
|
||||
Generated
+5910
-4
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
stories: ['./*.stories.mdx', '../components/**/*.stories.@(js|jsx|ts|tsx)'],
|
||||
addons: [
|
||||
'@storybook/addon-links',
|
||||
'@storybook/addon-essentials',
|
||||
'@storybook/addon-interactions',
|
||||
'storybook-dark-mode',
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
.dark,
|
||||
.light,
|
||||
.light .docs-story,
|
||||
.dark .docs-story {
|
||||
@apply bg-scale-200;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import './preview.css'
|
||||
|
||||
export const parameters = {
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
darkMode: {
|
||||
darkClass: 'dark',
|
||||
lightClass: 'light',
|
||||
stylePreview: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const decorators = [
|
||||
(Story) => {
|
||||
return <Story />
|
||||
},
|
||||
]
|
||||
@@ -8,6 +8,10 @@
|
||||
|
||||
|
||||
FROM node:14-slim as base
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3 \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /usr/src/app
|
||||
# Do `npm ci` separately so we can cache `node_modules`
|
||||
# https://nodejs.org/en/docs/guides/nodejs-docker-webapp/
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useState } from 'react'
|
||||
import { Typography, Button, Modal, IconTrash } from '@supabase/ui'
|
||||
import { useStore } from 'hooks'
|
||||
import { delete_ } from 'lib/common/fetch'
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { AccessToken, useAccessTokens } from 'hooks/queries/useAccessTokens'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import ConfirmationModal from 'components/ui/ConfirmationModal'
|
||||
|
||||
const AccessTokenList = observer(() => {
|
||||
const { ui } = useStore()
|
||||
const { mutateDeleteToken } = useAccessTokens()
|
||||
const { tokens, isLoading } = useAccessTokens()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [token, setToken] = useState<AccessToken | undefined>(undefined)
|
||||
|
||||
async function onDeleteToken(tokenId: number) {
|
||||
const response = await delete_(`${API_URL}/profile/access-tokens/${tokenId}`)
|
||||
if (response.error) {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: `Failed to delete token: ${response.error.message}`,
|
||||
})
|
||||
} else {
|
||||
mutateDeleteToken(tokenId)
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="max-w-7xl">
|
||||
<Table
|
||||
head={[
|
||||
<Table.th key="header-token">Token</Table.th>,
|
||||
<Table.th key="header-name">Name</Table.th>,
|
||||
<Table.th key="header-created">Created</Table.th>,
|
||||
<Table.th key="header-action"></Table.th>,
|
||||
]}
|
||||
body={
|
||||
tokens && tokens.length == 0 ? (
|
||||
<Table.tr>
|
||||
<Table.td colSpan={5} className="p-3 py-12 text-center">
|
||||
<Typography.Text type="secondary">
|
||||
{isLoading ? 'Checking for tokens' : "You don't have any token"}
|
||||
</Typography.Text>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
) : (
|
||||
<>
|
||||
{tokens?.map((x: AccessToken) => {
|
||||
return (
|
||||
<Table.tr key={x.token_alias}>
|
||||
<Table.td>{x.token_alias}</Table.td>
|
||||
<Table.td>{x.name}</Table.td>
|
||||
<Table.td>
|
||||
<Typography.Text>{new Date(x.created_at).toLocaleString()}</Typography.Text>
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
<Button
|
||||
as="span"
|
||||
type="danger"
|
||||
title="delete token"
|
||||
onClick={() => {
|
||||
setToken(x)
|
||||
setIsOpen(true)
|
||||
}}
|
||||
icon={<IconTrash />}
|
||||
></Button>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ConfirmationModal
|
||||
visible={isOpen}
|
||||
danger={true}
|
||||
header="Confirm to delete"
|
||||
buttonLabel="Delete"
|
||||
onSelectCancel={() => setIsOpen(false)}
|
||||
onSelectConfirm={() => {
|
||||
if (token) onDeleteToken(token.id)
|
||||
}}
|
||||
children={
|
||||
<Modal.Content>
|
||||
<p className="py-4 text-sm text-scale-1100">
|
||||
{`This action cannot be undone. Are you sure you want to delete "${token?.name}" token?`}
|
||||
</p>
|
||||
</Modal.Content>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
export default AccessTokenList
|
||||
@@ -0,0 +1,115 @@
|
||||
import { FC, useState } from 'react'
|
||||
import { Input, Button, Modal, Form } from '@supabase/ui'
|
||||
import { useStore } from 'hooks'
|
||||
import { post } from 'lib/common/fetch'
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { NewAccessToken, useAccessTokens } from 'hooks/queries/useAccessTokens'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
|
||||
const NewAccessTokenButton = observer(() => {
|
||||
const { ui } = useStore()
|
||||
const { mutateNewToken } = useAccessTokens()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [newToken, setNewToken] = useState<NewAccessToken | undefined>(undefined)
|
||||
|
||||
async function onFormSubmit(values: any, { setSubmitting }: any) {
|
||||
setSubmitting(true)
|
||||
const response = await post(`${API_URL}/profile/access-tokens`, { name })
|
||||
if (response.error) {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
message: `Failed to create token: ${response.error.message}`,
|
||||
})
|
||||
setSubmitting(false)
|
||||
} else {
|
||||
mutateNewToken(response)
|
||||
setNewToken(response)
|
||||
|
||||
setSubmitting(false)
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setName('')
|
||||
setNewToken(undefined)
|
||||
setIsOpen(!isOpen)
|
||||
}}
|
||||
>
|
||||
Generate New Token
|
||||
</Button>
|
||||
{newToken && <NewTokenItem data={newToken} />}
|
||||
<Modal
|
||||
visible={isOpen}
|
||||
onCancel={() => setIsOpen(!isOpen)}
|
||||
header={
|
||||
<div className="flex gap-2 items-baseline">
|
||||
<h5 className="text-sm text-scale-1200">Generate New Token</h5>
|
||||
</div>
|
||||
}
|
||||
size="small"
|
||||
hideFooter
|
||||
closable
|
||||
>
|
||||
<Form
|
||||
initialValues={{ tokenName: '' }}
|
||||
validateOnBlur
|
||||
onSubmit={onFormSubmit}
|
||||
validate={(values: any) => {
|
||||
const errors: any = {}
|
||||
if (!values.tokenName) {
|
||||
errors.tokenName = 'Enter the name of the token.'
|
||||
}
|
||||
return errors
|
||||
}}
|
||||
>
|
||||
{({ isSubmitting }: { isSubmitting: boolean }) => (
|
||||
<div className="space-y-4 py-3">
|
||||
<Modal.Content>
|
||||
<Input
|
||||
id="tokenName"
|
||||
label="Name"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
value={name}
|
||||
placeholder="Type in the token name"
|
||||
className="w-full"
|
||||
/>
|
||||
</Modal.Content>
|
||||
<Modal.Seperator />
|
||||
<Modal.Content>
|
||||
<Button htmlType="submit" loading={isSubmitting} size="small" block danger>
|
||||
Generate Token
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
export default NewAccessTokenButton
|
||||
|
||||
interface NewTokenItemProps {
|
||||
data: NewAccessToken
|
||||
}
|
||||
|
||||
const NewTokenItem: FC<NewTokenItemProps> = observer(({ data }) => {
|
||||
return (
|
||||
<Input
|
||||
label="Successfully generated a new token!"
|
||||
readOnly
|
||||
copy={true}
|
||||
className="input-mono max-w-xl"
|
||||
disabled
|
||||
value={data.token}
|
||||
onChange={() => {}}
|
||||
descriptionText="This token never will be displayed again."
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
import AccessTokenList from './AccessTokenList'
|
||||
import NewAccessTokenButton from './NewAccessTokenButton'
|
||||
|
||||
export { AccessTokenList, NewAccessTokenButton }
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FC, useState } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { Badge, Typography, Toggle } from '@supabase/ui'
|
||||
import { Badge, Toggle } from '@supabase/ui'
|
||||
|
||||
import { useStore } from 'hooks'
|
||||
import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmModal'
|
||||
@@ -82,19 +82,23 @@ const ExtensionCard: FC<Props> = ({ extension }) => {
|
||||
return (
|
||||
<div
|
||||
className="
|
||||
border border-border-secondary-light dark:border-border-secondary-dark
|
||||
border border-panel-border-light dark:border-panel-border-dark
|
||||
shadow-sm
|
||||
overflow-hidden
|
||||
rounded
|
||||
flex flex-col"
|
||||
flex flex-col
|
||||
"
|
||||
>
|
||||
<div
|
||||
className="
|
||||
p-4 px-6 flex
|
||||
bg-panel-header-light dark:bg-panel-header-dark
|
||||
border-b border-border-secondary-light dark:border-border-secondary-dark"
|
||||
border-b border-panel-border-light dark:border-panel-border-dark
|
||||
"
|
||||
>
|
||||
<div className="m-0 h-5 uppercase flex-1">
|
||||
<Typography.Title level={5}>{extension.name}</Typography.Title>
|
||||
</div>
|
||||
<h3 className="m-0 h-5 uppercase flex-1 truncate text-base text-scale-1200 truncate">
|
||||
{extension.name}
|
||||
</h3>
|
||||
{loading ? (
|
||||
<img className="loading-spinner" src="/img/spinner.gif"></img>
|
||||
) : (
|
||||
|
||||
@@ -27,7 +27,7 @@ import { USAGE_COLORS } from 'components/ui/Charts/Charts.constants'
|
||||
import { EndpointResponse, PathsDatum, StatusCodesDatum } from './ChartData.types'
|
||||
|
||||
const CHART_INTERVALS = [
|
||||
{ key: 'minutely', label: '60 minutes', startValue: 1, startUnit: 'hour', format: 'MMM D, h:ma' },
|
||||
{ key: 'minutely', label: '60 minutes', startValue: 1, startUnit: 'hour', format: 'MMM D, h:mma' },
|
||||
{ key: 'hourly', label: '24 hours', startValue: 24, startUnit: 'hour', format: 'MMM D, ha' },
|
||||
{ key: 'daily', label: '7 days', startValue: 7, startUnit: 'day', format: 'MMM D' },
|
||||
]
|
||||
|
||||
@@ -53,7 +53,8 @@ const LogTable = ({ isCustomQuery, data = [] }: Props) => {
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
const stringData = JSON.stringify(data)
|
||||
const logMap = useMemo(() => {
|
||||
if (!hasLogDataFormat) return {} as LogMap
|
||||
const logData = data as LogData[]
|
||||
@@ -61,15 +62,13 @@ const LogTable = ({ isCustomQuery, data = [] }: Props) => {
|
||||
acc[d.id] = d
|
||||
return acc
|
||||
}, {}) as LogMap
|
||||
}, [JSON.stringify(data)])
|
||||
}, [stringData])
|
||||
|
||||
const stringData = JSON.stringify(data)
|
||||
useEffect(() => {
|
||||
if (!hasLogDataFormat) return
|
||||
if (isNil(data)) return
|
||||
if (focusedLog && !(focusedLog.id in logMap)) {
|
||||
setFocusedLog(null)
|
||||
}
|
||||
}
|
||||
}, [stringData])
|
||||
|
||||
if (!data) return null
|
||||
|
||||
@@ -13,13 +13,6 @@ export const TEMPLATES: LogTemplate[] = [
|
||||
searchString: 'COMMIT',
|
||||
for: ['database'],
|
||||
},
|
||||
|
||||
{
|
||||
label: '10 Minutes Ago',
|
||||
mode: 'custom',
|
||||
searchString: 'timestamp < TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 10 MINUTE)',
|
||||
for: ['database', 'api'],
|
||||
},
|
||||
{
|
||||
label: 'Commits By User',
|
||||
mode: 'custom',
|
||||
@@ -69,3 +62,12 @@ export const LOG_TYPE_LABEL_MAPPING: { [k: string]: string } = {
|
||||
api: 'API',
|
||||
database: 'Database',
|
||||
}
|
||||
|
||||
export const genDefaultQuery = (table: string): string => `SELECT
|
||||
id, timestamp, event_message, metadata
|
||||
FROM
|
||||
${table}
|
||||
LIMIT 100
|
||||
`
|
||||
|
||||
export const genCountQuery = (table: string): string => `SELECT count(*) as count FROM ${table}`
|
||||
@@ -24,13 +24,13 @@ export interface CountData {
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface Count {
|
||||
data: [CountData] | []
|
||||
error?: any
|
||||
type LFResponse<T> = {
|
||||
result: T[]
|
||||
error?: {
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface Logs {
|
||||
data: LogData[]
|
||||
count: number
|
||||
error: any
|
||||
}
|
||||
export type Count = LFResponse<CountData>
|
||||
|
||||
export type Logs = LFResponse<LogData>
|
||||
@@ -74,6 +74,13 @@ const AccountLayout = ({ children, title, breadcrumbs }: any) => {
|
||||
href: `/account/me`,
|
||||
key: `/account/me`,
|
||||
},
|
||||
// {
|
||||
// isActive: router.pathname == `/account/tokens`,
|
||||
// icon: '/img/user.svg',
|
||||
// label: 'Tokens',
|
||||
// href: `/account/tokens`,
|
||||
// key: `/account/tokens`,
|
||||
// },
|
||||
logoutLink,
|
||||
],
|
||||
},
|
||||
|
||||
@@ -164,18 +164,19 @@ const SidebarItem: FC<any> = ({ id, label, href, isActive, isSubitem, onClick, e
|
||||
return (
|
||||
<Link href={href || ''}>
|
||||
<a className="block" target={external ? '_blank' : '_self'}>
|
||||
<Menu.Item
|
||||
rounded
|
||||
key={id}
|
||||
style={{
|
||||
marginLeft: isSubitem && '.5rem',
|
||||
}}
|
||||
active={isActive ? true : false}
|
||||
<button
|
||||
className="cursor-pointer flex space-x-2 items-center outline-none focus-visible:ring-1 ring-scale-1200 focus-visible:z-10 group py-1 font-normal border-scale-500 group-hover:border-scale-900"
|
||||
onClick={onClick || (() => {})}
|
||||
icon={external && <IconArrowUpRight size={'tiny'} />}
|
||||
>
|
||||
{isSubitem ? <Typography.Text small>{label}</Typography.Text> : label}
|
||||
</Menu.Item>
|
||||
{external && (
|
||||
<span className="transition truncate text-sm text-scale-900 group-hover:text-scale-1100">
|
||||
<IconArrowUpRight size={'tiny'} />
|
||||
</span>
|
||||
)}
|
||||
<span className="transition truncate text-sm w-full text-scale-1100 group-hover:text-scale-1200">
|
||||
{isSubitem ? <p>{label}</p> : label}
|
||||
</span>
|
||||
</button>
|
||||
</a>
|
||||
</Link>
|
||||
)
|
||||
|
||||
@@ -44,18 +44,25 @@ const PolicyRow = ({
|
||||
onSelectDeletePolicy = () => {},
|
||||
}) => {
|
||||
return (
|
||||
<div className="group">
|
||||
<Panel.Content className="flex gap-2 justify-between py-4 border-b border-panel-border-light dark:border-panel-border-dark">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-3">
|
||||
<div className="font-mono text-xs text-scale-900">{policy.command}</div>
|
||||
<div className="flex flex-col lg:flex-row gap-2">
|
||||
<span className="text-sm text-scale-1200 truncate">{policy.name}</span>
|
||||
<span className="text-sm text-scale-1100 truncate">
|
||||
{policy.definition || policy.check}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Panel.Content
|
||||
className="
|
||||
w-full
|
||||
flex gap-2
|
||||
lg:items-center
|
||||
py-4
|
||||
border-b
|
||||
border-panel-border-light
|
||||
dark:border-panel-border-dark
|
||||
"
|
||||
>
|
||||
<div className="font-mono text-xs text-scale-900">{policy.command}</div>
|
||||
<div className="flex flex-col lg:flex-row gap-2 truncate grow">
|
||||
<span className="text-sm text-scale-1200 truncate max-w-xs">{policy.name}</span>
|
||||
<span className="text-sm text-scale-1100 truncate">
|
||||
{policy.definition || policy.check}
|
||||
</span>
|
||||
</div>
|
||||
<div className="">
|
||||
<Dropdown
|
||||
side="bottom"
|
||||
align="end"
|
||||
@@ -65,7 +72,6 @@ const PolicyRow = ({
|
||||
<Dropdown.Item
|
||||
icon={<IconEdit size={14} />}
|
||||
type="outline"
|
||||
className="mx-2"
|
||||
onClick={() => onSelectEditPolicy(policy)}
|
||||
>
|
||||
Edit
|
||||
@@ -87,8 +93,8 @@ const PolicyRow = ({
|
||||
icon={<IconMoreVertical />}
|
||||
/>
|
||||
</Dropdown>
|
||||
</Panel.Content>
|
||||
</div>
|
||||
</div>
|
||||
</Panel.Content>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ const StorageConfig = ({ config, projectRef }: any) => {
|
||||
schema={{
|
||||
properties: {
|
||||
fileSizeLimit: {
|
||||
help: 'The maximum size in bytes of a file that can be uploaded.',
|
||||
help: 'The maximum size in bytes of a file that can be uploaded, up to 5 GB.',
|
||||
maximum: STORAGE_FILE_SIZE_LIMIT_MAX_BYTES,
|
||||
minimum: 0,
|
||||
title: 'Upload file size limit',
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react'
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react'
|
||||
|
||||
import StackedAreaChart from './StackedAreaChart'
|
||||
|
||||
export default {
|
||||
title: 'Charts/StackedAreaChart',
|
||||
component: StackedAreaChart,
|
||||
} as ComponentMeta<typeof StackedAreaChart>
|
||||
|
||||
const Template: ComponentStory<typeof StackedAreaChart> = (args) => <StackedAreaChart {...args} />
|
||||
|
||||
export const Default = Template.bind({})
|
||||
Default.args = {
|
||||
stackKey: 'status_code',
|
||||
xAxisKey: 'timestamp',
|
||||
yAxisKey: 'count',
|
||||
isLoading: false,
|
||||
xAxisFormatAsDate: true,
|
||||
size: 'large',
|
||||
dateFormat: 'MMM D, ha',
|
||||
|
||||
data: [
|
||||
{ count: 18, status_code: 200, timestamp: 1647864000000000 },
|
||||
{ count: 1, status_code: 303, timestamp: 1647864000000000 },
|
||||
{ count: 4, status_code: 201, timestamp: 1647864000000000 },
|
||||
{ count: 18, status_code: 200, timestamp: 1647860000000000 },
|
||||
{ count: 1, status_code: 303, timestamp: 1647860000000000 },
|
||||
{ count: 4, status_code: 201, timestamp: 1647864000000000 },
|
||||
],
|
||||
}
|
||||
@@ -109,7 +109,7 @@ const StackedAreaChart: React.FC<Props> = ({
|
||||
))}
|
||||
<Tooltip
|
||||
labelFormatter={xAxisFormatAsDate ? formatToDate : undefined}
|
||||
labelClassName="text-gray-100"
|
||||
labelClassName="text-white"
|
||||
contentStyle={{ backgroundColor: '#444444', borderColor: '#444444', fontSize: '12px' }}
|
||||
wrapperClassName="bg-gray-600"
|
||||
/>
|
||||
|
||||
@@ -52,7 +52,7 @@ const ConfirmationModal: FC<Props> = ({
|
||||
<Button block type="secondary" onClick={onSelectCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button block type="primary" danger={danger} loading={loading} onClick={onConfirm}>
|
||||
<Button block type={danger ? 'danger' : 'primary'} loading={loading} onClick={onConfirm}>
|
||||
{loading ? buttonLoadingLabel : buttonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import useSWR, { mutate } from 'swr'
|
||||
import { get } from 'lib/common/fetch'
|
||||
import { API_URL } from 'lib/constants'
|
||||
|
||||
export interface AccessToken {
|
||||
id: number
|
||||
token_alias: string
|
||||
name: string
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export interface NewAccessToken extends AccessToken {
|
||||
token: string
|
||||
}
|
||||
|
||||
export function useAccessTokens() {
|
||||
const url = `${API_URL}/profile/access-tokens`
|
||||
let { data, error } = useSWR<any>(url, get)
|
||||
const anyError = data?.error || error
|
||||
|
||||
function mutateNewToken(newToken: AccessToken, revalidate?: boolean) {
|
||||
mutate(url, (data: AccessToken[]) => [...data, newToken], revalidate ?? true)
|
||||
}
|
||||
|
||||
function mutateDeleteToken(tokenId: number, revalidate?: boolean) {
|
||||
mutate(
|
||||
url,
|
||||
(data: AccessToken[]) => data.filter((x: AccessToken) => x.id === tokenId),
|
||||
revalidate ?? true
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
tokens: anyError ? undefined : (data as AccessToken[]),
|
||||
isLoading: !anyError && !data,
|
||||
isError: !!anyError,
|
||||
mutateNewToken,
|
||||
mutateDeleteToken,
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,12 @@ export const METRICS = [
|
||||
provider: 'infra-monitoring',
|
||||
category: METRIC_CATEGORIES.INSTANCE,
|
||||
},
|
||||
{
|
||||
key: 'disk_io_budget',
|
||||
label: 'Daily Disk IO Budget % Remaining',
|
||||
provider: 'infra-monitoring',
|
||||
category: METRIC_CATEGORIES.INSTANCE,
|
||||
},
|
||||
{
|
||||
key: 'ram_usage',
|
||||
label: 'Memory % usage',
|
||||
|
||||
Generated
+12306
-236
File diff suppressed because it is too large.
Load diff
+15
-4
@@ -7,7 +7,9 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"test": "jest",
|
||||
"deploy:staging": "VERCEL_ORG_ID=team_E6KJ1W561hMTjon1QSwOh0WO VERCEL_PROJECT_ID=QmcmhbiAtCMFTAHCuGgQscNbke4TzgWULECctNcKmxWCoT vercel --prod -A .vercel/staging.json"
|
||||
"deploy:staging": "VERCEL_ORG_ID=team_E6KJ1W561hMTjon1QSwOh0WO VERCEL_PROJECT_ID=QmcmhbiAtCMFTAHCuGgQscNbke4TzgWULECctNcKmxWCoT vercel --prod -A .vercel/staging.json",
|
||||
"storybook": "start-storybook -p 6006",
|
||||
"build-storybook": "build-storybook"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.4.1",
|
||||
@@ -19,18 +21,18 @@
|
||||
"@sentry/nextjs": "^6.15.0",
|
||||
"@stripe/react-stripe-js": "^1.7.0",
|
||||
"@stripe/stripe-js": "^1.25.0",
|
||||
"@supabase/grid": "v2.5.0-alpha.8",
|
||||
"@supabase/grid": "v2.5.0-alpha.12",
|
||||
"@supabase/postgres-meta": "^0.26.1",
|
||||
"@supabase/react-data-grid": "^7.1.0-beta.7",
|
||||
"@supabase/shared-types": "^0.1.15",
|
||||
"@supabase/supabase-js": "^1.29.1",
|
||||
"@supabase/ui": "0.37.0-alpha.47",
|
||||
"@supabase/ui": "0.37.0-alpha.50",
|
||||
"ajv": "^8.6.3",
|
||||
"analytics-node": "^3.5.0",
|
||||
"clipboard": "^2.0.8",
|
||||
"configcat-js": "^5.6.0",
|
||||
"crypto-js": "^4.1.1",
|
||||
"dayjs": "^1.10.7",
|
||||
"dayjs": "^1.11.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lottie-react": "^2.1.0",
|
||||
"markdown-table": "=2.0.0",
|
||||
@@ -64,6 +66,14 @@
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.17.5",
|
||||
"@storybook/addon-actions": "^6.4.19",
|
||||
"@storybook/addon-essentials": "^6.4.19",
|
||||
"@storybook/addon-interactions": "^6.4.19",
|
||||
"@storybook/addon-links": "^6.4.19",
|
||||
"@storybook/react": "^6.4.19",
|
||||
"@storybook/testing-library": "^0.0.9",
|
||||
"storybook-dark-mode": "^1.0.4",
|
||||
"@tailwindcss/typography": "^0.5.2",
|
||||
"@testing-library/dom": "^8.11.1",
|
||||
"@testing-library/react": "^12.1.2",
|
||||
@@ -84,6 +94,7 @@
|
||||
"@types/sqlstring": "^2.3.0",
|
||||
"@types/zxcvbn": "^4.4.1",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"babel-loader": "^8.2.3",
|
||||
"jest": "^27.4.0",
|
||||
"jest-canvas-mock": "^2.3.1",
|
||||
"postcss": "^8.4.6",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { withAuth } from 'hooks'
|
||||
import { AccountLayout } from 'components/layouts'
|
||||
import { AccessTokenList, NewAccessTokenButton } from 'components/interfaces/Account'
|
||||
|
||||
const UserAccessTokens = () => {
|
||||
return (
|
||||
<AccountLayout
|
||||
title="Supabase"
|
||||
breadcrumbs={[
|
||||
{
|
||||
key: `supabase-account-tokens`,
|
||||
label: 'Access Tokens',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<div className="p-4 pt-0 mt-4 space-y-8">
|
||||
<h2 className="text-xl">Access Tokens</h2>
|
||||
<NewAccessTokenButton />
|
||||
<AccessTokenList />
|
||||
</div>
|
||||
</AccountLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default withAuth(UserAccessTokens)
|
||||
@@ -44,40 +44,55 @@ const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
SMTP_USER: null,
|
||||
SMTP_PASS: null,
|
||||
SMTP_MAX_FREQUENCY: 0,
|
||||
EXTERNAL_EMAIL_ENABLED: true,
|
||||
EXTERNAL_PHONE_ENABLED: true,
|
||||
EXTERNAL_APPLE_ENABLED: false,
|
||||
EXTERNAL_APPLE_CLIENT_ID: null,
|
||||
EXTERNAL_APPLE_SECRET: null,
|
||||
EXTERNAL_AZURE_ENABLED: false,
|
||||
EXTERNAL_AZURE_CLIENT_ID: null,
|
||||
EXTERNAL_AZURE_SECRET: null,
|
||||
EXTERNAL_AZURE_URL: null,
|
||||
EXTERNAL_BITBUCKET_ENABLED: false,
|
||||
EXTERNAL_BITBUCKET_CLIENT_ID: null,
|
||||
EXTERNAL_BITBUCKET_SECRET: null,
|
||||
EXTERNAL_DISCORD_ENABLED: false,
|
||||
EXTERNAL_DISCORD_CLIENT_ID: null,
|
||||
EXTERNAL_DISCORD_SECRET: null,
|
||||
EXTERNAL_FACEBOOK_ENABLED: false,
|
||||
EXTERNAL_FACEBOOK_CLIENT_ID: null,
|
||||
EXTERNAL_FACEBOOK_SECRET: null,
|
||||
EXTERNAL_GITHUB_ENABLED: false,
|
||||
EXTERNAL_GITHUB_CLIENT_ID: null,
|
||||
EXTERNAL_GITHUB_SECRET: null,
|
||||
EXTERNAL_GITLAB_ENABLED: false,
|
||||
EXTERNAL_GITLAB_CLIENT_ID: null,
|
||||
EXTERNAL_GITLAB_SECRET: null,
|
||||
EXTERNAL_GITLAB_REDIRECT_URI: null,
|
||||
EXTERNAL_GOOGLE_ENABLED: false,
|
||||
EXTERNAL_GOOGLE_CLIENT_ID: null,
|
||||
EXTERNAL_GOOGLE_SECRET: null,
|
||||
EXTERNAL_LINKEDIN_ENABLED: false,
|
||||
EXTERNAL_LINKEDIN_CLIENT_ID: null,
|
||||
EXTERNAL_LINKEDIN_SECRET: null,
|
||||
EXTERNAL_NOTION_ENABLED: false,
|
||||
EXTERNAL_NOTION_CLIENT_ID: null,
|
||||
EXTERNAL_NOTION_SECRET: null,
|
||||
EXTERNAL_BITBUCKET_ENABLED: false,
|
||||
EXTERNAL_BITBUCKET_CLIENT_ID: null,
|
||||
EXTERNAL_BITBUCKET_SECRET: null,
|
||||
EXTERNAL_GOOGLE_ENABLED: false,
|
||||
EXTERNAL_GOOGLE_CLIENT_ID: null,
|
||||
EXTERNAL_GOOGLE_SECRET: null,
|
||||
EXTERNAL_GITLAB_ENABLED: false,
|
||||
EXTERNAL_GITLAB_CLIENT_ID: null,
|
||||
EXTERNAL_GITLAB_SECRET: null,
|
||||
EXTERNAL_GITLAB_REDIRECT_URI: null,
|
||||
EXTERNAL_AZURE_ENABLED: false,
|
||||
EXTERNAL_AZURE_CLIENT_ID: null,
|
||||
EXTERNAL_AZURE_SECRET: null,
|
||||
EXTERNAL_FACEBOOK_ENABLED: false,
|
||||
EXTERNAL_FACEBOOK_CLIENT_ID: null,
|
||||
EXTERNAL_FACEBOOK_SECRET: null,
|
||||
EXTERNAL_SPOTIFY_ENABLED: false,
|
||||
EXTERNAL_SPOTIFY_CLIENT_ID: null,
|
||||
EXTERNAL_SPOTIFY_SECRET: null,
|
||||
EXTERNAL_SLACK_ENABLED: false,
|
||||
EXTERNAL_SLACK_CLIENT_ID: null,
|
||||
EXTERNAL_SLACK_SECRET: null,
|
||||
EXTERNAL_TWITTER_ENABLED: false,
|
||||
EXTERNAL_TWITTER_CLIENT_ID: null,
|
||||
EXTERNAL_TWITTER_SECRET: null,
|
||||
EXTERNAL_APPLE_ENABLED: false,
|
||||
EXTERNAL_APPLE_CLIENT_ID: null,
|
||||
EXTERNAL_APPLE_SECRET: null,
|
||||
EXTERNAL_DISCORD_ENABLED: false,
|
||||
EXTERNAL_DISCORD_CLIENT_ID: null,
|
||||
EXTERNAL_DISCORD_SECRET: null,
|
||||
EXTERNAL_TWITCH_ENABLED: false,
|
||||
EXTERNAL_TWITCH_CLIENT_ID: null,
|
||||
EXTERNAL_TWITCH_SECRET: null,
|
||||
EXTERNAL_ZOOM_ENABLED: false,
|
||||
EXTERNAL_ZOOM_CLIENT_ID: null,
|
||||
EXTERNAL_ZOOM_SECRET: null,
|
||||
MAILER_SUBJECTS_INVITE: 'You have been invited',
|
||||
MAILER_SUBJECTS_CONFIRMATION: 'Confirm Your Signup',
|
||||
MAILER_SUBJECTS_RECOVERY: 'Reset Your Password',
|
||||
@@ -98,12 +113,8 @@ const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
MAILER_TEMPLATES_MAGIC_LINK: null,
|
||||
MAILER_TEMPLATES_MAGIC_LINK_CONTENT:
|
||||
'<h2>Magic Link</h2>\n\n<p>Follow this link to login:</p>\n<p><a href="{{ .ConfirmationURL }}">Log In</a></p>',
|
||||
EXTERNAL_TWITCH_ENABLED: false,
|
||||
EXTERNAL_TWITCH_CLIENT_ID: null,
|
||||
EXTERNAL_TWITCH_SECRET: null,
|
||||
PASSWORD_MIN_LENGTH: 6,
|
||||
SMTP_SENDER_NAME: null,
|
||||
EXTERNAL_EMAIL_ENABLED: true,
|
||||
EXTERNAL_PHONE_ENABLED: true,
|
||||
SMS_AUTOCONFIRM: false,
|
||||
SMS_MAX_FREQUENCY: 0,
|
||||
SMS_OTP_EXP: 0,
|
||||
@@ -118,12 +129,6 @@ const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
SECURITY_CAPTCHA_SECRET: null,
|
||||
RATE_LIMIT_EMAIL_SENT: 0,
|
||||
MAILER_SECURE_EMAIL_CHANGE_ENABLED: true,
|
||||
EXTERNAL_SPOTIFY_ENABLED: false,
|
||||
EXTERNAL_SPOTIFY_CLIENT_ID: null,
|
||||
EXTERNAL_SPOTIFY_SECRET: null,
|
||||
EXTERNAL_SLACK_ENABLED: false,
|
||||
EXTERNAL_SLACK_CLIENT_ID: null,
|
||||
EXTERNAL_SLACK_SECRET: null,
|
||||
SMS_MESSAGEBIRD_ACCESS_KEY: null,
|
||||
SMS_MESSAGEBIRD_ORIGINATOR: null,
|
||||
SMS_VONAGE_API_KEY: null,
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import apiWrapper from 'lib/api/apiWrapper'
|
||||
|
||||
export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { method } = req
|
||||
|
||||
switch (method) {
|
||||
case 'POST':
|
||||
return handlePost(req, res)
|
||||
default:
|
||||
res.setHeader('Allow', ['POST'])
|
||||
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } })
|
||||
}
|
||||
}
|
||||
|
||||
const handlePost = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
// Platform specific endpoint
|
||||
return res.status(200).json({ slug: 'default-org-slug' })
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import apiWrapper from 'lib/api/apiWrapper'
|
||||
|
||||
export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { method } = req
|
||||
|
||||
switch (method) {
|
||||
case 'POST':
|
||||
return handlePost(req, res)
|
||||
default:
|
||||
res.setHeader('Allow', ['POST'])
|
||||
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } })
|
||||
}
|
||||
}
|
||||
|
||||
const handlePost = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
// Platform specific endpoint
|
||||
return res.status(200).json({ ref: 'default' })
|
||||
}
|
||||
@@ -72,7 +72,7 @@ const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
realtime_enabled: true,
|
||||
},
|
||||
endpoint: process.env.SUPABASE_URL,
|
||||
restUrl: `${process.env.SUPABASE_URL}/rest/v1/`,
|
||||
restUrl: process.env.SUPABASE_REST_URL,
|
||||
defaultApiKey: process.env.SUPABASE_ANON_KEY,
|
||||
serviceApiKey: process.env.SUPABASE_SERVICE_KEY,
|
||||
internalApiKey: process.env.SUPABASE_ANON_KEY,
|
||||
|
||||
@@ -37,7 +37,7 @@ const Wizard = () => {
|
||||
}
|
||||
|
||||
setNewOrgLoading(true)
|
||||
const response = await post(`${API_URL}/organizations/new`, {
|
||||
const response = await post(`${API_URL}/organizations`, {
|
||||
name: orgName,
|
||||
})
|
||||
|
||||
|
||||
@@ -120,12 +120,14 @@ const Settings = () => {
|
||||
'URI_ALLOW_LIST',
|
||||
'JWT_EXP',
|
||||
'DISABLE_SIGNUP',
|
||||
'PASSWORD_MIN_LENGTH',
|
||||
])}
|
||||
model={{
|
||||
SITE_URL: model.SITE_URL || undefined,
|
||||
URI_ALLOW_LIST: model.URI_ALLOW_LIST || undefined,
|
||||
DISABLE_SIGNUP: model.DISABLE_SIGNUP,
|
||||
JWT_EXP: model.JWT_EXP || undefined,
|
||||
PASSWORD_MIN_LENGTH: model.PASSWORD_MIN_LENGTH || undefined,
|
||||
}}
|
||||
onSubmit={(model: any) => onFormSubmit(model)}
|
||||
>
|
||||
@@ -140,6 +142,7 @@ const Settings = () => {
|
||||
errorMessage="Must be a comma separated list of exact URIs. No spaces."
|
||||
/>
|
||||
<NumField name="JWT_EXP" step="1" />
|
||||
<NumField name="PASSWORD_MIN_LENGTH" step="1" />
|
||||
<ToggleField name="DISABLE_SIGNUP" />
|
||||
</SchemaFormPanel>
|
||||
</div>
|
||||
@@ -399,6 +402,7 @@ const Settings = () => {
|
||||
'EXTERNAL_AZURE_ENABLED',
|
||||
'EXTERNAL_AZURE_CLIENT_ID',
|
||||
'EXTERNAL_AZURE_SECRET',
|
||||
'EXTERNAL_AZURE_URL',
|
||||
'EXTERNAL_BITBUCKET_ENABLED',
|
||||
'EXTERNAL_BITBUCKET_CLIENT_ID',
|
||||
'EXTERNAL_BITBUCKET_SECRET',
|
||||
@@ -435,6 +439,9 @@ const Settings = () => {
|
||||
'EXTERNAL_SPOTIFY_ENABLED',
|
||||
'EXTERNAL_SPOTIFY_CLIENT_ID',
|
||||
'EXTERNAL_SPOTIFY_SECRET',
|
||||
'EXTERNAL_ZOOM_ENABLED',
|
||||
'EXTERNAL_ZOOM_CLIENT_ID',
|
||||
'EXTERNAL_ZOOM_SECRET',
|
||||
])}
|
||||
model={{
|
||||
EXTERNAL_APPLE_ENABLED: model.EXTERNAL_APPLE_ENABLED,
|
||||
@@ -443,6 +450,7 @@ const Settings = () => {
|
||||
EXTERNAL_AZURE_ENABLED: model.EXTERNAL_AZURE_ENABLED,
|
||||
EXTERNAL_AZURE_CLIENT_ID: model.EXTERNAL_AZURE_CLIENT_ID || undefined,
|
||||
EXTERNAL_AZURE_SECRET: model.EXTERNAL_AZURE_SECRET || undefined,
|
||||
EXTERNAL_AZURE_URL: model.EXTERNAL_AZURE_URL || undefined,
|
||||
EXTERNAL_BITBUCKET_ENABLED: model.EXTERNAL_BITBUCKET_ENABLED,
|
||||
EXTERNAL_BITBUCKET_CLIENT_ID: model.EXTERNAL_BITBUCKET_CLIENT_ID || undefined,
|
||||
EXTERNAL_BITBUCKET_SECRET: model.EXTERNAL_BITBUCKET_SECRET || undefined,
|
||||
@@ -479,6 +487,9 @@ const Settings = () => {
|
||||
EXTERNAL_SPOTIFY_ENABLED: model.EXTERNAL_SPOTIFY_ENABLED,
|
||||
EXTERNAL_SPOTIFY_CLIENT_ID: model.EXTERNAL_SPOTIFY_CLIENT_ID || undefined,
|
||||
EXTERNAL_SPOTIFY_SECRET: model.EXTERNAL_SPOTIFY_SECRET || undefined,
|
||||
EXTERNAL_ZOOM_ENABLED: model.EXTERNAL_ZOOM_ENABLED,
|
||||
EXTERNAL_ZOOM_CLIENT_ID: model.EXTERNAL_ZOOM_CLIENT_ID || undefined,
|
||||
EXTERNAL_ZOOM_SECRET: model.EXTERNAL_ZOOM_SECRET || undefined,
|
||||
}}
|
||||
onChangeModel={(model) => setExternalProvidersModel(model)}
|
||||
onReset={() => onFormReset()}
|
||||
@@ -539,6 +550,11 @@ const Settings = () => {
|
||||
showInlineError
|
||||
errorMessage="Please enter the secret."
|
||||
/>
|
||||
<AutoField
|
||||
name="EXTERNAL_AZURE_URL"
|
||||
showInlineError
|
||||
errorMessage="Please enter the azure tenant url."
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Divider light />
|
||||
@@ -889,6 +905,35 @@ const Settings = () => {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Divider light />
|
||||
<ToggleField
|
||||
name="EXTERNAL_ZOOM_ENABLED"
|
||||
addOns={
|
||||
externalProvidersModel.EXTERNAL_ZOOM_ENABLED && (
|
||||
<a
|
||||
className="pl-4 text-scale-900"
|
||||
href="https://developers.zoom.us/"
|
||||
target="_blank"
|
||||
>
|
||||
Create new credentials
|
||||
</a>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{externalProvidersModel.EXTERNAL_ZOOM_ENABLED && (
|
||||
<>
|
||||
<AutoField
|
||||
name="EXTERNAL_ZOOM_CLIENT_ID"
|
||||
showInlineError
|
||||
errorMessage="Please enter the client id."
|
||||
/>
|
||||
<SecretField
|
||||
name="EXTERNAL_ZOOM_SECRET"
|
||||
showInlineError
|
||||
errorMessage="Please enter the secret."
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SchemaFormPanel>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -267,81 +267,76 @@ const ServiceList: FC<any> = ({ projectRef }) => {
|
||||
<Flag name="jwtSecretUpdate">
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 px-6 dark:bg-bg-alt-dark bg-bg-alt-light rounded-md shadow-sm border dark:border-dark">
|
||||
{isJwtSecretUpdateFailed ? (
|
||||
<Alert withIcon variant="warning" title="Failed to update JWT secret">
|
||||
Please contact Supabase support with the following details: <br />
|
||||
Change tracking ID: {changeTrackingId} <br />
|
||||
Error message: {jwtSecretUpdateErrorMessage}
|
||||
</Alert>
|
||||
{isUpdatingJwtSecret ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<IconLoader className="animate-spin" size={14} />
|
||||
<p className="text-sm">
|
||||
Updating JWT secret: {jwtSecretUpdateProgressMessage}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{isUpdatingJwtSecret ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<IconLoader className="animate-spin" size={14} />
|
||||
<p className="text-sm">
|
||||
Updating JWT secret: {jwtSecretUpdateProgressMessage}
|
||||
<div className="w-full space-y-2">
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Typography.Text>Generate a new JWT secret</Typography.Text>
|
||||
<p className="opacity-50 text-sm">
|
||||
A random secret will be created, or you can create your own.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full space-y-2">
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Typography.Text>Generate a new JWT secret</Typography.Text>
|
||||
<p className="opacity-50 text-sm">
|
||||
A random secret will be created, or you can create your own.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
{isUpdatingJwtSecret ? (
|
||||
<Button loading type="secondary">
|
||||
Updating JWT secret...
|
||||
</Button>
|
||||
) : (
|
||||
<Dropdown
|
||||
align="end"
|
||||
side="bottom"
|
||||
overlay={
|
||||
<>
|
||||
<Dropdown.Item
|
||||
onClick={() => setIsGeneratingKey(true)}
|
||||
icon={<IconRefreshCw size={16} />}
|
||||
>
|
||||
Generate a random secret
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Seperator />
|
||||
<Dropdown.Item
|
||||
onClick={() => setIsCreatingKey(true)}
|
||||
icon={<IconPenTool size={16} />}
|
||||
>
|
||||
Create my own secret
|
||||
</Dropdown.Item>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
as="span"
|
||||
type="default"
|
||||
iconRight={<IconChevronDown />}
|
||||
<div className="flex flex-col items-end">
|
||||
{isUpdatingJwtSecret ? (
|
||||
<Button loading type="secondary">
|
||||
Updating JWT secret...
|
||||
</Button>
|
||||
) : (
|
||||
<Dropdown
|
||||
align="end"
|
||||
side="bottom"
|
||||
overlay={
|
||||
<>
|
||||
<Dropdown.Item
|
||||
onClick={() => setIsGeneratingKey(true)}
|
||||
icon={<IconRefreshCw size={16} />}
|
||||
>
|
||||
Generate a new secret
|
||||
</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Alert
|
||||
withIcon
|
||||
variant="warning"
|
||||
title="This will invalidate all existing API keys!"
|
||||
>
|
||||
Your project will also be restarted during this process, which will
|
||||
terminate any existing connections.
|
||||
</Alert>
|
||||
Generate a random secret
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Seperator />
|
||||
<Dropdown.Item
|
||||
onClick={() => setIsCreatingKey(true)}
|
||||
icon={<IconPenTool size={16} />}
|
||||
>
|
||||
Create my own secret
|
||||
</Dropdown.Item>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button as="span" type="default" iconRight={<IconChevronDown />}>
|
||||
Generate a new secret
|
||||
</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isJwtSecretUpdateFailed ? (
|
||||
<Alert withIcon variant="warning" title="Failed to update JWT secret">
|
||||
Please try again. If the failures persist, please contact Supabase support
|
||||
with the following details: <br />
|
||||
Change tracking ID: {changeTrackingId} <br />
|
||||
Error message: {jwtSecretUpdateErrorMessage}
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert
|
||||
withIcon
|
||||
variant="warning"
|
||||
title="This will invalidate all existing API keys!"
|
||||
>
|
||||
Your project will also be restarted during this process, which will terminate
|
||||
any existing connections.
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</Flag>
|
||||
</Panel.Content>
|
||||
|
||||
@@ -108,6 +108,17 @@ const Usage: FC<any> = ({ project }) => {
|
||||
provider={'infra-monitoring'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{dateRange && (
|
||||
<ChartHandler
|
||||
startDate={dateRange?.period_start?.date}
|
||||
endDate={dateRange?.period_end?.date}
|
||||
attribute={'disk_io_budget'}
|
||||
label={'Daily Disk IO Budget remaining'}
|
||||
interval={dateRange.interval}
|
||||
provider={'infra-monitoring'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Panel.Content>
|
||||
</Panel>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
Button,
|
||||
IconInfo,
|
||||
Card,
|
||||
Loading,
|
||||
Input,
|
||||
} from '@supabase/ui'
|
||||
|
||||
import { withAuth } from 'hooks'
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
LogData,
|
||||
LogSearchCallback,
|
||||
LOG_TYPE_LABEL_MAPPING,
|
||||
genDefaultQuery,
|
||||
genCountQuery,
|
||||
} from 'components/interfaces/Settings/Logs'
|
||||
import { uuidv4 } from 'lib/helpers'
|
||||
import useSWRInfinite, { SWRInfiniteKeyLoader } from 'swr/infinite'
|
||||
@@ -60,30 +62,28 @@ export const LogPage: NextPage = () => {
|
||||
type: '',
|
||||
search_query: '',
|
||||
sql: '',
|
||||
where: '',
|
||||
timestamp_start: '',
|
||||
timestamp_end: '',
|
||||
})
|
||||
const title = `Logs - ${LOG_TYPE_LABEL_MAPPING[type as keyof typeof LOG_TYPE_LABEL_MAPPING]}`
|
||||
const checkIfSelectQuery = (value: string) =>
|
||||
value.toLowerCase().includes('select') ? true : false
|
||||
const isSelectQuery = checkIfSelectQuery(editorValue)
|
||||
|
||||
const table = type === 'api' ? 'edge_logs' : 'postgres_logs'
|
||||
useEffect(() => {
|
||||
setParams({ ...params, type: type as string })
|
||||
}, [type])
|
||||
|
||||
useEffect(() => {
|
||||
// on mount, set initial values
|
||||
if (q) {
|
||||
if (q !== undefined && q !== '') {
|
||||
onSelectTemplate({
|
||||
mode: 'custom',
|
||||
searchString: q as string,
|
||||
})
|
||||
} else if (s) {
|
||||
} else {
|
||||
onSelectTemplate({
|
||||
mode: 'simple',
|
||||
searchString: s as string,
|
||||
searchString: (s || '') as string,
|
||||
})
|
||||
}
|
||||
if (te) {
|
||||
@@ -91,7 +91,7 @@ export const LogPage: NextPage = () => {
|
||||
} else {
|
||||
setParams((prev) => ({ ...prev, timestamp_end: '' }))
|
||||
}
|
||||
}, [])
|
||||
}, [q, s, te])
|
||||
|
||||
const genQueryParams = (params: { [k: string]: string }) => {
|
||||
// remove keys which are empty strings, null, or undefined
|
||||
@@ -105,23 +105,23 @@ export const LogPage: NextPage = () => {
|
||||
return qs
|
||||
}
|
||||
// handle log fetching
|
||||
const getKeyLogs: SWRInfiniteKeyLoader = (_pageIndex: number, prevPageData) => {
|
||||
const getKeyLogs: SWRInfiniteKeyLoader = (_pageIndex: number, prevPageData: Logs) => {
|
||||
let queryParams
|
||||
// if prev page data is 100 items, could possibly have more records that are not yet fetched within this interval
|
||||
if (prevPageData === null) {
|
||||
// reduce interval window limit by using the timestamp of the last log
|
||||
queryParams = genQueryParams(params)
|
||||
} else if ((prevPageData?.data ?? []).length === 0) {
|
||||
} else if ((prevPageData.result ?? []).length === 0) {
|
||||
// no rows returned, indicates that no more data to retrieve and append.
|
||||
return null
|
||||
} else {
|
||||
const len = prevPageData.data.length
|
||||
const { timestamp: tsLimit }: LogData = prevPageData.data[len - 1]
|
||||
const len = prevPageData.result.length
|
||||
const { timestamp: tsLimit }: LogData = prevPageData.result[len - 1]
|
||||
// create new key from params
|
||||
queryParams = genQueryParams({ ...params, timestamp_end: String(tsLimit) })
|
||||
}
|
||||
|
||||
const logUrl = `${API_URL}/projects/${ref}/logs?${queryParams}`
|
||||
const logUrl = `${API_URL}/projects/${ref}/analytics/endpoints/logs.${type}?${queryParams}`
|
||||
return logUrl
|
||||
}
|
||||
const {
|
||||
@@ -133,23 +133,23 @@ export const LogPage: NextPage = () => {
|
||||
setSize,
|
||||
} = useSWRInfinite<Logs>(getKeyLogs, get, { revalidateOnFocus: false })
|
||||
let logData: LogData[] = []
|
||||
let error: null | string = swrError ? swrError.message : null
|
||||
data.forEach((response: Logs) => {
|
||||
if (!error && response && response.data) {
|
||||
logData = [...logData, ...response.data]
|
||||
let error: null | string | object = swrError ? swrError.message : null
|
||||
data.forEach((response) => {
|
||||
if (!error && response?.result) {
|
||||
logData = [...logData, ...response.result]
|
||||
}
|
||||
if (!error && response && response.error) {
|
||||
error = response.error
|
||||
}
|
||||
})
|
||||
|
||||
const countUrl = `${API_URL}/projects/${ref}/logs?${genQueryParams({
|
||||
const countUrl = `${API_URL}/projects/${ref}/analytics/endpoints/logs.${type}?${genQueryParams({
|
||||
...params,
|
||||
count: String(true),
|
||||
sql: genCountQuery(table),
|
||||
period_start: String(latestRefresh),
|
||||
})}`
|
||||
const { data: countData } = useSWR<Count>(countUrl, get, { refreshInterval: 5000 })
|
||||
const newCount = countData?.data?.[0]?.count ?? 0
|
||||
const newCount = countData?.result?.[0]?.count ?? 0
|
||||
|
||||
const handleRefresh = () => {
|
||||
setLatestRefresh(new Date().toISOString())
|
||||
@@ -167,7 +167,10 @@ export const LogPage: NextPage = () => {
|
||||
const handleModeToggle = () => {
|
||||
if (mode === 'simple') {
|
||||
setMode('custom')
|
||||
// setWhere(DEFAULT_QUERY)
|
||||
onSelectTemplate({
|
||||
mode: 'custom',
|
||||
searchString: genDefaultQuery(table),
|
||||
})
|
||||
} else {
|
||||
setMode('simple')
|
||||
}
|
||||
@@ -176,17 +179,12 @@ export const LogPage: NextPage = () => {
|
||||
const onSelectTemplate = (template: LogTemplate) => {
|
||||
setMode(template.mode)
|
||||
if (template.mode === 'simple') {
|
||||
setParams((prev) => ({ ...prev, search_query: template.searchString, sql: '', where: '' }))
|
||||
setParams((prev) => ({ ...prev, search_query: template.searchString, sql: '' }))
|
||||
} else {
|
||||
setEditorValue(template.searchString)
|
||||
setParams((prev) => ({
|
||||
...prev,
|
||||
where: checkIfSelectQuery(template.searchString)
|
||||
? ''
|
||||
: cleanEditorValue(template.searchString),
|
||||
sql: checkIfSelectQuery(template.searchString)
|
||||
? cleanEditorValue(template.searchString)
|
||||
: '',
|
||||
sql: template.searchString,
|
||||
search_query: '',
|
||||
timestamp_end: '',
|
||||
}))
|
||||
@@ -194,12 +192,7 @@ export const LogPage: NextPage = () => {
|
||||
}
|
||||
}
|
||||
const handleEditorSubmit = () => {
|
||||
setParams((prev) => ({
|
||||
...prev,
|
||||
where: isSelectQuery ? '' : cleanEditorValue(editorValue),
|
||||
sql: isSelectQuery ? cleanEditorValue(editorValue) : '',
|
||||
search_query: '',
|
||||
}))
|
||||
setParams((prev) => ({ ...prev, sql: editorValue, search_query: '' }))
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: {
|
||||
@@ -216,7 +209,6 @@ export const LogPage: NextPage = () => {
|
||||
...prev,
|
||||
search_query: query || '',
|
||||
timestamp_end: unixMicro ? String(unixMicro) : '',
|
||||
where: '',
|
||||
sql: '',
|
||||
}))
|
||||
router.push({
|
||||
@@ -265,7 +257,7 @@ export const LogPage: NextPage = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row justify-end items-center px-2 py-1 w-full">
|
||||
{isSelectQuery && (
|
||||
{mode === 'custom' && (
|
||||
<InformationBox
|
||||
className="shrink mr-auto"
|
||||
block={false}
|
||||
@@ -276,17 +268,15 @@ export const LogPage: NextPage = () => {
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-row gap-x-2 justify-end p-2">
|
||||
{editorValue && (
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => {
|
||||
setEditorValue('')
|
||||
setEditorId(uuidv4())
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => {
|
||||
setEditorValue(genDefaultQuery(table))
|
||||
setEditorId(uuidv4())
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button type={editorValue ? 'secondary' : 'text'} onClick={handleEditorSubmit}>
|
||||
Run
|
||||
</Button>
|
||||
@@ -317,9 +307,8 @@ export const LogPage: NextPage = () => {
|
||||
)}
|
||||
|
||||
<LogTable data={logData} isCustomQuery={mode === 'custom'} />
|
||||
{/* Footer section of log ui, appears below table */}
|
||||
<div className="p-2">
|
||||
{!isSelectQuery && (
|
||||
{mode === 'simple' && (
|
||||
<Button onClick={() => setSize(size + 1)} icon={<IconRewind />} type="default">
|
||||
Load older
|
||||
</Button>
|
||||
@@ -328,21 +317,19 @@ export const LogPage: NextPage = () => {
|
||||
|
||||
{error && (
|
||||
<div className="flex w-full h-full justify-center items-center mx-auto">
|
||||
<Card className="flex flex-col gap-y-2 w-1/3">
|
||||
<Card className="flex flex-col gap-y-2 w-2/5 bg-scale-400">
|
||||
<div className="flex flex-row gap-x-2 py-2">
|
||||
<IconAlertCircle size={16} />
|
||||
<Typography.Text type="secondary">
|
||||
Sorry! An error occured when fetching data.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<details className="cursor-pointer">
|
||||
<summary>
|
||||
<Typography.Text type="secondary">Error Message</Typography.Text>
|
||||
</summary>
|
||||
<Typography.Text className="block whitespace-pre-wrap" small code type="warning">
|
||||
{JSON.stringify(error, null, 2)}
|
||||
</Typography.Text>
|
||||
</details>
|
||||
<Input.TextArea
|
||||
label="Error Messages"
|
||||
value={JSON.stringify(error, null, 2)}
|
||||
borderless
|
||||
className=" border-t-2 border-scale-800 pt-2 font-mono"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useRouter } from 'next/router'
|
||||
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { useStore, withAuth } from 'hooks'
|
||||
import { post } from 'lib/common/fetch'
|
||||
import { post, get } from 'lib/common/fetch'
|
||||
import { Project } from 'types'
|
||||
import { isUndefined } from 'lodash'
|
||||
|
||||
@@ -195,17 +195,32 @@ const SupportNew = () => {
|
||||
setErrors([...errors])
|
||||
|
||||
if (errors.length === 0) {
|
||||
setLoading(true)
|
||||
const response = await post(`${API_URL}/feedback/send`, {
|
||||
const projectRef = formState.project.value
|
||||
const payload = {
|
||||
projectRef,
|
||||
message: formState.body.value,
|
||||
category: formState.category.value,
|
||||
projectRef: formState.project.value,
|
||||
verified: true,
|
||||
tags: ['dashboard-support-form'],
|
||||
subject: formState.subject.value,
|
||||
severity: formState.severity.value,
|
||||
})
|
||||
siteUrl: '',
|
||||
additionalRedirectUrls: '',
|
||||
}
|
||||
|
||||
if (projectRef !== 'no-project') {
|
||||
const URL = `${API_URL}/auth/${projectRef}/config`
|
||||
const authConfig = await get(URL)
|
||||
if (!authConfig.error) {
|
||||
payload.siteUrl = authConfig.SITE_URL
|
||||
payload.additionalRedirectUrls = authConfig.URI_ALLOW_LIST
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
const response = await post(`${API_URL}/feedback/send`, payload)
|
||||
setLoading(false)
|
||||
|
||||
if (response.error) {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createContext, useEffect, useState } from 'react'
|
||||
import { observer, useLocalObservable } from 'mobx-react-lite'
|
||||
import VercelIntegrationLayout from 'components/layouts/VercelIntegrationLayout'
|
||||
import { Button, Typography, IconLoader } from '@supabase/ui'
|
||||
import { Button, IconLoader } from '@supabase/ui'
|
||||
|
||||
const PageContext = createContext(null)
|
||||
function IntegrationComplete() {
|
||||
@@ -34,20 +34,15 @@ function IntegrationComplete() {
|
||||
<VercelIntegrationLayout>
|
||||
<div className="max-w-sm mx-auto">
|
||||
<IconLoader className="animate-spin" size={30} />
|
||||
<Typography.Title level={3}>
|
||||
<p className="pt-4">Your new project is spinning up</p>
|
||||
</Typography.Title>
|
||||
<Typography.Text>
|
||||
<p className="pt-2">This may take up to 2 mins, but you can continue on Vercel.</p>
|
||||
</Typography.Text>
|
||||
|
||||
<p className="pt-4 text-lg">Your new project is spinning up</p>
|
||||
<p className="pt-2">This may take up to 2 mins, but you can continue on Vercel.</p>
|
||||
<div className="py-4">
|
||||
<a
|
||||
href={`/project/${PageState.supabaseProjectRef}`}
|
||||
target="_blank"
|
||||
className="hover:text-green-500"
|
||||
>
|
||||
<Typography.Text>Open Supabase Dashboard →</Typography.Text>
|
||||
Open Supabase Dashboard →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -54,7 +54,6 @@ interface IVercelIntegrationStore {
|
||||
}[]
|
||||
|
||||
isDeployButtonFlow: boolean
|
||||
isSupabaseProjectListEmpty: boolean
|
||||
integrationComplete: boolean
|
||||
queryParams: Dictionary<string>
|
||||
projectLinkRemaining: number
|
||||
@@ -97,13 +96,6 @@ class VercelIntegrationStore implements IVercelIntegrationStore {
|
||||
return !!this.currentProjectId
|
||||
}
|
||||
|
||||
get isSupabaseProjectListEmpty() {
|
||||
const { app } = useStore()
|
||||
console.log('isSupabaseProjectListEmpty', app)
|
||||
const projects = app.projects.list()
|
||||
return projects.length == 0
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
get queryParams() {
|
||||
return {
|
||||
@@ -192,6 +184,9 @@ type VercelIntegrationProps = {} & any
|
||||
const VercelIntegration: FC<VercelIntegrationProps> = ({}) => {
|
||||
// @ts-ignore
|
||||
const _store: IVercelIntegrationStore = useLocalObservable(() => new VercelIntegrationStore())
|
||||
const { app } = useStore()
|
||||
const projects = app.projects.list()
|
||||
const isSupabaseProjectListEmpty = projects.length == 0
|
||||
|
||||
useEffect(() => {
|
||||
_store.loadInitialData()
|
||||
@@ -204,7 +199,7 @@ const VercelIntegration: FC<VercelIntegrationProps> = ({}) => {
|
||||
<Connecting />
|
||||
) : _store.isDeployButtonFlow ? (
|
||||
<IntegrationProject />
|
||||
) : _store.isSupabaseProjectListEmpty ? (
|
||||
) : isSupabaseProjectListEmpty ? (
|
||||
<ProjectLinksEmptyState />
|
||||
) : (
|
||||
<ProjectLinks />
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { FC, createContext, useEffect, useContext, useState } from 'react'
|
||||
import { createContext, useEffect, useContext, useState, ChangeEvent } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { observer, useLocalObservable } from 'mobx-react-lite'
|
||||
import { Transition } from '@headlessui/react'
|
||||
import { Button, Typography } from '@supabase/ui'
|
||||
import { Button, Input, Select } from '@supabase/ui'
|
||||
import { runInAction } from 'mobx'
|
||||
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { post } from 'lib/common/fetch'
|
||||
import { clickOutsideListener, useStore } from 'hooks'
|
||||
import { useStore } from 'hooks'
|
||||
import VercelIntegrationLayout from 'components/layouts/VercelIntegrationLayout'
|
||||
|
||||
const PageContext = createContext(null)
|
||||
@@ -86,7 +85,7 @@ const CreateOrganization = observer(({}) => {
|
||||
|
||||
async function onClick() {
|
||||
setLoading(true)
|
||||
const response = await post(`${API_URL}/organizations/new`, {
|
||||
const response = await post(`${API_URL}/organizations`, {
|
||||
name: orgName,
|
||||
})
|
||||
if (response.error) {
|
||||
@@ -111,29 +110,24 @@ const CreateOrganization = observer(({}) => {
|
||||
return (
|
||||
<div className="">
|
||||
{sortedOrganizations?.length > 0 ? (
|
||||
<Typography.Title level={3}>Create a new organization</Typography.Title>
|
||||
<p className="mb-2">Create a new organization</p>
|
||||
) : (
|
||||
<>
|
||||
<Typography.Title level={3}>You do not have an organization</Typography.Title>
|
||||
<Typography.Text>You will need an organization to deploy this project</Typography.Text>
|
||||
<p className="mb-2">You do not have any organization</p>
|
||||
<p>You will need an organization to deploy this project</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="py-4">
|
||||
<label htmlFor="orgName" className="block w-full text-base normal-case">
|
||||
Organization name
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
name="orgName"
|
||||
id="orgName"
|
||||
value={orgName}
|
||||
onChange={onOrgNameChange}
|
||||
placeholder="My organization"
|
||||
className="shadow-sm focus:ring-green-500 focus:border-green-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
autoFocus
|
||||
label="Name"
|
||||
type="text"
|
||||
placeholder="Organization name"
|
||||
descriptionText="What's the name of your company or team?"
|
||||
value={orgName}
|
||||
onChange={onOrgNameChange}
|
||||
/>
|
||||
</div>
|
||||
<Button disabled={loading || orgName == ''} loading={loading} onClick={onClick}>
|
||||
Create organization
|
||||
@@ -150,15 +144,11 @@ const OrgSelection = observer(({}) => {
|
||||
const { app } = useStore()
|
||||
const sortedOrganizations = app.organizations.list()
|
||||
|
||||
const clickContainerRef = clickOutsideListener(() => {
|
||||
if (PageState.selectMenuOpened) PageState.toggleSelectMenu(!PageState.selectMenuOpened)
|
||||
})
|
||||
|
||||
function onOpenMenu() {
|
||||
runInAction(() => {
|
||||
PageState.toggleSelectMenu(true)
|
||||
})
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!PageState.selectedOrg) {
|
||||
PageState.selectedOrg = sortedOrganizations?.length > 0 ? sortedOrganizations[0] : undefined
|
||||
}
|
||||
}, [sortedOrganizations])
|
||||
|
||||
function onContinue() {
|
||||
setLoading(true)
|
||||
@@ -173,60 +163,27 @@ const OrgSelection = observer(({}) => {
|
||||
})
|
||||
}
|
||||
|
||||
function onOrgSelect(e: ChangeEvent<HTMLSelectElement>) {
|
||||
const selectedOrg = sortedOrganizations.find((x: any) => x.slug === e.target.value)
|
||||
PageState.selectedOrg = selectedOrg
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<label id="listbox-label" className="block w-full text-base normal-case">
|
||||
Choose an organization
|
||||
</label>
|
||||
<div ref={clickContainerRef} className="mt-1 mb-8 relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="true"
|
||||
aria-labelledby="listbox-label"
|
||||
className="bg-white relative w-full border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||||
onClick={onOpenMenu}
|
||||
<div className="mt-1 mb-8 relative">
|
||||
<Select
|
||||
label="Choose an organization"
|
||||
value={PageState.selectedOrg?.slug}
|
||||
onChange={onOrgSelect}
|
||||
>
|
||||
<span className="block truncate text-gray-700">{PageState.selectedOrg?.name || '-'}</span>
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<svg
|
||||
className="h-5 w-5 text-gray-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<Transition
|
||||
show={PageState.selectMenuOpened}
|
||||
enter=""
|
||||
enterFrom=""
|
||||
enterTo=""
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="absolute z-10 mt-1 w-full rounded-md bg-white shadow-lg">
|
||||
<ul
|
||||
tabIndex={-1}
|
||||
role="listbox"
|
||||
aria-labelledby="listbox-label"
|
||||
className="rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm"
|
||||
style={{ maxHeight: '15rem' }}
|
||||
>
|
||||
{sortedOrganizations?.map((x) => (
|
||||
<OrgMenuItem key={x.id} org={x} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Transition>
|
||||
{sortedOrganizations?.map((x) => {
|
||||
return (
|
||||
<Select.Option key={x.id} value={x.slug}>
|
||||
{x.name}
|
||||
</Select.Option>
|
||||
)
|
||||
})}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<Button
|
||||
@@ -244,70 +201,3 @@ const OrgSelection = observer(({}) => {
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const OrgMenuItem: FC<any> = observer(({ org }) => {
|
||||
const PageState: any = useContext(PageContext)
|
||||
const selected = PageState.selectedOrg?.slug == org.slug
|
||||
const [highlighted, setHighlighted] = useState(false)
|
||||
|
||||
function onMouseEnter() {
|
||||
setHighlighted(true)
|
||||
}
|
||||
|
||||
function onMouseOver() {
|
||||
if (!highlighted) setHighlighted(true)
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
setHighlighted(false)
|
||||
}
|
||||
|
||||
function onSelect() {
|
||||
PageState.selectedOrg = org
|
||||
PageState.selectMenuOpened = false
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
id="listbox-option-0"
|
||||
role="option"
|
||||
className={`${
|
||||
highlighted ? 'bg-green-500' : ''
|
||||
} cursor-default select-none relative py-2 pl-3 pr-9`}
|
||||
onClick={onSelect}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseOver={onMouseOver}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<span
|
||||
className={`${selected ? 'font-semibold' : 'font-normal'} ${
|
||||
highlighted ? 'text-white' : 'text-gray-700'
|
||||
} block truncate `}
|
||||
>
|
||||
{org.name}
|
||||
</span>
|
||||
|
||||
{selected && (
|
||||
<span
|
||||
className={`${
|
||||
highlighted ? 'text-white' : 'text-green-500'
|
||||
} absolute inset-y-0 right-0 flex items-center pr-4`}
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import { useRouter } from 'next/router'
|
||||
import { observer, useLocalObservable } from 'mobx-react-lite'
|
||||
import { makeAutoObservable } from 'mobx'
|
||||
import { debounce } from 'lodash'
|
||||
import { Button, Typography } from '@supabase/ui'
|
||||
import { Button, Input, Listbox, Typography } from '@supabase/ui'
|
||||
import { Dictionary } from '@supabase/grid'
|
||||
|
||||
import { useStore } from 'hooks'
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
REGIONS_DEFAULT,
|
||||
DEFAULT_MINIMUM_PASSWORD_STRENGTH,
|
||||
API_URL,
|
||||
PRICING_PLANS_DEFAULT,
|
||||
} from 'lib/constants'
|
||||
import { VERCEL_INTEGRATION_CONFIGS } from 'lib/vercelConfigs'
|
||||
import {
|
||||
@@ -27,14 +28,6 @@ import VercelIntegrationLayout from 'components/layouts/VercelIntegrationLayout'
|
||||
import Loading from 'components/ui/Loading'
|
||||
import PasswordStrengthBar from 'components/ui/PasswordStrengthBar'
|
||||
|
||||
const PASSWORD_STRENGTH = {
|
||||
0: "That's terrible.",
|
||||
1: 'Pathetic.',
|
||||
2: 'Weak.',
|
||||
3: 'Not bad. Can you do better?',
|
||||
4: "Strong. Let's get started!",
|
||||
}
|
||||
|
||||
interface ISetupProjectStore {
|
||||
token: string
|
||||
teamId: string
|
||||
@@ -187,8 +180,8 @@ const CreateProject = observer(() => {
|
||||
} else delayedCheckPasswordStrength(value)
|
||||
}
|
||||
|
||||
function onDbRegionChange(e: ChangeEvent<HTMLSelectElement>) {
|
||||
setDbRegion(e.target.value)
|
||||
function onDbRegionChange(value: string) {
|
||||
setDbRegion(value)
|
||||
}
|
||||
|
||||
async function checkPasswordStrength(value: string) {
|
||||
@@ -205,6 +198,7 @@ const CreateProject = observer(() => {
|
||||
db_pass: dbPass,
|
||||
db_region: dbRegion,
|
||||
db_sql: dbSql || '',
|
||||
db_pricing_plan: PRICING_PLANS_DEFAULT,
|
||||
auth_site_url: _store.selectedVercelProjectUrl,
|
||||
vercel_configuration_id: _store.configurationId,
|
||||
}
|
||||
@@ -233,9 +227,7 @@ const CreateProject = observer(() => {
|
||||
const project = response
|
||||
_store.supabaseProjectRef = project.ref
|
||||
|
||||
// console.log('new project', project)
|
||||
const envs = prepareVercelEvns(requiredEnvs, project)
|
||||
// console.log('envs', envs)
|
||||
|
||||
await Promise.allSettled(
|
||||
envs.map(async (env: any) => {
|
||||
@@ -263,71 +255,64 @@ const CreateProject = observer(() => {
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<Typography.Title level={3}>Project details for integration</Typography.Title>
|
||||
<p className="mb-2">Project details for integration</p>
|
||||
<div className="py-2">
|
||||
<label htmlFor="projectName" className="block w-full text-base normal-case">
|
||||
Project name
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
name="projectName"
|
||||
id="projectName"
|
||||
value={projectName}
|
||||
onChange={onProjectNameChange}
|
||||
placeholder="Project name"
|
||||
className="shadow-sm focus:ring-green-500 focus:border-green-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
autoFocus
|
||||
id="projectName"
|
||||
label="Project name"
|
||||
type="text"
|
||||
placeholder=""
|
||||
descriptionText=""
|
||||
value={projectName}
|
||||
onChange={onProjectNameChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="py-2">
|
||||
<label htmlFor="dbPass" className="block w-full text-base normal-case">
|
||||
Database password
|
||||
</label>
|
||||
<div className="mt-1 space-y-2">
|
||||
<input
|
||||
type="password"
|
||||
name="dbPass"
|
||||
id="dbPass"
|
||||
value={dbPass}
|
||||
onChange={onDbPassChange}
|
||||
placeholder="· · · · · · · · · · · · ·"
|
||||
className="shadow-sm focus:ring-green-500 focus:border-green-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
/>
|
||||
<PasswordStrengthBar
|
||||
password={dbPass}
|
||||
passwordStrengthScore={passwordStrengthScore}
|
||||
passwordStrengthMessage={passwordStrengthMessage}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
id="dbPass"
|
||||
label="Database Password"
|
||||
type="password"
|
||||
placeholder="Type in a strong password"
|
||||
value={dbPass}
|
||||
onChange={onDbPassChange}
|
||||
descriptionText={
|
||||
<PasswordStrengthBar
|
||||
passwordStrengthScore={passwordStrengthScore}
|
||||
password={dbPass}
|
||||
passwordStrengthMessage={passwordStrengthMessage}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="py-2 pb-4">
|
||||
<label htmlFor="projectName" className="block w-full text-base normal-case">
|
||||
Database region
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<select
|
||||
id="dbRegion"
|
||||
name="dbRegion"
|
||||
<Listbox
|
||||
label="Region"
|
||||
type="select"
|
||||
value={dbRegion}
|
||||
onChange={onDbRegionChange}
|
||||
className="focus:ring-green-500 focus:border-green-500 relative block w-full rounded-md bg-transparent focus:z-10 sm:text-sm border-gray-300"
|
||||
descriptionText="Select a region close to you for the best performance."
|
||||
>
|
||||
{Object.keys(REGIONS).map((choice) => {
|
||||
{Object.keys(REGIONS).map((option: string, i) => {
|
||||
const label = Object.values(REGIONS)[i]
|
||||
return (
|
||||
<option
|
||||
key={(REGIONS as any)[choice]}
|
||||
value={(REGIONS as any)[choice]}
|
||||
className="text-black"
|
||||
<Listbox.Option
|
||||
key={option}
|
||||
label={label}
|
||||
value={label}
|
||||
addOnBefore={({ active, selected }: any) => (
|
||||
<img
|
||||
className="w-5 rounded-sm"
|
||||
src={`/img/regions/${Object.keys(REGIONS)[i]}.svg`}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{(REGIONS as any)[choice]}
|
||||
</option>
|
||||
<span className="text-scale-1200">{label}</span>
|
||||
</Listbox.Option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
<Typography.Text type="secondary">
|
||||
<p className="pt-2">Select a region close to you for the best performance.</p>
|
||||
</Typography.Text>
|
||||
</Listbox>
|
||||
</div>
|
||||
</div>
|
||||
<Button disabled={loading || !canSubmit} loading={loading} onClick={onCreateProject}>
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"EXTERNAL_TWITTER_ENABLED",
|
||||
"EXTERNAL_SLACK_ENABLED",
|
||||
"EXTERNAL_SPOTIFY_ENABLED",
|
||||
"EXTERNAL_ZOOM_ENABLED",
|
||||
"SMS_AUTOCONFIRM",
|
||||
"SMS_MAX_FREQUENCY",
|
||||
"SMS_OTP_EXP",
|
||||
@@ -110,6 +111,10 @@
|
||||
"title": "Azure secret",
|
||||
"type": "string"
|
||||
},
|
||||
"EXTERNAL_AZURE_URL": {
|
||||
"title": "Azure tenant URL",
|
||||
"type": "string"
|
||||
},
|
||||
"EXTERNAL_BITBUCKET_ENABLED": {
|
||||
"title": "Bitbucket enabled",
|
||||
"type": "boolean"
|
||||
@@ -258,6 +263,18 @@
|
||||
"title": "Spotify secret",
|
||||
"type": "string"
|
||||
},
|
||||
"EXTERNAL_ZOOM_ENABLED": {
|
||||
"title": "Zoom enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"EXTERNAL_ZOOM_CLIENT_ID": {
|
||||
"title": "Zoom client ID",
|
||||
"type": "string"
|
||||
},
|
||||
"EXTERNAL_ZOOM_SECRET": {
|
||||
"title": "Zoom secret",
|
||||
"type": "string"
|
||||
},
|
||||
"SMTP_ADMIN_EMAIL": {
|
||||
"title": "SMTP Admin Email",
|
||||
"type": "string"
|
||||
@@ -375,6 +392,10 @@
|
||||
"title": "Body",
|
||||
"type": "string"
|
||||
},
|
||||
"PASSWORD_MIN_LENGTH": {
|
||||
"title": "Minimum password length",
|
||||
"type": "integer"
|
||||
},
|
||||
"SMS_AUTOCONFIRM": {
|
||||
"title": "Enable phone confirmations",
|
||||
"type": "boolean",
|
||||
|
||||
@@ -271,7 +271,7 @@
|
||||
}
|
||||
|
||||
::selection {
|
||||
@apply bg-brand-900;
|
||||
background-color: #6ee7b7;
|
||||
color: #333 !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,14 @@ observer.mockImplementation((v) => v)
|
||||
// mock the router
|
||||
jest.mock('next/router')
|
||||
import { useRouter } from 'next/router'
|
||||
const router = jest.fn()
|
||||
router.query = { ref: '123', type: 'auth' }
|
||||
router.push = jest.fn()
|
||||
router.pathname = 'logs/path'
|
||||
useRouter.mockReturnValue(router)
|
||||
const defaultRouterMock = () => {
|
||||
const router = jest.fn()
|
||||
router.query = { ref: '123', type: 'auth' }
|
||||
router.push = jest.fn()
|
||||
router.pathname = 'logs/path'
|
||||
return router
|
||||
}
|
||||
useRouter.mockReturnValue(defaultRouterMock())
|
||||
|
||||
// mock monaco editor
|
||||
jest.mock('@monaco-editor/react')
|
||||
@@ -60,50 +63,48 @@ LogPage.mockImplementation((props) => {
|
||||
|
||||
import { render, fireEvent, waitFor, screen, act } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { clickDropdown, getToggleByText } from '../../helpers'
|
||||
import { getToggleByText } from '../../helpers'
|
||||
import { wait } from '@testing-library/user-event/dist/utils'
|
||||
import { find } from 'lodash'
|
||||
import { logDataFixture } from '../../fixtures'
|
||||
beforeEach(() => {
|
||||
// reset mocks between tests
|
||||
get.mockReset()
|
||||
useRouter.mockReset()
|
||||
useRouter.mockReturnValue(defaultRouterMock())
|
||||
})
|
||||
test('can display log data and metadata', async () => {
|
||||
const data = [
|
||||
logDataFixture({
|
||||
event_message: 'some event happened',
|
||||
metadata: {
|
||||
my_key: 'something_value',
|
||||
},
|
||||
}),
|
||||
]
|
||||
get.mockResolvedValue({ data })
|
||||
render(<LogPage />)
|
||||
|
||||
await waitFor(() => screen.getByText(/happened/))
|
||||
const row = screen.getByText(/happened/)
|
||||
fireEvent.click(row)
|
||||
await waitFor(() => screen.getByText(/my_key/))
|
||||
await waitFor(() => screen.getByText(/something_value/))
|
||||
})
|
||||
|
||||
test('Refreshpage', async () => {
|
||||
const data = [
|
||||
logDataFixture({
|
||||
event_message: 'some event happened',
|
||||
metadata: {
|
||||
my_key: 'something_value',
|
||||
},
|
||||
}),
|
||||
]
|
||||
get.mockImplementation((url) => {
|
||||
if (url.includes('count')) return { count: 0 }
|
||||
return { data }
|
||||
get.mockResolvedValue({
|
||||
result: [
|
||||
logDataFixture({
|
||||
event_message: 'some event happened',
|
||||
metadata: {
|
||||
my_key: 'something_value',
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
render(<LogPage />)
|
||||
await waitFor(() => screen.getByText(/happened/))
|
||||
get.mockResolvedValueOnce({ data: [] })
|
||||
const row = screen.getByText(/happened/)
|
||||
fireEvent.click(await screen.findByText(/happened/))
|
||||
await screen.findByText(/my_key/)
|
||||
await screen.findByText(/something_value/)
|
||||
})
|
||||
|
||||
test('Refresh page', async () => {
|
||||
get.mockImplementation((url) => {
|
||||
if (url.includes('count')) return { result: { count: 0 } }
|
||||
return {
|
||||
result: [
|
||||
logDataFixture({
|
||||
event_message: 'some event happened',
|
||||
metadata: { my_key: 'something_value' },
|
||||
}),
|
||||
],
|
||||
}
|
||||
})
|
||||
render(<LogPage />)
|
||||
|
||||
const row = await screen.findByText(/happened/)
|
||||
get.mockResolvedValueOnce({ result: [] })
|
||||
fireEvent.click(row)
|
||||
await waitFor(() => screen.getByText(/my_key/))
|
||||
|
||||
@@ -118,10 +119,10 @@ test('Search will trigger a log refresh', async () => {
|
||||
get.mockImplementation((url) => {
|
||||
if (url.includes('search_query') && url.includes('something')) {
|
||||
return {
|
||||
data: [logDataFixture({ event_message: 'some event happened' })],
|
||||
result: [logDataFixture({ event_message: 'some event happened' })],
|
||||
}
|
||||
}
|
||||
return { data: [] }
|
||||
return { result: [] }
|
||||
})
|
||||
render(<LogPage />)
|
||||
|
||||
@@ -134,6 +135,7 @@ test('Search will trigger a log refresh', async () => {
|
||||
expect(get).toHaveBeenCalledWith(expect.stringContaining('something'))
|
||||
|
||||
// updates router query params
|
||||
const router = useRouter()
|
||||
expect(router.push).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pathname: expect.any(String),
|
||||
@@ -152,10 +154,10 @@ test('Search will trigger a log refresh', async () => {
|
||||
test('poll count for new messages', async () => {
|
||||
get.mockImplementation((url) => {
|
||||
if (url.includes('count')) {
|
||||
return { data: [{ count: 125 }] }
|
||||
return { result: [{ count: 125 }] }
|
||||
}
|
||||
return {
|
||||
data: [logDataFixture({ event_message: 'something happened' })],
|
||||
result: [logDataFixture({ event_message: 'something happened' })],
|
||||
}
|
||||
})
|
||||
render(<LogPage />)
|
||||
@@ -168,81 +170,24 @@ test('poll count for new messages', async () => {
|
||||
await waitFor(() => screen.getByText(/happened/))
|
||||
})
|
||||
|
||||
test('where clause will trigger a log refresh', async () => {
|
||||
get.mockImplementation((url) => {
|
||||
if (url.includes('where') && url.includes('something')) {
|
||||
return {
|
||||
data: [logDataFixture({ event_message: 'some event happened' })],
|
||||
}
|
||||
}
|
||||
return { data: [] }
|
||||
})
|
||||
const { container } = render(<LogPage />)
|
||||
// fill search bar with some value, should be ignored when in custom mode
|
||||
userEvent.type(screen.getByPlaceholderText(/Search/), 'search_value')
|
||||
userEvent.click(screen.getByTitle('Go'))
|
||||
// clear mock calls, for clean assertions
|
||||
get.mockClear()
|
||||
|
||||
let editor = container.querySelector('.monaco-editor')
|
||||
expect(editor).toBeFalsy()
|
||||
// TODO: abstract this out into a toggle selection helper
|
||||
const toggle = getToggleByText(/via query/)
|
||||
expect(toggle).toBeTruthy()
|
||||
userEvent.click(toggle)
|
||||
await waitFor(() => {
|
||||
editor = container.querySelector('.monaco-editor')
|
||||
expect(editor).toBeTruthy()
|
||||
})
|
||||
editor = container.querySelector('.monaco-editor')
|
||||
userEvent.type(editor, 'metadata.field = something')
|
||||
userEvent.click(screen.getByText('Run'))
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(get).toHaveBeenCalledWith(expect.stringContaining('where'))
|
||||
expect(get).toHaveBeenCalledWith(expect.stringContaining('metadata.field'))
|
||||
|
||||
// updates router query params
|
||||
expect(router.push).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pathname: expect.any(String),
|
||||
query: expect.objectContaining({
|
||||
q: expect.stringContaining('something'),
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
// should ignore search bar value
|
||||
expect(get).not.toHaveBeenCalledWith(expect.stringContaining('search_value'))
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
)
|
||||
|
||||
await waitFor(() => screen.getByText(/happened/))
|
||||
})
|
||||
|
||||
test('s= query param will populate the search bar', async () => {
|
||||
useRouter.mockReturnValueOnce({
|
||||
query: { ref: '123', type: 'api', s: 'someSearch' },
|
||||
push: jest.fn(),
|
||||
})
|
||||
const router = defaultRouterMock()
|
||||
router.query = { ...router.query, type: 'api', s: 'someSearch' }
|
||||
useRouter.mockReturnValue(router)
|
||||
render(<LogPage />)
|
||||
// should populate search input with the search param
|
||||
screen.getByDisplayValue('someSearch')
|
||||
await waitFor(() => {
|
||||
expect(get).toHaveBeenCalledWith(expect.stringContaining('search_query=someSearch'))
|
||||
})
|
||||
await screen.findByDisplayValue('someSearch')
|
||||
expect(get).toHaveBeenCalledWith(expect.stringContaining('search_query=someSearch'))
|
||||
})
|
||||
|
||||
test('q= query param will populate the query input', async () => {
|
||||
useRouter.mockReturnValueOnce({
|
||||
query: { ref: '123', type: 'api', q: 'some_query', s: 'someSearch' },
|
||||
push: jest.fn(),
|
||||
})
|
||||
const router = defaultRouterMock()
|
||||
router.query = { ...router.query, type: 'api', q: 'some_query', s: 'someSearch' }
|
||||
useRouter.mockReturnValue(router)
|
||||
render(<LogPage />)
|
||||
// should populate editor with the query param
|
||||
await waitFor(() => {
|
||||
expect(get).toHaveBeenCalledWith(expect.stringContaining('where=some_query'))
|
||||
expect(get).toHaveBeenCalledWith(expect.stringContaining('sql=some_query'))
|
||||
})
|
||||
|
||||
// query takes precedence of search queryparam
|
||||
@@ -255,11 +200,9 @@ test('te= query param will populate the timestamp from input', async () => {
|
||||
newDate.setMinutes(new Date().getMinutes() - 20)
|
||||
const isoString = newDate.toISOString()
|
||||
const unixMicro = newDate.getTime() * 1000 //microseconds
|
||||
|
||||
useRouter.mockReturnValueOnce({
|
||||
query: { ref: '123', type: 'api', te: unixMicro },
|
||||
push: jest.fn(),
|
||||
})
|
||||
const router = defaultRouterMock()
|
||||
router.query = { ...router.query, te: unixMicro }
|
||||
useRouter.mockReturnValue(router)
|
||||
render(<LogPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -274,14 +217,14 @@ test('custom sql querying', async () => {
|
||||
get.mockImplementation((url) => {
|
||||
if (url.includes('sql=') && url.includes('select')) {
|
||||
return {
|
||||
data: [
|
||||
result: [
|
||||
{
|
||||
my_count: 12345,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
return { data: [] }
|
||||
return { result: [] }
|
||||
})
|
||||
const { container } = render(<LogPage />)
|
||||
let editor = container.querySelector('.monaco-editor')
|
||||
@@ -297,15 +240,18 @@ test('custom sql querying', async () => {
|
||||
expect(editor).toBeTruthy()
|
||||
})
|
||||
editor = container.querySelector('.monaco-editor')
|
||||
// clear the default query
|
||||
userEvent.type(editor, '{backspace}'.repeat(100))
|
||||
// type new query
|
||||
userEvent.type(editor, 'select \ncount(*) as my_count \nfrom edge_logs')
|
||||
// should show sandbox warning alert
|
||||
await waitFor(() => screen.getByText(/restricted to a 7 day querying window/))
|
||||
await screen.findByText(/restricted to a 7 day querying window/)
|
||||
|
||||
// should trigger query
|
||||
userEvent.click(screen.getByText('Run'))
|
||||
userEvent.click(await screen.findByText('Run'))
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(get).not.toHaveBeenCalledWith(expect.stringContaining(encodeURI('\n')))
|
||||
expect(get).toHaveBeenCalledWith(expect.stringContaining(encodeURI('\n')))
|
||||
expect(get).toHaveBeenCalledWith(expect.stringContaining('sql='))
|
||||
expect(get).toHaveBeenCalledWith(expect.stringContaining('select'))
|
||||
expect(get).toHaveBeenCalledWith(expect.stringContaining('edge_logs'))
|
||||
@@ -314,11 +260,11 @@ test('custom sql querying', async () => {
|
||||
{ timeout: 1000 }
|
||||
)
|
||||
|
||||
await waitFor(() => screen.getByText(/my_count/)) //column header
|
||||
await waitFor(() => screen.getByText(/12345/)) // row value
|
||||
await screen.findByText(/my_count/) //column header
|
||||
const rowValue = await screen.findByText(/12345/) // row value
|
||||
|
||||
// clicking on the row value should not show log selection panel
|
||||
userEvent.click(screen.getByText(/12345/))
|
||||
userEvent.click(rowValue)
|
||||
await expect(screen.findByText(/Metadata/)).rejects.toThrow()
|
||||
|
||||
// should not see chronological features
|
||||
@@ -331,24 +277,22 @@ test('load older btn will fetch older logs', async () => {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
data: [logDataFixture({ event_message: 'first event' })],
|
||||
result: [logDataFixture({ event_message: 'first event' })],
|
||||
}
|
||||
})
|
||||
render(<LogPage />)
|
||||
// should display first log but not second
|
||||
await waitFor(() => screen.getByText('first event'))
|
||||
expect(() => screen.getByText('second event')).toThrow()
|
||||
await expect(screen.findByText('second event')).rejects.toThrow()
|
||||
|
||||
get.mockResolvedValueOnce({
|
||||
data: [logDataFixture({ event_message: 'second event' })],
|
||||
result: [logDataFixture({ event_message: 'second event' })],
|
||||
})
|
||||
// should display first and second log
|
||||
userEvent.click(screen.getByText('Load older'))
|
||||
await waitFor(() => screen.getByText('first event'))
|
||||
await waitFor(() => {
|
||||
expect(get).toHaveBeenCalledWith(expect.stringContaining('timestamp_end=1'))
|
||||
})
|
||||
await waitFor(() => screen.getByText('second event'))
|
||||
userEvent.click(await screen.findByText('Load older'))
|
||||
await screen.findByText('first event')
|
||||
await screen.findByText('second event')
|
||||
expect(get).toHaveBeenCalledWith(expect.stringContaining('timestamp_end='))
|
||||
})
|
||||
|
||||
test('bug: load older btn does not error out when previous page is empty', async () => {
|
||||
@@ -357,11 +301,11 @@ test('bug: load older btn does not error out when previous page is empty', async
|
||||
if (url.includes('count')) {
|
||||
return {}
|
||||
}
|
||||
return { data: [] }
|
||||
return { result: [] }
|
||||
})
|
||||
render(<LogPage />)
|
||||
|
||||
userEvent.click(screen.getByText('Load older'))
|
||||
userEvent.click(await screen.findByText('Load older'))
|
||||
// NOTE: potential race condition, since we are asserting that something DOES NOT EXIST
|
||||
// wait for 500s to make sure all ui logic is complete
|
||||
// need to wrap in act because internal react state is changing during this time.
|
||||
@@ -375,6 +319,31 @@ test('bug: load older btn does not error out when previous page is empty', async
|
||||
})
|
||||
})
|
||||
|
||||
test('bug: log selection gets hidden when custom query is run', async () => {
|
||||
const result = [
|
||||
logDataFixture({
|
||||
event_message: 'some event happened',
|
||||
metadata: {
|
||||
my_key: 'something_value',
|
||||
},
|
||||
}),
|
||||
]
|
||||
get.mockResolvedValue({ result })
|
||||
const {container} =render(<LogPage />)
|
||||
userEvent.click(await screen.findByText(/happened/))
|
||||
await screen.findByDisplayValue(/something_value/)
|
||||
get.mockResolvedValue({ data: [] })
|
||||
|
||||
const toggle = getToggleByText(/via query/)
|
||||
expect(toggle).toBeTruthy()
|
||||
userEvent.click(toggle)
|
||||
const editor = container.querySelector('.monaco-editor')
|
||||
userEvent.type(editor, 'select \ncount(*) as my_count \nfrom edge_logs')
|
||||
userEvent.click(await screen.findByText('Run'))
|
||||
|
||||
await expect(screen.findByDisplayValue(/something_value/)).rejects.toThrow()
|
||||
})
|
||||
|
||||
test('log event chart hide', async () => {
|
||||
render(<LogPage />)
|
||||
await screen.findByText('Events')
|
||||
@@ -382,3 +351,43 @@ test('log event chart hide', async () => {
|
||||
userEvent.click(toggle)
|
||||
await expect(screen.findByText('Events')).rejects.toThrow()
|
||||
})
|
||||
|
||||
test('bug: nav backwards with params change results in ui changing', async () => {
|
||||
// bugfix for https://sentry.io/organizations/supabase/issues/2903331460/?project=5459134&referrer=slack
|
||||
get.mockImplementation((url) => {
|
||||
if (url.includes('count')) {
|
||||
return {}
|
||||
}
|
||||
return { data: [] }
|
||||
})
|
||||
const { container, rerender } = render(<LogPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
let editor = container.querySelector('.monaco-editor')
|
||||
expect(editor).toBeFalsy()
|
||||
})
|
||||
await expect(screen.findByDisplayValue('simple-query')).rejects.toThrow()
|
||||
|
||||
// change the router values for query editor
|
||||
const router = defaultRouterMock()
|
||||
router.query = { ...router.query, q: 'advanced-query' }
|
||||
useRouter.mockReturnValue(router)
|
||||
rerender(<LogPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
let editor = container.querySelector('.monaco-editor')
|
||||
expect(editor).toBeTruthy()
|
||||
})
|
||||
await expect(screen.findByDisplayValue('simple-query')).rejects.toThrow()
|
||||
|
||||
// change the router values
|
||||
router.query = { ...router.query, q: undefined, s: 'simple-query' }
|
||||
useRouter.mockReturnValue(router)
|
||||
rerender(<LogPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
let editor = container.querySelector('.monaco-editor')
|
||||
expect(editor).toBeFalsy()
|
||||
})
|
||||
await screen.findByDisplayValue('simple-query')
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
id: sla
|
||||
title: Service Level Agreement
|
||||
---
|
||||
|
||||
The following Service Level Agreement, which is incorporated into and forms part of the Subscription Agreement between Supabase, Inc. ("Supabase") and Customer (the "Agreement"), will apply to the Services for Enterprise Customers specified in an Order Form during the applicable Subscription Term:
|
||||
|
||||
## Platform
|
||||
### 1. Uptime Commitment
|
||||
|
||||
Supabase will provide Actual Availability for at least ninety-nine and nine tenths percent (99.9%) of the total time in each calendar month during the Subscription Term, as measured by Supabase (the **"Uptime Commitment"**).
|
||||
|
||||
|
||||
### 2. Service Credits
|
||||
|
||||
If the Uptime Commitment is not met during any particular calendar month during the Subscription Term, then Customer will be eligible for a service credit ("Service Credit"), provided that Customer reports to Supabase such failure to meet the Uptime Commitment and requests such Service Credit in accordance with this Exhibit. The amount of any Service Credit due hereunder shall be calculated as follows:
|
||||
X * Y, where X = the total fees due from Customer to Supabase for the affected Services for the relevant calendar month (regardless of when billed or payable), and Y = the Credit Percentage corresponding with the Actual Availability provided (as a percentage of total time) for the relevant calendar month, as set forth in the table below.
|
||||
|
||||
| Actual Availability | Credit Percentage |
|
||||
|----------------------------------------------------|-------------------|
|
||||
| Less than 99.9% but greater than or equal to 99.0% | 10% |
|
||||
| Less than 99.0% but greater than or equal to 98.0% | 15% |
|
||||
| Less than 98.0% but greater than or equal to 96.0% | 20% |
|
||||
| Less than 96.0% | 30% |
|
||||
|
||||
### 3. Credit Requests and Payment
|
||||
|
||||
To request a Service Credit, Customer must send an email to Supabase at support@supabase.io within thirty (30) days of the end of the month in which the Uptime Commitment was not met. Customer must include either its account ID or registered email address, and the previously reported dates and times that there was no Service Availability. If Supabase confirms that Customer is eligible for a Service Credit, Supabase will issue a credit to Customer’s account within thirty (30) days. Service Credits are not refunds, cannot be exchanged into a cash amount, and may only be used against future billing charges. Except as set forth in Section 4 below, the Service Credits shall be Customer’s sole and exclusive remedy, and Supabase’s sole and exclusive liability, for any failure by Supabase to meet the Uptime Commitment.
|
||||
|
||||
### 4. Definitions
|
||||
|
||||
All capitalized words used but not defined in this Service Level Agreement have the meaning set forth in the Agreement.
|
||||
|
||||
#### 4.1 Scheduled Availability
|
||||
|
||||
"Scheduled Availability" means the time, in minutes, that the applicable Services are generally accessible and available to Customer’s Permitted Users.
|
||||
|
||||
#### 4.2 Unscheduled Downtime
|
||||
|
||||
"Unscheduled Downtime" means the time, in minutes, that the applicable Services are not generally accessible and available to Customer’s Permitted Users, excluding inaccessibility or unavailability due to Customer’s or Permitted Users’ acts or omissions, force majeure events, scheduled maintenance disclosed with at least 24 hours’ notice by email, hacking or virus attacks, or reasonable emergency maintenance.
|
||||
|
||||
#### 4.3 Actual Availability
|
||||
|
||||
"Actual Availability" means Scheduled Availability less Unscheduled Downtime.
|
||||
|
||||
## Support
|
||||
|
||||
Supabase Support Service Level Agreements.
|
||||
|
||||
### 1. Urgent
|
||||
|
||||
**Critical Issue**
|
||||
|
||||
Defect resulting in full or partial system outage or a condition that makes Supabase unusable
|
||||
or unavailable in production for all of Customer’s Users.
|
||||
|
||||
### 2. High
|
||||
|
||||
**Significant Business Disruption**
|
||||
|
||||
Issue resulting in a situation meaning major functionality is impacted and
|
||||
significant performance degradation is experienced. Issue impacts significant proportion of user base and / or major
|
||||
Supabase functionality.
|
||||
|
||||
### 3. Normal)
|
||||
|
||||
**Minor Feature or Functional Issue / General Question**
|
||||
|
||||
Issue results in a component of Supabase not
|
||||
performing as expected or documented. An inquiry by a Customer representative regarding a general technical issue
|
||||
or general question.
|
||||
|
||||
### 4. Low
|
||||
|
||||
**Minor Issue / Feature Request**
|
||||
|
||||
An Information request about Supabase or feature request.
|
||||
|
||||
## Target response times
|
||||
|
||||
| Severity Level | Standard | Priority | Priority Plus |
|
||||
|----------------|--------------------------------------------|----------------------------------|-----------------------------------|
|
||||
| 1. Urgent | 1 business hour<br />24/7 × 365 | 1 business hour<br />24/7 × 365 | 1 business hour<br />24/7 × 365 |
|
||||
| 2. High | 4 business hours<br />Monday - Friday | 2 business hours<br />Monday - Friday | 2 business hours<br />24/7 × 365 |
|
||||
| 3. Normal | 1 business day<br />Monday - Friday | 1 business day<br />Monday - Friday | 12 business hours<br />Monday - Friday |
|
||||
| 4. Low | 2 business days<br />Monday - Friday | 2 business days<br />Monday - Friday | 1 business day<br />Monday - Friday |
|
||||
|
||||
|
||||
Business hours are from 6am to 6pm (local time), except where otherwise stated.
|
||||
@@ -77,6 +77,10 @@ Build a basic Todo List with Supabase and your favorite frontend framework:
|
||||
|
||||
## Community
|
||||
|
||||
### Courses
|
||||
|
||||
- Build a FullStack App with Next.js, Supabase & Prisma by [@gdangel0](https://twitter.com/gdangel0): [Free course](https://themodern.dev/courses/build-a-fullstack-app-with-nextjs-supabase-and-prisma-322389284337222224)
|
||||
|
||||
### Libraries
|
||||
|
||||
- D (in development): [GitHub](https://github.com/csharpdf/dupabase)
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
---
|
||||
id: plasmic
|
||||
title: 'Plasmic'
|
||||
description: 'Get started with Supabase and Plasmic, an open-source framework for building internal tools.'
|
||||
---
|
||||
|
||||
In this guide, we will show you how to build a crowd-sourced Pokemon Pokedex, by connecting **Supabase**, an open source Firebase backend alternative, with **Plasmic**, a visual builder for the web. While many users leverage Plasmic to quickly launch and iterate on landing pages, in this tutorial we’ll show just how powerful Plasmic can be as a general-purpose visual builder for React, which can be used to design and implement fully featured read-write applications.
|
||||
|
||||
You can play with the live demo here:
|
||||
[https://plasmic-supabase-demo-git-master-plasmic.vercel.app/](https://plasmic-supabase-demo-git-master-plasmic.vercel.app/login-page)
|
||||
|
||||
You can also take a look at the Plasmic project here:
|
||||
[https://studio.plasmic.app/projects/i6YZmFxPJB69NEUgdf79ap](https://studio.plasmic.app/projects/i6YZmFxPJB69NEUgdf79ap)
|
||||
|
||||
> You’ll need to enable 3rd-party cookies in your browser for the project to properly load.
|
||||
|
||||

|
||||
|
||||
At a high level,
|
||||
|
||||
- **Supabase** is used to store the database of Pokemon (backed by Postgres) and provides an authentication backend. Our code base includes React components for querying the database, displaying this data, and supporting user sessions.
|
||||
- **Plasmic** is used to create the pages and visual design of the application. We import our Supabase components into the Studio, which can be visually assembled and configured there (e.g. for displaying data).
|
||||
- Plasmic designed pages are rendered back into the Next.js application.
|
||||
|
||||
## Step 1: Set up your Backend on Supabase
|
||||
|
||||
- On the [Supabase dashboard](https://app.supabase.io/), click `New project` and set the name of the project.
|
||||
By default, Supabase will already be set up for user signups with email, with users being stored in a `users` table.
|
||||
|
||||

|
||||
|
||||
- Navigate to the `Table Editor` on the left side navigation bar. Here we can create a `New table` to store our Pokemon entries. Make sure you are in the `schema public` view. Create a new table called `entries`, with 6 columns:
|
||||
- `id`: is a unique ID for the entry. This column should be generated automatically as the primary column.
|
||||
- `user_id`: Create a relation to the `user` table by clicking on the link icon next to the column name. Here, you can select the `id` column of the `user` table.
|
||||
- `name`, `description`, `imageUrl`: This will store the name, description, and imageUrl for each Pokemon.
|
||||
- `inserted_at` : This will be an automatically populated column, set to when the row was first inserted.
|
||||
|
||||
> Note: In this tutorial we’ve turned off “[Row Level Security (RLS)](https://supabase.com/docs/guides/auth/row-level-security)”. In practice, you will want to create policies that restrict who gets to create, edit, and delete posts. By turning this off, any user can modify the database without restrictions.
|
||||
>
|
||||
|
||||

|
||||
|
||||
For your convenience, feel free to import the following CSV into Supabase to pre-populate your database. In order to import, you must select `Import data via spreadsheet`, in the new table dialog box. (It does not work on existing tables.)
|
||||
|
||||
[pokedex-export.csv](/img/guides/integrations/plasmic/pokedex-export.csv)
|
||||
|
||||
## Step 2: Set up your codebase
|
||||
|
||||
We have a working code example for you [here](https://github.com/plasmicapp/plasmic/tree/master/examples/supabase-demo). This starter comes with all of the code components you need to get started querying Supabase through Plasmic Studio.
|
||||
|
||||
> Code components are React components defined in your code base that we import into Plasmic Studio for use. Your project will be configured to look for these at `http://localhost:3000/plasmic-host` You can use these components in your design, as well as style them. Check out `supabase-demo/plasmic-init.ts` to see how they are registered with Plasmic.
|
||||
>
|
||||
|
||||
First, clone the repo to your development machine and install the dependencies.
|
||||
|
||||
```bash
|
||||
git clone git@github.com:plasmicapp/plasmic.git
|
||||
cd plasmic/examples/supabase-demo/
|
||||
yarn install
|
||||
```
|
||||
|
||||
Copy `.env.example` to `.env.local`, which will store the environment variables when running a local development server. Add your Supabase project’s URL and public key, which you can find in the `API` tab on the left pane of your Supabase dashboard.
|
||||
|
||||
Now run the dev server, which listens at `http://localhost:3000`
|
||||
|
||||
```bash
|
||||
yarn dev
|
||||
```
|
||||
|
||||
## Step 3: Explore the existing application
|
||||
|
||||
Navigate to [http://localhost:3000](http://localhost:3000) in your web browser. The project will already be set up for user signups, logins, and an admin interface for adding and editing Pokemon to the database. Feel free to sign up with your email address for an account and add Pokemon to the database. Supabase will require that you verify your email address before you can log in.
|
||||
|
||||
If you pre-populated the database in Step 1. you should see the following homepage after logging in. Otherwise, feel free to add Pokemon manually via the UI.
|
||||
|
||||

|
||||
|
||||
## Step 4: Clone the Plasmic project
|
||||
|
||||
Now let’s try to make some additions! The code base is currently configured to a read-only copy of the Plasmic project. Let’s make an editable copy first.
|
||||
|
||||
Open the default starter Plasmic project here:
|
||||
[https://studio.plasmic.app/projects/i6YZmFxPJB69NEUgdf79ap](https://studio.plasmic.app/projects/i6YZmFxPJB69NEUgdf79ap)
|
||||
|
||||

|
||||
|
||||
To make an editable copy, click on the `Copy Project` button in the blue bar. This will clone the project and redirect you to your copy.
|
||||
|
||||
### Step 4a: Configure your code base to use the new Plasmic project
|
||||
|
||||
Take note of the `project ID` and `API token`. You can find the project ID in the URL:
|
||||
`https://studio.plasmic.app/projects/PROJECTID`.
|
||||
|
||||
The API token can be found by clicking the `Code` button in the top bar.
|
||||
|
||||

|
||||
|
||||
Now go back to `.env.local` and update the corresponding project ID and token fields.
|
||||
|
||||
### Step 4b: Configure your Plasmic project app host
|
||||
|
||||
To tell Plasmic to look for your code components on your dev server, you’ll need to update your project’s app host to `http://localhost:3000/plasmic-host`.
|
||||
|
||||
> Note: At this point, you’ll need to keep your dev server running at `http://localhost:3000`for the project to load.
|
||||
|
||||
<video width="99%" autoPlay="autoplay" muted playsInline controls={true}>
|
||||
<source src="/docs/videos/guides/integrations/plasmic/configure-app-host-plasmic-05.mp4" type="video/mp4" muted playsInline />
|
||||
</video>
|
||||
|
||||
After restarting the dev server and Plasmic Studio, you should now be able to make edits across Plasmic Studio and your codebase.
|
||||
|
||||
## Step 5: Create a new page for our Pokedex gallery
|
||||
|
||||
Let’s make a visual gallery for our Pokemon by using the code components from the code base.
|
||||
|
||||
Create a new page called `Gallery`, and set a path for this page (`/gallery`).
|
||||
|
||||
<video width="99%" autoPlay="autoplay" muted playsInline controls={true}>
|
||||
<source src="/docs/videos/guides/integrations/plasmic/create-gallery-page-plasmic-06.mp4" type="video/mp4" muted playsInline />
|
||||
</video>
|
||||
|
||||
Insert a `SupabaseGrid` by searching the AddDrawer (by clicking the blue + button)
|
||||
|
||||
> For source see `components/CodeComponents/DatabaseComponents.tsx`
|
||||
>
|
||||
|
||||

|
||||
|
||||
Then in the right-hand panel, configure the props on `SupabaseGrid`.
|
||||
|
||||
- `tableName` should match the table you created in Supabase
|
||||
- `tableColumns` are a comma-delimited list of columns you want to select from the table
|
||||
- We also set the number of columns and spacing shown in the grid
|
||||
|
||||

|
||||
|
||||
The `SupabaseGrid` will loop over the rows from the query.
|
||||
|
||||
Now customize the repeated content by inserting instances of `SupabaseField`. Select the type of content and a selector string to fetch a single value. In the example below, we use `{{row.imageUrl}}` to retrieve the `imageUrl` column of the row. Apply any styling and layout you want on these elements.
|
||||
|
||||

|
||||
|
||||
### Putting it all together (video)
|
||||
|
||||
For your convenience, the following video shows you how to create the page end-to-end.
|
||||
|
||||
<video width="99%" autoPlay="autoplay" muted playsInline controls={true}>
|
||||
<source src="/docs/videos/guides/integrations/plasmic/end-to-end-plasmic-10.mp4" type="video/mp4" muted playsInline />
|
||||
</video>
|
||||
|
||||
## Step 6: Check your dev server
|
||||
|
||||
If you have been running your development server this whole time, you’ll see that we have been automatically fetching and rebuilding your site as you make changes in Plasmic Studio. If you need to restart your dev server, just run:
|
||||
|
||||
```bash
|
||||
yarn dev
|
||||
```
|
||||
|
||||
See the results at `http://localhost:3000/gallery`.
|
||||
|
||||
## How does this all work under the hood?
|
||||
|
||||
### SupabaseGrid
|
||||
|
||||
`SupabaseGrid` is a code component that was registered in `plasmic-init.ts`. The `props` field is used to tell the Plasmic Studio the component prop interface, which allows us to expose these props in the right pane as shown in the screenshots earlier. See the docs for details on [component registration](https://docs.plasmic.app/learn/registering-code-components/).
|
||||
|
||||
```tsx
|
||||
// plasmic-init.ts
|
||||
...
|
||||
|
||||
PLASMIC.registerComponent(SupabaseGrid, {
|
||||
name: "SupabaseGrid",
|
||||
props: {
|
||||
tableName: "string",
|
||||
tableColumns: "string",
|
||||
queryFilters: "object",
|
||||
children: {
|
||||
type: "slot",
|
||||
defaultValue: {
|
||||
type: "text",
|
||||
value: "Placeholder",
|
||||
},
|
||||
},
|
||||
numColumns: {
|
||||
type: "number",
|
||||
defaultValue: 4,
|
||||
},
|
||||
columnGap: {
|
||||
type: "number",
|
||||
defaultValue: 16,
|
||||
},
|
||||
rowGap: {
|
||||
type: "number",
|
||||
defaultValue: 16,
|
||||
},
|
||||
count: "number",
|
||||
loading: {
|
||||
type: "slot",
|
||||
defaultValue: {
|
||||
type: "text",
|
||||
value: "Loading...",
|
||||
},
|
||||
},
|
||||
},
|
||||
importPath: "./components/CodeComponents/DisplayCollections",
|
||||
});
|
||||
```
|
||||
|
||||
### SupabaseQuery
|
||||
|
||||
`SupabaseGrid` wraps a `SupabaseQuery` component, where we perform the query based on the provided props and store the result in a `SupabaseQueryContext`. This will be used in downstream components to display the data.
|
||||
|
||||
```tsx
|
||||
// supabase-demo/components/CodeComponents/DatabaseComponents.tsx
|
||||
export function SupabaseQuery(props: SupabaseQueryProps) {
|
||||
// These props are set in the Plasmic Studio
|
||||
const { children, tableName, columns, className, filters, single } = props;
|
||||
const [result, setResult] = React.useState<any[] | undefined>(undefined);
|
||||
|
||||
...
|
||||
|
||||
// Performs the Supabase query
|
||||
let query = supabase.from(tableName!).select(columns + ",id");
|
||||
query = applyFilter(query, validFilters, contexts);
|
||||
const { data, error, status } = await (single ? query.single() : query.order('id', { ascending: false }));
|
||||
|
||||
if (error && status !== 406) {
|
||||
throw error;
|
||||
} else if (data) {
|
||||
setResult(data);
|
||||
}
|
||||
|
||||
...
|
||||
|
||||
// Save the result in a `SupabaseQueryContext for use with downstream components
|
||||
return (
|
||||
<div className={className}>
|
||||
<SupabaseQueryContext.Provider value={result}>
|
||||
{children}
|
||||
</SupabaseQueryContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Note that this code component is defined in your codebase. Feel free to augment it to expose more powerful querying capabilities to the Plasmic Studio.
|
||||
|
||||
### SupabaseGridCollection
|
||||
|
||||
`SupabaseGrid` also nests a `SupabaseGridCollection` under the `SupabaseQuery`. This code component is a simple CSS grid, where we retrieve the Supabase query results from `SupabaseQueryContext`, and iterate over the results. For each row, we populate a `RowContext`, which will be used by the children to read the results of a single row. Note the use of `repeatedElement`, a special convenience function that enables the component’s children to be repeated. In this case, this represents a single card to be shown in the gallery.
|
||||
|
||||
```tsx
|
||||
// supabase-demo/components/CodeComponents/DisplayCollections.tsx
|
||||
|
||||
export function SupabaseGridCollection(props: SupabaseGridCollectionProps) {
|
||||
const supabaseQuery = React.useContext(SupabaseQueryContext);
|
||||
const { children, columns, columnGap, rowGap, count, className, loading, testLoading } = props;
|
||||
|
||||
const result = supabaseQuery;
|
||||
if (!result || testLoading) {
|
||||
return loading;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||
columnGap: `${columnGap}px`,
|
||||
rowGap: `${rowGap}px`,
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
{result.slice(0, count).map((row: any, i: any) =>
|
||||
<RowContext.Provider value={row} key={row.id}>
|
||||
<div key={row.id}>{repeatedElement(i === 0, children)}</div>
|
||||
</RowContext.Provider>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### SupabaseField
|
||||
|
||||
`SupabaseField` will either render a `SupabaseTextField` or `SupabaseImgField` depending on the type. These code components simply read a single value from the contexts and display the data.
|
||||
|
||||
```tsx
|
||||
// supabase-demo/components/CodeComponents/DisplayCollections.tsx
|
||||
|
||||
export function SupabaseTextField({ name, className }: { name?: string; className?: string }) {
|
||||
const contexts = useAllContexts();
|
||||
if (!name) {
|
||||
return <p>You need to set the name prop</p>
|
||||
}
|
||||
return <div className={className}>{getPropValue(name, contexts)}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
In summary, by populating state into React contexts, we can store and retrieve data for use in other code components, which can be used for arbitrarily powerful interactions in Plasmic Studio.
|
||||
@@ -7,7 +7,7 @@ description: How to use Supabase on your local development machine.
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
Supabase provides a CLI so that you can develop your application locally, rather than connecting to a live project.
|
||||
Supabase provides a CLI so that you can develop your application locally, with the ability to deploy your application to the Supabase platform.
|
||||
|
||||
## Getting started
|
||||
|
||||
@@ -42,27 +42,35 @@ supabase init
|
||||
|
||||
This command will create a `supabase` folder which holds all the configuration for developing your project locally.
|
||||
|
||||
### Remote project
|
||||
|
||||
We can link any local project to a remote database. Use the connection string from your Supabase project here.
|
||||
|
||||
```bash
|
||||
supabase db remote set 'postgresql://postgres:<your_password>@db.<your_project_ref>.supabase.co:5432/postgres'
|
||||
```
|
||||
|
||||
After that, run:
|
||||
|
||||
```bash
|
||||
supabase db remote commit
|
||||
```
|
||||
|
||||
This command captures any changes that you have made to your database before setting up the CLI.
|
||||
|
||||
You'll notice that `supabase/migrations` is now populated with a migration in `..._remote_commit.sql`.
|
||||
This migration captures all the changes required on your local database to match the schema of your remote Supabase project.
|
||||
|
||||
### Start Supabase
|
||||
|
||||
```bash
|
||||
supabase start
|
||||
```
|
||||
|
||||
This command uses Docker to start all the open source [services](/docs/#how-it-works) of Supabase. This command will take a while to run, there are a lot of services to build.
|
||||
The `supabase start` command uses Docker to start the open source [services](/docs/#how-it-works) of Supabase.
|
||||
This command may take a while to run if this is the first time using the CLI.
|
||||
|
||||
Once this is running, you will see an output that contains your local Supabase credentials:
|
||||
|
||||
```txt
|
||||
Started local development setup.
|
||||
|
||||
API URL: http://localhost:54321
|
||||
DB URL: postgresql://postgres:postgres@localhost:54322/postgres
|
||||
Studio URL: http://localhost:54323
|
||||
anon key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiJ9.ZopqoUt20nEV9cklpv9e3yw3PVyZLmKs5qLD6nGL1SI
|
||||
service_role key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIn0.M2d2z4SFn5C7HlJlaSLfrzuYim9nbY_XI40uWFN3hEE
|
||||
```
|
||||
|
||||
Now you can open a browser and visit the Studio at [http://localhost:54323](http://localhost:54323).
|
||||
Once all of the Supabase services are running, you'll see an output that contains your local Supabase credentials.
|
||||
|
||||
### Accessing Services Directly
|
||||
|
||||
@@ -122,65 +130,81 @@ supabase stop
|
||||
|
||||
Stop Supabase services and let's build a real application next.
|
||||
|
||||
|
||||
## Example application
|
||||
|
||||
Now that we've learned how to install and start Supabase locally, let's see how you can use it with a frontend application.
|
||||
This quick-start guide will show you, how you can configure a React app which uses the Supabase stack.
|
||||
|
||||
```bash
|
||||
# create a fresh React app
|
||||
npx create-react-app react-demo --use-npm
|
||||
|
||||
# move into the new folder
|
||||
cd react-demo
|
||||
|
||||
# set up Supabase
|
||||
supabase init
|
||||
|
||||
# Save the install supabase-js library
|
||||
npm install --save @supabase/supabase-js
|
||||
```
|
||||
|
||||
Now that your application is prepared, start the backend:
|
||||
|
||||
```bash
|
||||
supabase start # Start Supabase
|
||||
```
|
||||
|
||||
You can use Supabase anywhere in your application. Copy this snippet into `src/App.js`:
|
||||
|
||||
```js
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const SUPABASE_URL = '<your API URL>'
|
||||
const SUPABASE_ANON_KEY = '<your anon key>'
|
||||
|
||||
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
|
||||
```
|
||||
|
||||
Then start the frontend in a separate terminal:
|
||||
|
||||
```bash
|
||||
npm start # Start the React app
|
||||
```
|
||||
|
||||
If everything is working, you should have a React app running on `http://localhost:3000` and Supabase services running on `http://localhost:54321`.
|
||||
|
||||
When you're finished with Supabase, send <kbd>Ctrl-C</kbd> to stop the application and run
|
||||
|
||||
```bash
|
||||
supabase stop # Stop Supabase
|
||||
```
|
||||
|
||||
to stop Supabase services.
|
||||
|
||||
## Migrations
|
||||
|
||||
You can also use the CLI to manage your migrations. Follow the tour [here](https://github.com/supabase/cli/tree/main/examples/tour) to get started.
|
||||
You can also use the CLI to manage your migrations.
|
||||
|
||||
|
||||
## 2. Making database changes
|
||||
|
||||
To modify your local database, simply run some SQL against the `DB URL` shown by `supabase start`.
|
||||
|
||||
For this example, we'll create a table called `employees`, using the "Supabase Studio" link provided.
|
||||
Open the Studio, navigate to the "SQL Editor" section, and then run the following SQL command:
|
||||
|
||||
```sql
|
||||
create table employees (
|
||||
id serial primary key,
|
||||
name text
|
||||
);
|
||||
```
|
||||
|
||||
Now we have the `employees` table in the local database, but how do we incorporate this into migrations? The CLI can automatically detect changes by running:
|
||||
|
||||
```sh
|
||||
supabase db commit create_employees
|
||||
```
|
||||
|
||||
This will create a new migration named `<timestamp>_create_employees.sql` that represents any changes we've made to the local database since `supabase start`.
|
||||
|
||||
Let's add some sample data into the table. To do this we can use the seed script in `supabase/seed.sql`.
|
||||
|
||||
```sql
|
||||
-- in supabase/seed.sql
|
||||
insert into employees (id, name)
|
||||
values
|
||||
(1, 'Erlich Backman'),
|
||||
(2, 'Richard Hendricks'),
|
||||
(3, 'Monica Hall');
|
||||
```
|
||||
|
||||
Now run the following to rerun the migration scripts and the seed script:
|
||||
|
||||
```bash
|
||||
supabase db reset
|
||||
```
|
||||
|
||||
If you look again within Studio, you should now see the contents of `employees`.
|
||||
|
||||
## 3. Resetting database changes
|
||||
|
||||
If you run any SQL on the local database that you want to revert, you can use the `reset` command.
|
||||
|
||||
```sql
|
||||
-- run on local database
|
||||
alter table employees
|
||||
add department text default 'Hooli';
|
||||
```
|
||||
|
||||
To revert this change we can run:
|
||||
|
||||
```sh
|
||||
supabase db reset
|
||||
```
|
||||
|
||||
And the local database will be reset.
|
||||
|
||||
## 4. Deploying migrations
|
||||
|
||||
Finally, we need to deploy these local changes to the remote database (i.e. your remote Supabase project).
|
||||
We can do this by running:
|
||||
|
||||
```sh
|
||||
supabase db push
|
||||
```
|
||||
|
||||
## Next steps
|
||||
|
||||
- Got a question? [Ask here](https://github.com/supabase/supabase/discussions).
|
||||
- Got a question? [Ask in our Discussions](https://github.com/supabase/supabase/discussions).
|
||||
- CLI repository: [GitHub](https://github.com/supabase/cli).
|
||||
- Sign in: [app.supabase.io](https://app.supabase.io)
|
||||
@@ -28,23 +28,9 @@ To access advanced querying features, you will need to enable **custom queries**
|
||||
|
||||

|
||||
|
||||
Advanced queries can take two forms, **WHERE queries** and **SELECT queries**.
|
||||
|
||||
### WHERE Queries
|
||||
|
||||
To perform advanced filtering, you add in additional `WHERE` filters into the query editor. These will be inserted and combined with the existing query. Doing so will allow you to access chronological features such as **loading older logs** and **metadata viewing**.
|
||||
|
||||
For example, you may query the API event messages as well as the timestamp field simultaneously:
|
||||
|
||||
```sql
|
||||
REGEXP_CONTAINS(event_message, "/auth") AND REGEXP_CONTAINS(event_message, "400") AND timestamp < TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 10 MINUTE)
|
||||
```
|
||||
|
||||

|
||||
|
||||
### SELECT Queries
|
||||
|
||||
`SELECT` queries allow for much more complex and powerful aggregated result sets, that would allow more in-depth analysis of log data.
|
||||
`SELECT` queries allow complex and powerful aggregated result sets, letting you to perform in-depth analysis of your log data.
|
||||
|
||||
For example, you may enter the following into the API logs dashboard to query for each user's IP address:
|
||||
|
||||
@@ -64,7 +50,7 @@ You may notice that the table name reference, `edge_logs`, is required in order
|
||||
|
||||
The table references are as follows:
|
||||
|
||||
- API - `edge_logs`
|
||||
- API Edge - `edge_logs`
|
||||
- Database - `postgres_logs`
|
||||
|
||||
Note that cross-table joining is currently not supported.
|
||||
|
||||
+135
-119
@@ -1,13 +1,13 @@
|
||||
---
|
||||
id: with-expo
|
||||
title: "Quickstart: Expo"
|
||||
title: 'Quickstart: Expo'
|
||||
description: Learn how to use Supabase in your React Native App.
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
import Tabs from '@theme/Tabs'
|
||||
import TabItem from '@theme/TabItem'
|
||||
|
||||
## Intro
|
||||
## Intro
|
||||
|
||||
This example provides the steps to build a simple user management app (from scratch!) using Supabase and React Native. It includes:
|
||||
|
||||
@@ -21,20 +21,19 @@ By the end of this guide you'll have an app which allows users to login and upda
|
||||
|
||||
## Project set up
|
||||
|
||||
Before we start building we're going to set up our Database and API. This is as simple as starting a new Project in Supabase
|
||||
Before we start building we're going to set up our Database and API. This is as simple as starting a new Project in Supabase
|
||||
and then creating a "schema" inside the database.
|
||||
|
||||
### Create a project
|
||||
### Create a project
|
||||
|
||||
1. Go to [app.supabase.io](https://app.supabase.io).
|
||||
1. Click on "New Project".
|
||||
1. Enter your project details.
|
||||
1. Wait for the new database to launch.
|
||||
|
||||
|
||||
### Set up the database schema
|
||||
|
||||
Now we are going to set up the database schema. We can use the "User Management Starter" quickstart in the SQL Editor,
|
||||
Now we are going to set up the database schema. We can use the "User Management Starter" quickstart in the SQL Editor,
|
||||
or you can just copy/paste the SQL from below and run it yourself.
|
||||
|
||||
<Tabs
|
||||
@@ -52,7 +51,7 @@ values={[
|
||||
```
|
||||
|
||||
<video width="99%" muted playsInline controls="true">
|
||||
<source src="/docs/videos/sql-user-management-starter.mp4" type="video/mp4" muted playsInline />
|
||||
<source src="/docs/videos/sql-user-management-starter.mp4" type="video/mp4" muted playsInline />
|
||||
</video>
|
||||
|
||||
</TabItem>
|
||||
@@ -110,11 +109,9 @@ create policy "Anyone can upload an avatar."
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Get the API Keys
|
||||
|
||||
### Get the API Keys
|
||||
|
||||
|
||||
Now that you've created some database tables, you are ready to insert data using the auto-generated API.
|
||||
Now that you've created some database tables, you are ready to insert data using the auto-generated API.
|
||||
We just need to get the URL and `anon` key from the API settings.
|
||||
|
||||
<Tabs
|
||||
@@ -132,7 +129,7 @@ values={[
|
||||
```
|
||||
|
||||
<video width="99%" muted playsInline controls="true">
|
||||
<source src="/docs/videos/api/api-url-and-key.mp4" type="video/mp4" muted playsInline />
|
||||
<source src="/docs/videos/api/api-url-and-key.mp4" type="video/mp4" muted playsInline />
|
||||
</video>
|
||||
|
||||
</TabItem>
|
||||
@@ -144,11 +141,11 @@ Let's start building the React Native app from scratch.
|
||||
|
||||
### Initialize a React Native app
|
||||
|
||||
We can use [`expo`](https://docs.expo.dev/get-started/create-a-new-app/) to initialize
|
||||
We can use [`expo`](https://docs.expo.dev/get-started/create-a-new-app/) to initialize
|
||||
an app called `supabaseReactNative`:
|
||||
|
||||
```bash
|
||||
#select the "Blank" template when prompted
|
||||
#select the "Blank (TypeScript)" template when prompted
|
||||
expo init supabaseReactNative
|
||||
|
||||
cd supabaseReactNative
|
||||
@@ -160,50 +157,65 @@ Then let's install the additional dependencies: [supabase-js](https://github.com
|
||||
yarn add @supabase/supabase-js
|
||||
yarn add react-native-elements
|
||||
yarn add react-native-safe-area-context
|
||||
yarn add @react-native-async-storage/async-storage
|
||||
yarn add react-native-url-polyfill
|
||||
```
|
||||
|
||||
Now let's create a helper file to initialize the Supabase client.
|
||||
We need the API URL and the `anon` key that you copied [earlier](#get-the-api-keys).
|
||||
These variables will be exposed on the browser, and that's completely fine since we have
|
||||
These variables will be exposed on the browser, and that's completely fine since we have
|
||||
[Row Level Security](/docs/guides/auth#row-level-security) enabled on our Database.
|
||||
|
||||
```js title="lib/supabase.js"
|
||||
```js title="lib/supabase.ts"
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabaseUrl = YOUR_REACT_NATIVE_SUPABASE_URL
|
||||
const supabaseAnonKey = YOUR_REACT_NATIVE_SUPABASE_ANON_KEY
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
localStorage: AsyncStorage as any,
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
detectSessionInUrl: false,
|
||||
});
|
||||
```
|
||||
|
||||
### Set up a Login component
|
||||
|
||||
Let's set up a React Native component to manage logins and sign ups.
|
||||
Let's set up a React Native component to manage logins and sign ups.
|
||||
Users would be able to sign in with their email and password.
|
||||
|
||||
```jsx title="components/Auth.js"
|
||||
import React, {useState} from 'react'
|
||||
import {Alert, StyleSheet, View} from 'react-native'
|
||||
import {supabase} from '../lib/supabase'
|
||||
import {Button, Input} from 'react-native-elements'
|
||||
```jsx title="components/Auth.tsx"
|
||||
import React, { useState } from 'react'
|
||||
import { Alert, StyleSheet, View } from 'react-native'
|
||||
import { supabase } from '../lib/supabase'
|
||||
import { Button, Input } from 'react-native-elements'
|
||||
|
||||
export default function Auth() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function signInWithEmail() {
|
||||
setLoading(true)
|
||||
const { user, error } = await supabase.auth.signIn({
|
||||
email: email,
|
||||
password: password,
|
||||
})
|
||||
|
||||
if (error) Alert.alert(error.message)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
async function signUpWithEmail() {
|
||||
setLoading(true)
|
||||
const { user, error } = await supabase.auth.signUp({
|
||||
email: email,
|
||||
password: password,
|
||||
})
|
||||
|
||||
if (error) Alert.alert(error.message)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -211,8 +223,8 @@ export default function Auth() {
|
||||
<View style={[styles.verticallySpaced, styles.mt20]}>
|
||||
<Input
|
||||
label="Email"
|
||||
leftIcon={{type: 'font-awesome', name: 'envelope'}}
|
||||
onChangeText={text => setEmail(text)}
|
||||
leftIcon={{ type: 'font-awesome', name: 'envelope' }}
|
||||
onChangeText={(text) => setEmail(text)}
|
||||
value={email}
|
||||
placeholder="email@address.com"
|
||||
autoCapitalize={'none'}
|
||||
@@ -221,8 +233,8 @@ export default function Auth() {
|
||||
<View style={styles.verticallySpaced}>
|
||||
<Input
|
||||
label="Password"
|
||||
leftIcon={{type: 'font-awesome', name: 'lock'}}
|
||||
onChangeText={text => setPassword(text)}
|
||||
leftIcon={{ type: 'font-awesome', name: 'lock' }}
|
||||
onChangeText={(text) => setPassword(text)}
|
||||
value={password}
|
||||
secureTextEntry={true}
|
||||
placeholder="Password"
|
||||
@@ -230,19 +242,13 @@ export default function Auth() {
|
||||
/>
|
||||
</View>
|
||||
<View style={[styles.verticallySpaced, styles.mt20]}>
|
||||
<Button
|
||||
title="Sign in"
|
||||
onPress={() => signInWithEmail()}
|
||||
/>
|
||||
<Button title="Sign in" disabled={loading} onPress={() => signInWithEmail()} />
|
||||
</View>
|
||||
<View style={styles.verticallySpaced}>
|
||||
<Button
|
||||
title="Sign up"
|
||||
onPress={() => signUpWithEmail()}
|
||||
/>
|
||||
<Button title="Sign up" disabled={loading} onPress={() => signUpWithEmail()} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
@@ -258,7 +264,7 @@ const styles = StyleSheet.create({
|
||||
mt20: {
|
||||
marginTop: 20,
|
||||
},
|
||||
});
|
||||
})
|
||||
```
|
||||
|
||||
### Account page
|
||||
@@ -267,54 +273,63 @@ After a user is signed in we can allow them to edit their profile details and ma
|
||||
|
||||
Let's create a new component for that called `Account.js`.
|
||||
|
||||
```jsx title="components/Account.js"
|
||||
import { useState, useEffect } from 'react'
|
||||
import { supabase } from '../lib/supabase'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
```jsx title="components/Account.tsx"
|
||||
import { useState, useEffect } from "react";
|
||||
import { supabase } from "../lib/supabase";
|
||||
import { StyleSheet, View, Alert } from "react-native";
|
||||
import { Button, Input } from "react-native-elements";
|
||||
import { ApiError, Session } from "@supabase/supabase-js";
|
||||
|
||||
import {Button, Input} from 'react-native-elements'
|
||||
|
||||
export default function Account({ session }) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [username, setUsername] = useState(null)
|
||||
const [website, setWebsite] = useState(null)
|
||||
const [avatar_url, setAvatarUrl] = useState(null)
|
||||
export default function Account({ session }: { session: Session }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [username, setUsername] = useState("");
|
||||
const [website, setWebsite] = useState("");
|
||||
const [avatar_url, setAvatarUrl] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
getProfile()
|
||||
}, [session])
|
||||
if (session) getProfile();
|
||||
}, [session]);
|
||||
|
||||
async function getProfile() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const user = supabase.auth.user()
|
||||
setLoading(true);
|
||||
const user = supabase.auth.user();
|
||||
if (!user) throw new Error("No user on the session!");
|
||||
|
||||
let { data, error, status } = await supabase
|
||||
.from('profiles')
|
||||
.from("profiles")
|
||||
.select(`username, website, avatar_url`)
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
.eq("id", user.id)
|
||||
.single();
|
||||
if (error && status !== 406) {
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
setUsername(data.username)
|
||||
setWebsite(data.website)
|
||||
setAvatarUrl(data.avatar_url)
|
||||
setUsername(data.username);
|
||||
setWebsite(data.website);
|
||||
setAvatarUrl(data.avatar_url);
|
||||
}
|
||||
} catch (error) {
|
||||
alert(error.message)
|
||||
Alert.alert((error as ApiError).message);
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProfile({ username, website, avatar_url }) {
|
||||
async function updateProfile({
|
||||
username,
|
||||
website,
|
||||
avatar_url,
|
||||
}: {
|
||||
username: string;
|
||||
website: string;
|
||||
avatar_url: string;
|
||||
}) {
|
||||
try {
|
||||
setLoading(true)
|
||||
const user = supabase.auth.user()
|
||||
setLoading(true);
|
||||
const user = supabase.auth.user();
|
||||
if (!user) throw new Error("No user on the session!");
|
||||
|
||||
const updates = {
|
||||
id: user.id,
|
||||
@@ -322,79 +337,88 @@ export default function Account({ session }) {
|
||||
website,
|
||||
avatar_url,
|
||||
updated_at: new Date(),
|
||||
}
|
||||
};
|
||||
|
||||
let { error } = await supabase.from('profiles').upsert(updates, {
|
||||
returning: 'minimal', // Don't return the value after inserting
|
||||
})
|
||||
let { error } = await supabase
|
||||
.from("profiles")
|
||||
.upsert(updates, { returning: "minimal" });
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
alert(error.message)
|
||||
Alert.alert((error as ApiError).message);
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="form-widget">
|
||||
<View>
|
||||
<Input id="email" type="text" value={session.user.email} disabled />
|
||||
<View>
|
||||
<View style={[styles.verticallySpaced, styles.mt20]}>
|
||||
<Input label="Email" value={session?.user?.email} disabled />
|
||||
</View>
|
||||
<View>
|
||||
<View style={styles.verticallySpaced}>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username || ''}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
label="Username"
|
||||
value={username || ""}
|
||||
onChangeText={(text) => setUsername(text)}
|
||||
/>
|
||||
</View>
|
||||
<View>
|
||||
<label htmlFor="website">Website</label>
|
||||
<View style={styles.verticallySpaced}>
|
||||
<Input
|
||||
id="website"
|
||||
type="website"
|
||||
value={website || ''}
|
||||
onChange={(e) => setWebsite(e.target.value)}
|
||||
label="Website"
|
||||
value={website || ""}
|
||||
onChangeText={(text) => setWebsite(text)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<View style={[styles.verticallySpaced, styles.mt20]}>
|
||||
<Button
|
||||
className="button block primary"
|
||||
onClick={() => updateProfile({ username, website, avatar_url })}
|
||||
title={loading ? "Loading ..." : "Update"}
|
||||
onPress={() => updateProfile({ username, website, avatar_url })}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Loading ...' : 'Update'}
|
||||
</Button>
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Button className="button block" onClick={() => supabase.auth.signOut()}>
|
||||
Sign Out
|
||||
</Button>
|
||||
<View style={styles.verticallySpaced}>
|
||||
<Button title="Sign Out" onPress={() => supabase.auth.signOut()} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginTop: 40,
|
||||
padding: 12,
|
||||
},
|
||||
verticallySpaced: {
|
||||
paddingTop: 4,
|
||||
paddingBottom: 4,
|
||||
alignSelf: "stretch",
|
||||
},
|
||||
mt20: {
|
||||
marginTop: 20,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Launch!
|
||||
|
||||
Now that we have all the components in place, let's update `app.js`:
|
||||
Now that we have all the components in place, let's update `App.tsx`:
|
||||
|
||||
```jsx title="app.js"
|
||||
```jsx title="App.tsx"
|
||||
import 'react-native-url-polyfill/auto'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { supabase } from './lib/supabase'
|
||||
import Auth from './components/Auth'
|
||||
import Account from './components/Account'
|
||||
import { View } from 'react-native'
|
||||
import { Session } from '@supabase/supabase-js'
|
||||
|
||||
export default function App() {
|
||||
const [session, setSession] = useState(null)
|
||||
const [session, setSession] = (useState < Session) | (null > null)
|
||||
|
||||
useEffect(() => {
|
||||
setSession(supabase.auth.session())
|
||||
@@ -405,8 +429,8 @@ export default function App() {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View className="container">
|
||||
{!session ? <Auth /> : <Account key={session.user.id} session={session} />}
|
||||
<View>
|
||||
{session && session.user ? <Account key={session.user.id} session={session} /> : <Auth />}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -420,22 +444,21 @@ yarn start
|
||||
|
||||
And then open the browser to [localhost:3000](http://localhost:3000) and you should see the completed app.
|
||||
|
||||
|
||||
## Bonus: Profile photos
|
||||
|
||||
Every Supabase project is configured with [Storage](/docs/guides/storage) for managing large files like
|
||||
Every Supabase project is configured with [Storage](/docs/guides/storage) for managing large files like
|
||||
photos and videos.
|
||||
|
||||
### Create an upload widget
|
||||
|
||||
Let's create an avatar for the user so that they can upload a profile photo.
|
||||
Let's create an avatar for the user so that they can upload a profile photo.
|
||||
We can start by creating a new component:
|
||||
|
||||
```jsx title="components/Avatar.js"
|
||||
import { useEffect, useState } from 'react'
|
||||
import { supabase } from '../lib/supabase'
|
||||
import {Alert, StyleSheet, View, Image} from 'react-native'
|
||||
import {Button, Input} from 'react-native-elements'
|
||||
import { Alert, StyleSheet, View, Image } from 'react-native'
|
||||
import { Button, Input } from 'react-native-elements'
|
||||
|
||||
export default function Avatar({ url, size, onUpload }) {
|
||||
const [avatarUrl, setAvatarUrl] = useState(null)
|
||||
@@ -458,7 +481,6 @@ export default function Avatar({ url, size, onUpload }) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function uploadAvatar(event) {
|
||||
try {
|
||||
setUploading(true)
|
||||
@@ -472,9 +494,7 @@ export default function Avatar({ url, size, onUpload }) {
|
||||
const fileName = `${Math.random()}.${fileExt}`
|
||||
const filePath = `${fileName}`
|
||||
|
||||
let { error: uploadError } = await supabase.storage
|
||||
.from('avatars')
|
||||
.upload(filePath, file)
|
||||
let { error: uploadError } = await supabase.storage.from('avatars').upload(filePath, file)
|
||||
|
||||
if (uploadError) {
|
||||
throw uploadError
|
||||
@@ -491,11 +511,7 @@ export default function Avatar({ url, size, onUpload }) {
|
||||
return (
|
||||
<View>
|
||||
{avatarUrl ? (
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt="Avatar"
|
||||
className="avatar image"
|
||||
/>
|
||||
<Image src={avatarUrl} alt="Avatar" className="avatar image" />
|
||||
) : (
|
||||
<View className="avatar no-image" />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,705 @@
|
||||
---
|
||||
id: with-ionic-angular
|
||||
title: "Quickstart: Ionic Angular"
|
||||
description: Learn how to use Supabase in your Ionic Angular App.
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
## Intro
|
||||
|
||||
This example provides the steps to build a simple user management app (from scratch!) using Supabase and Ionic Angular. It includes:
|
||||
|
||||
- Supabase [Database](/docs/guides/database): a Postgres database for storing your user data.
|
||||
- Supabase [Auth](/docs/guides/auth): users can sign in with magic links (no passwords, only email).
|
||||
- Supabase [Storage](/docs/guides/storage): users can upload a photo.
|
||||
- [Row Level Security](/docs/guides/auth#row-level-security): data is protected so that individuals can only access their own data.
|
||||
- Instant [APIs](/docs/guides/api): APIs will be automatically generated when you create your database tables.
|
||||
|
||||
By the end of this guide you'll have an app which allows users to login and update some basic profile details:
|
||||
|
||||

|
||||
|
||||
Clicking this button the application will:
|
||||
|
||||
- Launch and prepare the Postgres database in Supabase.
|
||||
- Launch the app in Vercel.
|
||||
- Fork the example into your own GitHub account.
|
||||
- Prepare the deployed application with all the necessary environment variables.
|
||||
|
||||
If you want to do it yourself, let's get started!
|
||||
|
||||
### GitHub
|
||||
|
||||
Whenever you get stuck at any point, take a look at [this repo](https://github.com/mhartington/supabase-ionic-angular).
|
||||
|
||||
## Project set up
|
||||
|
||||
Before we start building we're going to set up our Database and API. This is as simple as starting a new Project in Supabase
|
||||
and then creating a "schema" inside the database.
|
||||
|
||||
### Create a project
|
||||
|
||||
1. Go to [app.supabase.io](https://app.supabase.io).
|
||||
1. Click on "New Project".
|
||||
1. Enter your project details.
|
||||
1. Wait for the new database to launch.
|
||||
|
||||
|
||||
### Set up the database schema
|
||||
|
||||
Now we are going to set up the database schema. We can use the "User Management Starter" quickstart in the SQL Editor,
|
||||
or you can just copy/paste the SQL from below and run it yourself.
|
||||
|
||||
<Tabs
|
||||
defaultValue="UI"
|
||||
values={[
|
||||
{label: 'UI', value: 'UI'},
|
||||
{label: 'SQL', value: 'SQL'},
|
||||
]}>
|
||||
<TabItem value="UI">
|
||||
|
||||
```sh
|
||||
1. Go to the "SQL" section.
|
||||
2. Click "User Management Starter".
|
||||
3. Click "Run".
|
||||
```
|
||||
|
||||
<video width="99%" muted playsInline controls="true">
|
||||
<source src="/docs/videos/sql-user-management-starter.mp4" type="video/mp4" muted playsInline />
|
||||
</video>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="SQL">
|
||||
|
||||
```sql
|
||||
-- Create a table for public "profiles"
|
||||
create table profiles (
|
||||
id uuid references auth.users not null,
|
||||
updated_at timestamp with time zone,
|
||||
username text unique,
|
||||
avatar_url text,
|
||||
website text,
|
||||
|
||||
primary key (id),
|
||||
unique(username),
|
||||
constraint username_length check (char_length(username) >= 3)
|
||||
);
|
||||
|
||||
alter table profiles enable row level security;
|
||||
|
||||
create policy "Public profiles are viewable by everyone."
|
||||
on profiles for select
|
||||
using ( true );
|
||||
|
||||
create policy "Users can insert their own profile."
|
||||
on profiles for insert
|
||||
with check ( auth.uid() = id );
|
||||
|
||||
create policy "Users can update own profile."
|
||||
on profiles for update
|
||||
using ( auth.uid() = id );
|
||||
|
||||
-- Set up Realtime!
|
||||
begin;
|
||||
drop publication if exists supabase_realtime;
|
||||
create publication supabase_realtime;
|
||||
commit;
|
||||
alter publication supabase_realtime add table profiles;
|
||||
|
||||
-- Set up Storage!
|
||||
insert into storage.buckets (id, name)
|
||||
values ('avatars', 'avatars');
|
||||
|
||||
create policy "Avatar images are publicly accessible."
|
||||
on storage.objects for select
|
||||
using ( bucket_id = 'avatars' );
|
||||
|
||||
create policy "Anyone can upload an avatar."
|
||||
on storage.objects for insert
|
||||
with check ( bucket_id = 'avatars' );
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
|
||||
### Get the API Keys
|
||||
|
||||
Now that you've created some database tables, you are ready to insert data using the auto-generated API.
|
||||
We just need to get the URL and `anon` key from the API settings.
|
||||
|
||||
<Tabs
|
||||
defaultValue="UI"
|
||||
values={[
|
||||
{label: 'UI', value: 'UI'}
|
||||
]}>
|
||||
<TabItem value="UI">
|
||||
|
||||
```sh
|
||||
1. Go to the "Settings" section.
|
||||
2. Click "API" in the sidebar.
|
||||
3. Find your API URL in this page.
|
||||
4. Find your "anon" and "service_role" keys on this page.
|
||||
```
|
||||
|
||||
<video width="99%" muted playsInline controls="true">
|
||||
<source src="/docs/videos/api/api-url-and-key.mp4" type="video/mp4" muted playsInline />
|
||||
</video>
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Building the App
|
||||
|
||||
Let's start building the Angular app from scratch.
|
||||
|
||||
### Initialize an Ionic Angular app
|
||||
|
||||
We can use the [Ionic CLI](https://ionicframework.com/docs/cli) to initialize
|
||||
an app called `supabase-ionic-angular`:
|
||||
|
||||
```bash
|
||||
npm install -g @ionic/cli
|
||||
ionic start supabase-ionic-angular blank --type angular
|
||||
cd supabase-ionic-angular
|
||||
```
|
||||
|
||||
Then let's install the only additional dependency: [supabase-js](https://github.com/supabase/supabase-js)
|
||||
|
||||
```bash
|
||||
npm install @supabase/supabase-js
|
||||
```
|
||||
|
||||
And finally we want to save the environment variables in the `environment.ts` file.
|
||||
All we need are the API URL and the `anon` key that you copied [earlier](#get-the-api-keys).
|
||||
These variables will be exposed on the browser, and that's completely fine since we have [Row Level Security](/docs/guides/auth#row-level-security) enabled on our Database.
|
||||
|
||||
|
||||
```ts title="environment.ts"
|
||||
export const environment = {
|
||||
production: false,
|
||||
supabaseUrl: "YOUR_SUPABASE_URL",
|
||||
supabaseKey: "YOUR_SUPABASE_KEY"
|
||||
};
|
||||
```
|
||||
|
||||
Now that we have the API credentials in place, let's create a **SupabaseService** with `ionic g s supabase` to initialize the Supabase client and implement functions to communicate with the Supabase API.
|
||||
|
||||
```ts title="src/app/supabase.service.ts"
|
||||
import { Injectable } from '@angular/core';
|
||||
import { LoadingController, ToastController } from '@ionic/angular';
|
||||
import {
|
||||
AuthChangeEvent,
|
||||
createClient,
|
||||
Session,
|
||||
SupabaseClient,
|
||||
} from '@supabase/supabase-js';
|
||||
import { environment } from '../environments/environment';
|
||||
|
||||
export interface Profile {
|
||||
username: string;
|
||||
website: string;
|
||||
avatar_url: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class SupabaseService {
|
||||
private supabase: SupabaseClient;
|
||||
|
||||
constructor(private loadingCtrl: LoadingController, private toastCtrl: ToastController) {
|
||||
this.supabase = createClient(
|
||||
environment.supabaseUrl,
|
||||
environment.supabaseKey
|
||||
);
|
||||
}
|
||||
|
||||
get user() {
|
||||
return this.supabase.auth.user();
|
||||
}
|
||||
|
||||
get session() {
|
||||
return this.supabase.auth.session();
|
||||
}
|
||||
|
||||
get profile() {
|
||||
return this.supabase
|
||||
.from('profiles')
|
||||
.select(`username, website, avatar_url`)
|
||||
.eq('id', this.user?.id)
|
||||
.single();
|
||||
}
|
||||
|
||||
authChanges(
|
||||
callback: (event: AuthChangeEvent, session: Session | null) => void
|
||||
) {
|
||||
return this.supabase.auth.onAuthStateChange(callback);
|
||||
}
|
||||
|
||||
signIn(email: string) {
|
||||
return this.supabase.auth.signIn({ email });
|
||||
}
|
||||
|
||||
signOut() {
|
||||
return this.supabase.auth.signOut();
|
||||
}
|
||||
|
||||
updateProfile(profile: Profile) {
|
||||
const update = {
|
||||
...profile,
|
||||
id: this.user?.id,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
return this.supabase.from('profiles').upsert(update, {
|
||||
returning: 'minimal', // Don't return the value after inserting
|
||||
});
|
||||
}
|
||||
|
||||
downLoadImage(path: string) {
|
||||
return this.supabase.storage.from('avatars').download(path);
|
||||
}
|
||||
|
||||
uploadAvatar(filePath: string, file: File) {
|
||||
return this.supabase.storage.from('avatars').upload(filePath, file);
|
||||
}
|
||||
|
||||
async createNotice(message: string) {
|
||||
const toast = await this.toastCtrl.create({ message, duration: 5000 });
|
||||
await toast.present();
|
||||
}
|
||||
|
||||
createLoader() {
|
||||
return this.loadingCtrl.create();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
And one optional step is to update the CSS file `src/theme/variables.scss` to customize the look of your app.
|
||||
You can find the full contents of this file [here](https://raw.githubusercontent.com/angular-supa/supabase-angular-user-management/main/src/styles.css).
|
||||
|
||||
### Set up a Login route
|
||||
|
||||
Let's set up an route to manage logins and sign ups. We'll use Magic Links, so users can sign in with their email without using passwords.
|
||||
Create an **LoginPage** with `ionic g page login` Ionic CLI command.
|
||||
|
||||
> This guide will show the template inline, but the example app will have templateUrls
|
||||
|
||||
```ts title="src/app/login/login.page.ts"
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { SupabaseService } from '../supabase.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
template: `
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Login</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<div class="ion-padding">
|
||||
<h1>Supabase + Ionic Angular</h1>
|
||||
<p>Sign in via magic link with your email below</p>
|
||||
</div>
|
||||
<ion-list inset="true">
|
||||
<form (ngSubmit)="handleLogin($event)">
|
||||
<ion-item>
|
||||
<ion-label position="stacked">Email</ion-label>
|
||||
<ion-input
|
||||
[(ngModel)]="email"
|
||||
name="email"
|
||||
autocomplete
|
||||
type="email"
|
||||
></ion-input>
|
||||
</ion-item>
|
||||
<div class="ion-text-center">
|
||||
<ion-button type="submit" fill="clear">Login</ion-button>
|
||||
</div>
|
||||
</form>
|
||||
</ion-list>
|
||||
</ion-content>
|
||||
`,
|
||||
styleUrls: ['./login.page.scss'],
|
||||
})
|
||||
export class LoginPage implements OnInit {
|
||||
email = '';
|
||||
constructor(
|
||||
private readonly supabase: SupabaseService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {}
|
||||
async handleLogin(event: any) {
|
||||
event.preventDefault();
|
||||
const loader = await this.supabase.createLoader();
|
||||
await loader.present();
|
||||
try {
|
||||
await this.supabase.signIn(this.email);
|
||||
await loader.dismiss();
|
||||
await this.supabase.createNotice('Check your email for the login link!');
|
||||
} catch (error) {
|
||||
await loader.dismiss();
|
||||
await this.supabase.createNotice(error.error_description || error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Account page
|
||||
|
||||
After a user is signed in we can allow them to edit their profile details and manage their account.
|
||||
Create an **AccountComponent** with `ionic g page account` Ionic CLI command.
|
||||
|
||||
```ts title="src/app/account.component.ts"
|
||||
import { Component, OnInit} from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Profile, SupabaseService } from '../supabase.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-account',
|
||||
template: `
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Account</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<form>
|
||||
<ion-item>
|
||||
<ion-label position="stacked">Email</ion-label>
|
||||
<ion-input type="email" [value]="session?.user?.email"></ion-input>
|
||||
</ion-item>
|
||||
|
||||
<ion-item>
|
||||
<ion-label position="stacked">Name</ion-label>
|
||||
<ion-input
|
||||
type="text"
|
||||
name="username"
|
||||
[(ngModel)]="profile.username"
|
||||
></ion-input>
|
||||
</ion-item>
|
||||
|
||||
<ion-item>
|
||||
<ion-label position="stacked">Website</ion-label>
|
||||
<ion-input
|
||||
type="url"
|
||||
name="website"
|
||||
[(ngModel)]="profile.website"
|
||||
></ion-input>
|
||||
</ion-item>
|
||||
<div class="ion-text-center">
|
||||
<ion-button fill="clear" (click)="updateProfile()"
|
||||
>Update Profile</ion-button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="ion-text-center">
|
||||
<ion-button fill="clear" (click)="signOut()">Log Out</ion-button>
|
||||
</div>
|
||||
</ion-content>
|
||||
`,
|
||||
styleUrls: ['./account.page.scss'],
|
||||
})
|
||||
export class AccountPage implements OnInit {
|
||||
profile: Profile = {
|
||||
username: '',
|
||||
avatar_url: '',
|
||||
website: '',
|
||||
};
|
||||
|
||||
session = this.supabase.session;
|
||||
|
||||
constructor(
|
||||
private readonly supabase: SupabaseService,
|
||||
private router: Router
|
||||
) {}
|
||||
ngOnInit() {
|
||||
this.getProfile();
|
||||
}
|
||||
|
||||
async getProfile() {
|
||||
try {
|
||||
let { data: profile, error, status } = await this.supabase.profile;
|
||||
if (error && status !== 406) {
|
||||
throw error;
|
||||
}
|
||||
if (profile) {
|
||||
this.profile = profile;
|
||||
}
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async updateProfile(avatar_url: string = '') {
|
||||
const loader = await this.supabase.createLoader();
|
||||
await loader.present();
|
||||
try {
|
||||
await this.supabase.updateProfile({ ...this.profile, avatar_url });
|
||||
await loader.dismiss();
|
||||
await this.supabase.createNotice('Profile updated!');
|
||||
} catch (error) {
|
||||
await this.supabase.createNotice(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async signOut() {
|
||||
console.log('testing?');
|
||||
await this.supabase.signOut();
|
||||
this.router.navigate(['/'], { replaceUrl: true });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
### Launch!
|
||||
|
||||
Now that we have all the components in place, let's update **AppComponent**:
|
||||
|
||||
```ts title="src/app/app.component.ts"
|
||||
import { Component } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { SupabaseService } from './supabase.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
template: `
|
||||
<ion-app>
|
||||
<ion-router-outlet></ion-router-outlet>
|
||||
</ion-app>
|
||||
`,
|
||||
styleUrls: ['app.component.scss'],
|
||||
})
|
||||
export class AppComponent {
|
||||
constructor(private supabase: SupabaseService, private router: Router) {
|
||||
this.supabase.authChanges((_, session) => {
|
||||
console.log(session);
|
||||
if (session?.user) {
|
||||
this.router.navigate(['/account']);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then update the **AppRoutingModule**
|
||||
|
||||
```ts title="src/app/app.ts"
|
||||
import { NgModule } from '@angular/core';
|
||||
import { PreloadAllModules, RouterModule, Routes } from '@angular/router';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '/',
|
||||
loadChildren: () => import('./login/login.module').then((m) => m.LoginPageModule)
|
||||
},
|
||||
{
|
||||
path: 'account',
|
||||
loadChildren: () => import('./account/account.module').then((m) => m.AccountPageModule)
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forRoot(routes, {
|
||||
preloadingStrategy: PreloadAllModules
|
||||
}),
|
||||
],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class AppRoutingModule {}
|
||||
```
|
||||
|
||||
Once that's done, run this in a terminal window:
|
||||
|
||||
```bash
|
||||
ionic serve
|
||||
```
|
||||
|
||||
And the browser will auomatically open to show the app.
|
||||
|
||||
|
||||

|
||||
|
||||
## Bonus: Profile photos
|
||||
|
||||
Every Supabase project is configured with [Storage](/docs/guides/storage) for managing large files like photos and videos.
|
||||
|
||||
### Create an upload widget
|
||||
|
||||
Let's create an avatar for the user so that they can upload a profile photo.
|
||||
|
||||
First install two packages in order to interact with the user's camera.
|
||||
|
||||
```bash
|
||||
npm install @ionic/pwa-elements @capacitor/camera
|
||||
```
|
||||
|
||||
[CapacitorJS](https://capacitorjs.com) is a cross platform native runtime from Ionic that enables web apps to be deployed through the app store and provides access to native deavice API.
|
||||
|
||||
Ionic PWA elements is a companion package that will polyfill certain browser APIs that provide no user interface with custom Ionic UI.
|
||||
|
||||
With those packages installed we can update our `main.ts` to include an additional bootstapping call for the Ionic PWA Elements.
|
||||
|
||||
```ts title="src/main.ts"
|
||||
import { enableProdMode } from '@angular/core';
|
||||
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
|
||||
|
||||
import { AppModule } from './app/app.module';
|
||||
import { environment } from './environments/environment';
|
||||
|
||||
import { defineCustomElements } from '@ionic/pwa-elements/loader';
|
||||
defineCustomElements(window)
|
||||
|
||||
if (environment.production) {
|
||||
enableProdMode();
|
||||
}
|
||||
platformBrowserDynamic().bootstrapModule(AppModule)
|
||||
.catch(err => console.log(err));
|
||||
```
|
||||
|
||||
|
||||
Then create an **AvatarComponent** with this Ionic CLI command:
|
||||
|
||||
```bash
|
||||
ionic g component avatar --module=/src/app/account/account.module.ts --create-module
|
||||
```
|
||||
|
||||
```ts title="src/app/avatar.component.ts"
|
||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||
import { SupabaseService } from '../supabase.service';
|
||||
import { Camera, CameraResultType } from '@capacitor/camera';
|
||||
@Component({
|
||||
selector: 'app-avatar',
|
||||
template: `
|
||||
<div class="avatar_wrapper" (click)="uploadAvatar()">
|
||||
<img *ngIf="_avatarUrl; else noAvatar" [src]="_avatarUrl" />
|
||||
<ng-template #noAvatar>
|
||||
<ion-icon name="person" class="no-avatar"></ion-icon>
|
||||
</ng-template>
|
||||
</div>
|
||||
`,
|
||||
style: [`
|
||||
:host {
|
||||
display: block;
|
||||
margin: auto;
|
||||
min-height: 150px;
|
||||
}
|
||||
:host .avatar_wrapper {
|
||||
margin: 16px auto 16px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
height: 150px;
|
||||
aspect-ratio: 1;
|
||||
background: var(--ion-color-step-50);
|
||||
border: thick solid var(--ion-color-step-200);
|
||||
}
|
||||
:host .avatar_wrapper:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
:host .avatar_wrapper ion-icon.no-avatar {
|
||||
width: 100%;
|
||||
height: 115%;
|
||||
}
|
||||
:host img {
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class AvatarComponent implements OnInit {
|
||||
_avatarUrl: SafeResourceUrl | undefined;
|
||||
uploading = false;
|
||||
|
||||
@Input()
|
||||
set avatarUrl(url: string | undefined) {
|
||||
if (url) {
|
||||
this.downloadImage(url);
|
||||
}
|
||||
}
|
||||
|
||||
@Output() upload = new EventEmitter<string>();
|
||||
|
||||
constructor(
|
||||
private readonly supabase: SupabaseService,
|
||||
private readonly dom: DomSanitizer
|
||||
) {}
|
||||
|
||||
ngOnInit() {}
|
||||
|
||||
async downloadImage(path: string) {
|
||||
try {
|
||||
const { data } = await this.supabase.downLoadImage(path);
|
||||
this._avatarUrl = this.dom.bypassSecurityTrustResourceUrl(
|
||||
URL.createObjectURL(data)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error downloading image: ', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async uploadAvatar() {
|
||||
const loader = await this.supabase.createLoader();
|
||||
try {
|
||||
const photo = await Camera.getPhoto({
|
||||
resultType: CameraResultType.DataUrl,
|
||||
});
|
||||
|
||||
const file = await fetch(photo.dataUrl)
|
||||
.then((res) => res.blob())
|
||||
.then( (blob) => new File([blob], 'my-file', { type: `image/${photo.format}` }));
|
||||
|
||||
const fileName = `${Math.random()}-${new Date().getTime()}.${ photo.format }`;
|
||||
|
||||
await loader.present();
|
||||
await this.supabase.uploadAvatar(fileName, file);
|
||||
|
||||
this.upload.emit(fileName);
|
||||
} catch (error) {
|
||||
this.supabase.createNotice(error.message);
|
||||
} finally {
|
||||
loader.dismiss();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### Add the new widget
|
||||
|
||||
And then we can add the widget on top of the **AccountComponent** html template:
|
||||
|
||||
```ts title="src/app/account.component.ts"
|
||||
template: `
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Account</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<app-avatar
|
||||
[avatarUrl]="this.profile?.avatar_url"
|
||||
(upload)="updateProfile($event)"
|
||||
></app-avatar>
|
||||
|
||||
<!-- input fields -->
|
||||
`
|
||||
```
|
||||
|
||||
## Next steps
|
||||
|
||||
At this stage you have a fully functional application!
|
||||
|
||||
- Got a question? [Ask here](https://github.com/supabase/supabase/discussions).
|
||||
- Sign in: [app.supabase.io](https://app.supabase.io)
|
||||
@@ -0,0 +1,640 @@
|
||||
---
|
||||
id: with-ionic-react
|
||||
title: "Quickstart: Ionic React"
|
||||
description: Learn how to use Supabase in your Ionic React App.
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
## Intro
|
||||
|
||||
This example provides the steps to build a simple user management app (from scratch!) using Supabase and Ionic React. It includes:
|
||||
|
||||
- Supabase [Database](/docs/guides/database): a Postgres database for storing your user data.
|
||||
- Supabase [Auth](/docs/guides/auth): users can sign in with magic links (no passwords, only email).
|
||||
- Supabase [Storage](/docs/guides/storage): users can upload a photo.
|
||||
- [Row Level Security](/docs/guides/auth#row-level-security): data is protected so that individuals can only access their own data.
|
||||
- Instant [APIs](/docs/guides/api): APIs will be automatically generated when you create your database tables.
|
||||
|
||||
By the end of this guide you'll have an app which allows users to login and update some basic profile details:
|
||||
|
||||

|
||||
|
||||
Clicking this button the application will:
|
||||
|
||||
- Launch and prepare the Postgres database in Supabase.
|
||||
- Launch the app in Vercel.
|
||||
- Fork the example into your own GitHub account.
|
||||
- Prepare the deployed application with all the necessary environment variables.
|
||||
|
||||
If you want to do it yourself, let's get started!
|
||||
|
||||
### GitHub
|
||||
|
||||
Whenever you get stuck at any point, take a look at [this repo](https://github.com/mhartington/supabase-ionic-react).
|
||||
|
||||
## Project set up
|
||||
|
||||
Before we start building we're going to set up our Database and API. This is as simple as starting a new Project in Supabase
|
||||
and then creating a "schema" inside the database.
|
||||
|
||||
### Create a project
|
||||
|
||||
1. Go to [app.supabase.io](https://app.supabase.io).
|
||||
1. Click on "New Project".
|
||||
1. Enter your project details.
|
||||
1. Wait for the new database to launch.
|
||||
|
||||
|
||||
### Set up the database schema
|
||||
|
||||
Now we are going to set up the database schema. We can use the "User Management Starter" quickstart in the SQL Editor,
|
||||
or you can just copy/paste the SQL from below and run it yourself.
|
||||
|
||||
<Tabs
|
||||
defaultValue="UI"
|
||||
values={[
|
||||
{label: 'UI', value: 'UI'},
|
||||
{label: 'SQL', value: 'SQL'},
|
||||
]}>
|
||||
<TabItem value="UI">
|
||||
|
||||
```sh
|
||||
1. Go to the "SQL" section.
|
||||
2. Click "User Management Starter".
|
||||
3. Click "Run".
|
||||
```
|
||||
|
||||
<video width="99%" muted playsInline controls="true">
|
||||
<source src="/docs/videos/sql-user-management-starter.mp4" type="video/mp4" muted playsInline />
|
||||
</video>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="SQL">
|
||||
|
||||
```sql
|
||||
-- Create a table for public "profiles"
|
||||
create table profiles (
|
||||
id uuid references auth.users not null,
|
||||
updated_at timestamp with time zone,
|
||||
username text unique,
|
||||
avatar_url text,
|
||||
website text,
|
||||
|
||||
primary key (id),
|
||||
unique(username),
|
||||
constraint username_length check (char_length(username) >= 3)
|
||||
);
|
||||
|
||||
alter table profiles enable row level security;
|
||||
|
||||
create policy "Public profiles are viewable by everyone."
|
||||
on profiles for select
|
||||
using ( true );
|
||||
|
||||
create policy "Users can insert their own profile."
|
||||
on profiles for insert
|
||||
with check ( auth.uid() = id );
|
||||
|
||||
create policy "Users can update own profile."
|
||||
on profiles for update
|
||||
using ( auth.uid() = id );
|
||||
|
||||
-- Set up Realtime!
|
||||
begin;
|
||||
drop publication if exists supabase_realtime;
|
||||
create publication supabase_realtime;
|
||||
commit;
|
||||
alter publication supabase_realtime add table profiles;
|
||||
|
||||
-- Set up Storage!
|
||||
insert into storage.buckets (id, name)
|
||||
values ('avatars', 'avatars');
|
||||
|
||||
create policy "Avatar images are publicly accessible."
|
||||
on storage.objects for select
|
||||
using ( bucket_id = 'avatars' );
|
||||
|
||||
create policy "Anyone can upload an avatar."
|
||||
on storage.objects for insert
|
||||
with check ( bucket_id = 'avatars' );
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
|
||||
### Get the API Keys
|
||||
|
||||
Now that you've created some database tables, you are ready to insert data using the auto-generated API.
|
||||
We just need to get the URL and `anon` key from the API settings.
|
||||
|
||||
<Tabs
|
||||
defaultValue="UI"
|
||||
values={[
|
||||
{label: 'UI', value: 'UI'}
|
||||
]}>
|
||||
<TabItem value="UI">
|
||||
|
||||
```sh
|
||||
1. Go to the "Settings" section.
|
||||
2. Click "API" in the sidebar.
|
||||
3. Find your API URL in this page.
|
||||
4. Find your "anon" and "service_role" keys on this page.
|
||||
```
|
||||
|
||||
<video width="99%" muted playsInline controls="true">
|
||||
<source src="/docs/videos/api/api-url-and-key.mp4" type="video/mp4" muted playsInline />
|
||||
</video>
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Building the App
|
||||
|
||||
Let's start building the React app from scratch.
|
||||
|
||||
### Initialize an Ionic React app
|
||||
|
||||
We can use the [Ionic CLI](https://ionicframework.com/docs/cli) to initialize
|
||||
an app called `supabase-ionic-react`:
|
||||
|
||||
```bash
|
||||
npm install -g @ionic/cli
|
||||
ionic start supabase-ionic-react blank --type react
|
||||
cd supabase-ionic-react
|
||||
```
|
||||
|
||||
Then let's install the only additional dependency: [supabase-js](https://github.com/supabase/supabase-js)
|
||||
|
||||
```bash
|
||||
npm install @supabase/supabase-js
|
||||
```
|
||||
|
||||
And finally we want to save the environment variables in a `.env`.
|
||||
All we need are the API URL and the `anon` key that you copied [earlier](#get-the-api-keys).
|
||||
|
||||
```bash title=".env"
|
||||
REACT_APP_SUPABASE_URL=YOUR_SUPABASE_URL
|
||||
REACT_APP_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
|
||||
```
|
||||
|
||||
Now that we have the API credentials in place, let's create a helper file to initialize the Supabase client. These variables will be exposed
|
||||
on the browser, and that's completely fine since we have [Row Level Security](/docs/guides/auth#row-level-security) enabled on our Database.
|
||||
|
||||
```js title="src/supabaseClient.js"
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabaseUrl = process.env.REACT_APP_SUPABASE_URL
|
||||
const supabaseAnonKey = process.env.REACT_APP_SUPABASE_ANON_KEY
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
|
||||
```
|
||||
|
||||
And one optional step is to update the CSS file `src/theme/variables.scss` to customize the look of your app.
|
||||
You can find the full contents of this file [here](https://raw.githubusercontent.com/angular-supa/supabase-angular-user-management/main/src/styles.css).
|
||||
|
||||
### Set up a Login route
|
||||
|
||||
Let's set up a React component to manage logins and sign ups. We'll use Magic Links, so users can sign in with their email without using passwords.
|
||||
|
||||
```jsx title="/src/pages/Login.tsx"
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
IonButton,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonInput,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonList,
|
||||
IonPage,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
useIonToast,
|
||||
useIonLoading,
|
||||
} from '@ionic/react';
|
||||
import { supabase } from '../supabaseClient';
|
||||
|
||||
export function LoginPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
const [showLoading, hideLoading] = useIonLoading();
|
||||
const [showToast ] = useIonToast();
|
||||
const handleLogin = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
console.log()
|
||||
e.preventDefault();
|
||||
await showLoading();
|
||||
try {
|
||||
await supabase.auth.signIn({ email });
|
||||
await showToast({ message: 'Check your email for the login link!' });
|
||||
} catch (e: any) {
|
||||
await showToast({ message: e.error_description || e.message , duration: 5000});
|
||||
} finally {
|
||||
await hideLoading();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader>
|
||||
<IonToolbar>
|
||||
<IonTitle>Login</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent>
|
||||
<div className="ion-padding">
|
||||
<h1>Supabase + Ionic React</h1>
|
||||
<p>Sign in via magic link with your email below</p>
|
||||
</div>
|
||||
<IonList inset={true}>
|
||||
<form onSubmit={handleLogin}>
|
||||
<IonItem>
|
||||
<IonLabel position="stacked">Email</IonLabel>
|
||||
<IonInput
|
||||
value={email}
|
||||
name="email"
|
||||
onIonChange={(e) => setEmail(e.detail.value ?? '')}
|
||||
type="email"
|
||||
></IonInput>
|
||||
</IonItem>
|
||||
<div className="ion-text-center">
|
||||
<IonButton type="submit" fill="clear">
|
||||
Login
|
||||
</IonButton>
|
||||
</div>
|
||||
</form>
|
||||
</IonList>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Account page
|
||||
|
||||
After a user is signed in we can allow them to edit their profile details and manage their account.
|
||||
|
||||
Let's create a new component for that called `Account.tsx`.
|
||||
|
||||
```jsx title="src/pages/Account.tsx"
|
||||
import {
|
||||
IonButton,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonInput,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonPage,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
useIonLoading,
|
||||
useIonToast,
|
||||
useIonRouter
|
||||
} from '@ionic/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { supabase } from '../supabaseClient';
|
||||
|
||||
export function AccountPage() {
|
||||
const [showLoading, hideLoading] = useIonLoading();
|
||||
const [showToast] = useIonToast();
|
||||
const [session] = useState(() => supabase.auth.session());
|
||||
const router = useIonRouter();
|
||||
const [profile, setProfile] = useState({
|
||||
username: '',
|
||||
website: '',
|
||||
avatar_url: '',
|
||||
});
|
||||
useEffect(() => {
|
||||
getProfile();
|
||||
}, [session]);
|
||||
const getProfile = async () => {
|
||||
console.log('get');
|
||||
await showLoading();
|
||||
try {
|
||||
const user = supabase.auth.user();
|
||||
let { data, error, status } = await supabase
|
||||
.from('profiles')
|
||||
.select(`username, website, avatar_url`)
|
||||
.eq('id', user!.id)
|
||||
.single();
|
||||
|
||||
if (error && status !== 406) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
setProfile({
|
||||
username: data.username,
|
||||
website: data.website,
|
||||
avatar_url: data.avatar_url,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
showToast({ message: error.message, duration: 5000 });
|
||||
} finally {
|
||||
await hideLoading();
|
||||
}
|
||||
};
|
||||
const signOut = async () => {
|
||||
await supabase.auth.signOut();
|
||||
router.push('/', 'forward', 'replace');
|
||||
}
|
||||
const updateProfile = async (e?: any, avatar_url: string = '') => {
|
||||
e?.preventDefault();
|
||||
|
||||
console.log('update ');
|
||||
await showLoading();
|
||||
|
||||
try {
|
||||
const user = supabase.auth.user();
|
||||
|
||||
const updates = {
|
||||
id: user!.id,
|
||||
...profile,
|
||||
avatar_url: avatar_url,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
let { error } = await supabase.from('profiles').upsert(updates, {
|
||||
returning: 'minimal', // Don't return the value after inserting
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
} catch (error: any) {
|
||||
showToast({ message: error.message, duration: 5000 });
|
||||
} finally {
|
||||
await hideLoading();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader>
|
||||
<IonToolbar>
|
||||
<IonTitle>Account</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent>
|
||||
<form onSubmit={updateProfile}>
|
||||
<IonItem>
|
||||
<IonLabel>
|
||||
<p>Email</p>
|
||||
<p>{session?.user?.email}</p>
|
||||
</IonLabel>
|
||||
</IonItem>
|
||||
|
||||
<IonItem>
|
||||
<IonLabel position="stacked">Name</IonLabel>
|
||||
<IonInput
|
||||
type="text"
|
||||
name="username"
|
||||
value={profile.username}
|
||||
onIonChange={(e) =>
|
||||
setProfile({ ...profile, username: e.detail.value ?? '' })
|
||||
}
|
||||
></IonInput>
|
||||
</IonItem>
|
||||
|
||||
<IonItem>
|
||||
<IonLabel position="stacked">Website</IonLabel>
|
||||
<IonInput
|
||||
type="url"
|
||||
name="website"
|
||||
value={profile.website}
|
||||
onIonChange={(e) =>
|
||||
setProfile({ ...profile, website: e.detail.value ?? '' })
|
||||
}
|
||||
></IonInput>
|
||||
</IonItem>
|
||||
<div className="ion-text-center">
|
||||
<IonButton fill="clear" type="submit">
|
||||
Update Profile
|
||||
</IonButton>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="ion-text-center">
|
||||
<IonButton fill="clear" onClick={signOut}>
|
||||
Log Out
|
||||
</IonButton>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Launch!
|
||||
|
||||
Now that we have all the components in place, let's update `App.tsx`:
|
||||
|
||||
```jsx title="src/App.tsx"
|
||||
import { Redirect, Route } from 'react-router-dom';
|
||||
import { IonApp, IonRouterOutlet, setupIonicReact } from '@ionic/react';
|
||||
import { IonReactRouter } from '@ionic/react-router';
|
||||
import { supabase } from './supabaseClient';
|
||||
|
||||
import '@ionic/react/css/ionic.bundle.css';
|
||||
|
||||
/* Theme variables */
|
||||
import './theme/variables.css';
|
||||
import { LoginPage } from './pages/Login';
|
||||
import { AccountPage } from './pages/Account';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Session } from '@supabase/supabase-js';
|
||||
|
||||
setupIonicReact();
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
useEffect(() => {
|
||||
setSession(supabase.auth.session());
|
||||
supabase.auth.onAuthStateChange((_event, session) => {
|
||||
setSession(session);
|
||||
});
|
||||
}, [session]);
|
||||
return (
|
||||
<IonApp>
|
||||
<IonReactRouter>
|
||||
<IonRouterOutlet>
|
||||
<Route
|
||||
exact
|
||||
path="/"
|
||||
render={() => {
|
||||
return session ? <Redirect to="/account" /> : <LoginPage />;
|
||||
}}
|
||||
/>
|
||||
<Route exact path="/account">
|
||||
<AccountPage />
|
||||
</Route>
|
||||
</IonRouterOutlet>
|
||||
</IonReactRouter>
|
||||
</IonApp>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
```
|
||||
|
||||
Once that's done, run this in a terminal window:
|
||||
|
||||
```bash
|
||||
ionic serve
|
||||
```
|
||||
|
||||
And then open the browser to [localhost:3000](http://localhost:3000) and you should see the completed app.
|
||||
|
||||

|
||||
|
||||
## Bonus: Profile photos
|
||||
|
||||
Every Supabase project is configured with [Storage](/docs/guides/storage) for managing large files like photos and videos.
|
||||
|
||||
### Create an upload widget
|
||||
|
||||
First install two packages in order to interact with the user's camera.
|
||||
|
||||
```bash
|
||||
npm install @ionic/pwa-elements @capacitor/camera
|
||||
```
|
||||
|
||||
[CapacitorJS](https://capacitorjs.com) is a cross platform native runtime from Ionic that enables web apps to be deployed through the app store and provides access to native deavice API.
|
||||
|
||||
Ionic PWA elements is a companion package that will polyfill certain browser APIs that provide no user interface with custom Ionic UI.
|
||||
|
||||
With those packages installed we can update our `index.tsx` to include an additional bootstapping call for the Ionic PWA Elements.
|
||||
|
||||
```ts title="src/index.tsx"
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import App from './App';
|
||||
import * as serviceWorkerRegistration from './serviceWorkerRegistration';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
import {defineCustomElements} from '@ionic/pwa-elements/loader';
|
||||
defineCustomElements(window);
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
);
|
||||
|
||||
serviceWorkerRegistration.unregister();
|
||||
reportWebVitals();
|
||||
```
|
||||
|
||||
Then create an **AvatarComponent**.
|
||||
|
||||
```jsx title="src/components/Avatar.tsx"
|
||||
import { IonIcon } from '@ionic/react';
|
||||
import { person } from 'ionicons/icons';
|
||||
import { Camera, CameraResultType } from '@capacitor/camera';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { supabase } from '../supabaseClient';
|
||||
import './Avatar.css'
|
||||
export function Avatar({
|
||||
url,
|
||||
onUpload,
|
||||
}: {
|
||||
url: string;
|
||||
onUpload: (e: any, file: string) => Promise<void>;
|
||||
}) {
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (url) {
|
||||
downloadImage(url);
|
||||
}
|
||||
}, [url]);
|
||||
const uploadAvatar = async () => {
|
||||
try {
|
||||
const photo = await Camera.getPhoto({
|
||||
resultType: CameraResultType.DataUrl,
|
||||
});
|
||||
|
||||
const file = await fetch(photo.dataUrl!)
|
||||
.then((res) => res.blob())
|
||||
.then(
|
||||
(blob) =>
|
||||
new File([blob], 'my-file', { type: `image/${photo.format}` })
|
||||
);
|
||||
|
||||
const fileName = `${Math.random()}-${new Date().getTime()}.${
|
||||
photo.format
|
||||
}`;
|
||||
let { error: uploadError } = await supabase.storage
|
||||
.from('avatars')
|
||||
.upload(fileName, file);
|
||||
if (uploadError) {
|
||||
throw uploadError;
|
||||
}
|
||||
onUpload(null, fileName);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadImage = async (path: string) => {
|
||||
try {
|
||||
const { data, error } = await supabase.storage
|
||||
.from('avatars')
|
||||
.download(path);
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
const url = URL.createObjectURL(data!);
|
||||
setAvatarUrl(url);
|
||||
} catch (error: any) {
|
||||
console.log('Error downloading image: ', error.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="avatar">
|
||||
<div className="avatar_wrapper" onClick={uploadAvatar}>
|
||||
{avatarUrl ? (
|
||||
<img src={avatarUrl} />
|
||||
) : (
|
||||
<IonIcon icon={person} className="no-avatar" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Add the new widget
|
||||
|
||||
And then we can add the widget to the Account page:
|
||||
|
||||
```jsx title="src/pages/Account.tsx"
|
||||
// Import the new component
|
||||
|
||||
import { Avatar } from '../components/Avatar';
|
||||
|
||||
// ...
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader>
|
||||
<IonToolbar>
|
||||
<IonTitle>Account</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent>
|
||||
<Avatar url={profile.avatar_url} onUpload={updateProfile}></Avatar>
|
||||
```
|
||||
|
||||
## Next steps
|
||||
|
||||
At this stage you have a fully functional application!
|
||||
|
||||
- Got a question? [Ask here](https://github.com/supabase/supabase/discussions).
|
||||
- Sign in: [app.supabase.io](https://app.supabase.io)
|
||||
@@ -0,0 +1,729 @@
|
||||
---
|
||||
id: with-ionic-vue
|
||||
title: "Quickstart: Ionic Vue"
|
||||
description: Learn how to use Supabase in your Ionic Vue App.
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
## Intro
|
||||
|
||||
This example provides the steps to build a simple user management app (from scratch!) using Supabase and Ionic Vue. It includes:
|
||||
|
||||
- Supabase [Database](/docs/guides/database): a Postgres database for storing your user data.
|
||||
- Supabase [Auth](/docs/guides/auth): users can sign in with magic links (no passwords, only email).
|
||||
- Supabase [Storage](/docs/guides/storage): users can upload a photo.
|
||||
- [Row Level Security](/docs/guides/auth#row-level-security): data is protected so that individuals can only access their own data.
|
||||
- Instant [APIs](/docs/guides/api): APIs will be automatically generated when you create your database tables.
|
||||
|
||||
By the end of this guide you'll have an app which allows users to login and update some basic profile details:
|
||||
|
||||

|
||||
|
||||
Clicking this button the application will:
|
||||
|
||||
- Launch and prepare the Postgres database in Supabase.
|
||||
- Launch the app in Vercel.
|
||||
- Fork the example into your own GitHub account.
|
||||
- Prepare the deployed application with all the necessary environment variables.
|
||||
|
||||
If you want to do it yourself, let's get started!
|
||||
|
||||
### GitHub
|
||||
|
||||
Whenever you get stuck at any point, take a look at [this repo](https://github.com/mhartington/supabase-ionic-vue).
|
||||
|
||||
## Project set up
|
||||
|
||||
Before we start building we're going to set up our Database and API. This is as simple as starting a new Project in Supabase
|
||||
and then creating a "schema" inside the database.
|
||||
|
||||
### Create a project
|
||||
|
||||
1. Go to [app.supabase.io](https://app.supabase.io).
|
||||
1. Click on "New Project".
|
||||
1. Enter your project details.
|
||||
1. Wait for the new database to launch.
|
||||
|
||||
|
||||
### Set up the database schema
|
||||
|
||||
Now we are going to set up the database schema. We can use the "User Management Starter" quickstart in the SQL Editor,
|
||||
or you can just copy/paste the SQL from below and run it yourself.
|
||||
|
||||
<Tabs
|
||||
defaultValue="UI"
|
||||
values={[
|
||||
{label: 'UI', value: 'UI'},
|
||||
{label: 'SQL', value: 'SQL'},
|
||||
]}>
|
||||
<TabItem value="UI">
|
||||
|
||||
```sh
|
||||
1. Go to the "SQL" section.
|
||||
2. Click "User Management Starter".
|
||||
3. Click "Run".
|
||||
```
|
||||
|
||||
<video width="99%" muted playsInline controls="true">
|
||||
<source src="/docs/videos/sql-user-management-starter.mp4" type="video/mp4" muted playsInline />
|
||||
</video>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="SQL">
|
||||
|
||||
```sql
|
||||
-- Create a table for public "profiles"
|
||||
create table profiles (
|
||||
id uuid references auth.users not null,
|
||||
updated_at timestamp with time zone,
|
||||
username text unique,
|
||||
avatar_url text,
|
||||
website text,
|
||||
|
||||
primary key (id),
|
||||
unique(username),
|
||||
constraint username_length check (char_length(username) >= 3)
|
||||
);
|
||||
|
||||
alter table profiles enable row level security;
|
||||
|
||||
create policy "Public profiles are viewable by everyone."
|
||||
on profiles for select
|
||||
using ( true );
|
||||
|
||||
create policy "Users can insert their own profile."
|
||||
on profiles for insert
|
||||
with check ( auth.uid() = id );
|
||||
|
||||
create policy "Users can update own profile."
|
||||
on profiles for update
|
||||
using ( auth.uid() = id );
|
||||
|
||||
-- Set up Realtime!
|
||||
begin;
|
||||
drop publication if exists supabase_realtime;
|
||||
create publication supabase_realtime;
|
||||
commit;
|
||||
alter publication supabase_realtime add table profiles;
|
||||
|
||||
-- Set up Storage!
|
||||
insert into storage.buckets (id, name)
|
||||
values ('avatars', 'avatars');
|
||||
|
||||
create policy "Avatar images are publicly accessible."
|
||||
on storage.objects for select
|
||||
using ( bucket_id = 'avatars' );
|
||||
|
||||
create policy "Anyone can upload an avatar."
|
||||
on storage.objects for insert
|
||||
with check ( bucket_id = 'avatars' );
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
|
||||
### Get the API Keys
|
||||
|
||||
Now that you've created some database tables, you are ready to insert data using the auto-generated API.
|
||||
We just need to get the URL and `anon` key from the API settings.
|
||||
|
||||
<Tabs
|
||||
defaultValue="UI"
|
||||
values={[
|
||||
{label: 'UI', value: 'UI'}
|
||||
]}>
|
||||
<TabItem value="UI">
|
||||
|
||||
```sh
|
||||
1. Go to the "Settings" section.
|
||||
2. Click "API" in the sidebar.
|
||||
3. Find your API URL in this page.
|
||||
4. Find your "anon" and "service_role" keys on this page.
|
||||
```
|
||||
|
||||
<video width="99%" muted playsInline controls="true">
|
||||
<source src="/docs/videos/api/api-url-and-key.mp4" type="video/mp4" muted playsInline />
|
||||
</video>
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Building the App
|
||||
|
||||
Let's start building the React app from scratch.
|
||||
|
||||
### Initialize an Ionic React app
|
||||
|
||||
We can use the [Ionic CLI](https://ionicframework.com/docs/cli) to initialize
|
||||
an app called `supabase-ionic-vue`:
|
||||
|
||||
```bash
|
||||
npm install -g @ionic/cli
|
||||
ionic start supabase-ionic-vue blank --type vue
|
||||
cd supabase-ionic-vue
|
||||
```
|
||||
|
||||
Then let's install the only additional dependency: [supabase-js](https://github.com/supabase/supabase-js)
|
||||
|
||||
```bash
|
||||
npm install @supabase/supabase-js
|
||||
```
|
||||
|
||||
And finally we want to save the environment variables in a `.env`.
|
||||
All we need are the API URL and the `anon` key that you copied [earlier](#get-the-api-keys).
|
||||
|
||||
```bash title=".env"
|
||||
VUE_APP_SUPABASE_URL=YOUR_SUPABASE_URL
|
||||
VUE_APP_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
|
||||
```
|
||||
|
||||
Now that we have the API credentials in place, let's create a helper file to initialize the Supabase client. These variables will be exposed
|
||||
on the browser, and that's completely fine since we have [Row Level Security](/docs/guides/auth#row-level-security) enabled on our Database.
|
||||
|
||||
```js title="src/supabase.ts"
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl = process.env.VUE_APP_SUPABASE_URL as string;
|
||||
const supabaseAnonKey = process.env.VUE_APP_SUPABASE_ANON_KEY as string;
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
||||
```
|
||||
|
||||
And one optional step is to update the CSS file `src/theme/variables.scss` to customize the look of your app.
|
||||
You can find the full contents of this file [here](https://raw.githubusercontent.com/angular-supa/supabase-angular-user-management/main/src/styles.css).
|
||||
|
||||
### Set up a Login route
|
||||
|
||||
Let's set up a React component to manage logins and sign ups. We'll use Magic Links, so users can sign in with their email without using passwords.
|
||||
|
||||
```html title="/src/views/Login.vue"
|
||||
<template>
|
||||
<ion-page>
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Login</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<div class="ion-padding">
|
||||
<h1>Supabase + Ionic Vue</h1>
|
||||
<p>Sign in via magic link with your email below</p>
|
||||
</div>
|
||||
<ion-list inset="true">
|
||||
<form @submit.prevent="handleLogin">
|
||||
<ion-item>
|
||||
<ion-label position="stacked">Email</ion-label>
|
||||
<ion-input
|
||||
v-model="email"
|
||||
name="email"
|
||||
autocomplete
|
||||
type="email"
|
||||
></ion-input>
|
||||
</ion-item>
|
||||
<div class="ion-text-center">
|
||||
<ion-button type="submit" fill="clear">Login</ion-button>
|
||||
</div>
|
||||
</form>
|
||||
</ion-list>
|
||||
<p>{{email}}</p>
|
||||
</ion-content>
|
||||
|
||||
</ion-page>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { supabase } from '../supabase';
|
||||
import {
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonPage,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
IonList,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonInput,
|
||||
IonButton,
|
||||
toastController,
|
||||
loadingController,
|
||||
} from '@ionic/vue';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'LoginPage',
|
||||
components: {
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonPage,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
IonList,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonInput,
|
||||
IonButton,
|
||||
},
|
||||
setup() {
|
||||
const email = ref('');
|
||||
const handleLogin = async () => {
|
||||
|
||||
const loader = await loadingController.create({});
|
||||
const toast = await toastController.create({ duration: 5000 });
|
||||
|
||||
try {
|
||||
await loader.present();
|
||||
const { error } = await supabase.auth.signIn({ email: email.value });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.message = 'Check your email for the login link!';
|
||||
await toast.present();
|
||||
|
||||
} catch (error: any) {
|
||||
|
||||
toast.message = error.error_description || error.message;
|
||||
await toast.present();
|
||||
|
||||
} finally {
|
||||
|
||||
await loader.dismiss();
|
||||
|
||||
}
|
||||
};
|
||||
return { handleLogin, email };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Account page
|
||||
|
||||
After a user is signed in we can allow them to edit their profile details and manage their account.
|
||||
|
||||
Let's create a new component for that called `Account.vue`.
|
||||
|
||||
```html title="src/views/Account.vue"
|
||||
<template>
|
||||
<ion-page>
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Account</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<form @submit.prevent="updateProfile">
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<p>Email</p>
|
||||
<p>{{ session?.user?.email }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item>
|
||||
<ion-label position="stacked">Name</ion-label>
|
||||
<ion-input
|
||||
type="text"
|
||||
name="username"
|
||||
v-model="profile.username"
|
||||
></ion-input>
|
||||
</ion-item>
|
||||
|
||||
<ion-item>
|
||||
<ion-label position="stacked">Website</ion-label>
|
||||
<ion-input
|
||||
type="url"
|
||||
name="website"
|
||||
v-model="profile.website"
|
||||
></ion-input>
|
||||
</ion-item>
|
||||
<div class="ion-text-center">
|
||||
<ion-button fill="clear" type="submit">Update Profile</ion-button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="ion-text-center">
|
||||
<ion-button fill="clear" @click="signOut">Log Out</ion-button>
|
||||
</div>
|
||||
</ion-content>
|
||||
</ion-page>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { store } from '@/store';
|
||||
import { supabase } from '@/supabase';
|
||||
import {
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonPage,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
toastController,
|
||||
loadingController,
|
||||
IonInput,
|
||||
IonItem,
|
||||
IonButton,
|
||||
IonLabel,
|
||||
} from '@ionic/vue';
|
||||
import { User } from '@supabase/supabase-js';
|
||||
import { defineComponent, onMounted, ref } from 'vue';
|
||||
export default defineComponent({
|
||||
name: 'AccountPage',
|
||||
components: {
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonPage,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
IonInput,
|
||||
IonItem,
|
||||
IonButton,
|
||||
IonLabel,
|
||||
},
|
||||
setup() {
|
||||
const session = ref(supabase.auth.session());
|
||||
const profile = ref({
|
||||
username: '',
|
||||
website: '',
|
||||
avatar_url: '',
|
||||
});
|
||||
const user = store.user as User;
|
||||
async function getProfile() {
|
||||
const loader = await loadingController.create({});
|
||||
const toast = await toastController.create({ duration: 5000 });
|
||||
await loader.present();
|
||||
try {
|
||||
let { data, error, status } = await supabase
|
||||
.from('profiles')
|
||||
.select(`username, website, avatar_url`)
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (error && status !== 406) throw error;
|
||||
|
||||
if (data) {
|
||||
console.log(data)
|
||||
profile.value = {
|
||||
username: data.username,
|
||||
website: data.website,
|
||||
avatar_url: data.avatar_url,
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.message = error.message;
|
||||
await toast.present();
|
||||
} finally {
|
||||
await loader.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
const updateProfile = async () => {
|
||||
const loader = await loadingController.create({});
|
||||
const toast = await toastController.create({ duration: 5000 });
|
||||
try {
|
||||
await loader.present();
|
||||
const updates = {
|
||||
id: user.id,
|
||||
...profile.value,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
//
|
||||
let { error } = await supabase.from('profiles').upsert(updates, {
|
||||
returning: 'minimal', // Don't return the value after inserting
|
||||
});
|
||||
//
|
||||
if (error) throw error;
|
||||
} catch (error: any) {
|
||||
toast.message = error.message;
|
||||
await toast.present();
|
||||
} finally {
|
||||
await loader.dismiss();
|
||||
}
|
||||
};
|
||||
|
||||
async function signOut() {
|
||||
const loader = await loadingController.create({});
|
||||
const toast = await toastController.create({ duration: 5000 });
|
||||
await loader.present();
|
||||
try {
|
||||
let { error } = await supabase.auth.signOut();
|
||||
if (error) throw error;
|
||||
} catch (error: any) {
|
||||
toast.message = error.message;
|
||||
await toast.present();
|
||||
} finally {
|
||||
await loader.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getProfile();
|
||||
});
|
||||
return { signOut, profile, session, updateProfile };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Launch!
|
||||
|
||||
Now that we have all the components in place, let's update `App.vue` and our routes:
|
||||
|
||||
```ts title="src/router.index.ts"
|
||||
import { createRouter, createWebHistory } from '@ionic/vue-router';
|
||||
import { RouteRecordRaw } from 'vue-router';
|
||||
import LoginPage from '../views/Login.vue';
|
||||
import AccountPage from '../views/Account.vue';
|
||||
const routes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Login',
|
||||
component: LoginPage
|
||||
},
|
||||
{
|
||||
path: '/account',
|
||||
name: 'Account',
|
||||
component: AccountPage
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(process.env.BASE_URL),
|
||||
routes
|
||||
})
|
||||
|
||||
export default router
|
||||
```
|
||||
|
||||
|
||||
```html title="src/App.vue"
|
||||
<template>
|
||||
<ion-app>
|
||||
<ion-router-outlet />
|
||||
</ion-app>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { IonApp, IonRouterOutlet, useIonRouter } from '@ionic/vue';
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
import { store } from './store';
|
||||
import { supabase } from './supabase';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'App',
|
||||
components: {
|
||||
IonApp,
|
||||
IonRouterOutlet
|
||||
},
|
||||
setup(){
|
||||
const router = useIonRouter();
|
||||
store.user = supabase.auth.user() ?? {};
|
||||
supabase.auth.onAuthStateChange((_, session) => {
|
||||
store.user = session?.user ?? {}
|
||||
if(session?.user) {
|
||||
router.replace('/account');
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
Once that's done, run this in a terminal window:
|
||||
|
||||
```bash
|
||||
ionic serve
|
||||
```
|
||||
|
||||
And then open the browser to [localhost:3000](http://localhost:3000) and you should see the completed app.
|
||||
|
||||

|
||||
|
||||
## Bonus: Profile photos
|
||||
|
||||
Every Supabase project is configured with [Storage](/docs/guides/storage) for managing large files like photos and videos.
|
||||
|
||||
### Create an upload widget
|
||||
|
||||
First install two packages in order to interact with the user's camera.
|
||||
|
||||
```bash
|
||||
npm install @ionic/pwa-elements @capacitor/camera
|
||||
```
|
||||
|
||||
[CapacitorJS](https://capacitorjs.com) is a cross platform native runtime from Ionic that enables web apps to be deployed through the app store and provides access to native deavice API.
|
||||
|
||||
Ionic PWA elements is a companion package that will polyfill certain browser APIs that provide no user interface with custom Ionic UI.
|
||||
|
||||
With those packages installed we can update our `main.ts` to include an additional bootstapping call for the Ionic PWA Elements.
|
||||
|
||||
```ts title="src/main.tsx"
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router';
|
||||
|
||||
import { IonicVue } from '@ionic/vue';
|
||||
/* Core CSS required for Ionic components to work properly */
|
||||
import '@ionic/vue/css/ionic.bundle.css';
|
||||
|
||||
/* Theme variables */
|
||||
import './theme/variables.css';
|
||||
|
||||
import { defineCustomElements } from '@ionic/pwa-elements/loader';
|
||||
defineCustomElements(window);
|
||||
const app = createApp(App)
|
||||
.use(IonicVue)
|
||||
.use(router);
|
||||
|
||||
router.isReady().then(() => {
|
||||
app.mount('#app');
|
||||
});
|
||||
```
|
||||
|
||||
Then create an **AvatarComponent**.
|
||||
|
||||
```html title="src/components/Avatar.vue"
|
||||
<template>
|
||||
<div class="avatar">
|
||||
<div class="avatar_wrapper" @click="uploadAvatar">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" />
|
||||
<ion-icon v-else name="person" class="no-avatar"></ion-icon>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { ref, toRefs, watch, defineComponent } from 'vue';
|
||||
import { supabase } from '../supabase';
|
||||
import { Camera, CameraResultType } from '@capacitor/camera';
|
||||
import { IonIcon } from '@ionic/vue';
|
||||
import { person } from 'ionicons/icons';
|
||||
export default defineComponent({
|
||||
name: 'AppAvatar',
|
||||
props: { path: String },
|
||||
emits: ['upload', 'update:path'],
|
||||
components: { IonIcon },
|
||||
setup(prop, { emit }) {
|
||||
const { path } = toRefs(prop);
|
||||
const avatarUrl = ref('');
|
||||
|
||||
const downloadImage = async () => {
|
||||
try {
|
||||
const { data, error } = await supabase.storage
|
||||
.from('avatars')
|
||||
.download(path.value);
|
||||
if (error) throw error;
|
||||
avatarUrl.value = URL.createObjectURL(data!);
|
||||
} catch (error: any) {
|
||||
console.error('Error downloading image: ', error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadAvatar = async () => {
|
||||
try {
|
||||
const photo = await Camera.getPhoto({
|
||||
resultType: CameraResultType.DataUrl,
|
||||
});
|
||||
if (photo.dataUrl) {
|
||||
const file = await fetch(photo.dataUrl)
|
||||
.then((res) => res.blob())
|
||||
.then(
|
||||
(blob) =>
|
||||
new File([blob], 'my-file', { type: `image/${photo.format}` })
|
||||
);
|
||||
|
||||
const fileName = `${Math.random()}-${new Date().getTime()}.${
|
||||
photo.format
|
||||
}`;
|
||||
let { error: uploadError } = await supabase.storage
|
||||
.from('avatars')
|
||||
.upload(fileName, file);
|
||||
if (uploadError) {
|
||||
throw uploadError;
|
||||
}
|
||||
emit('update:path', fileName);
|
||||
emit('upload');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
watch(path, () => {
|
||||
if (path.value) downloadImage();
|
||||
});
|
||||
|
||||
return { avatarUrl, uploadAvatar, person };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
.avatar {
|
||||
display: block;
|
||||
margin: auto;
|
||||
min-height: 150px;
|
||||
}
|
||||
.avatar .avatar_wrapper {
|
||||
margin: 16px auto 16px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
height: 150px;
|
||||
aspect-ratio: 1;
|
||||
background: var(--ion-color-step-50);
|
||||
border: thick solid var(--ion-color-step-200);
|
||||
}
|
||||
.avatar .avatar_wrapper:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
.avatar .avatar_wrapper ion-icon.no-avatar {
|
||||
width: 100%;
|
||||
height: 115%;
|
||||
}
|
||||
.avatar img {
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Add the new widget
|
||||
|
||||
And then we can add the widget to the Account page:
|
||||
```html title="src/views/Account.vue"
|
||||
<template>
|
||||
<ion-page>
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Account</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<avatar v-model:path="profile.avatar_url" @upload="updateProfile"></avatar>
|
||||
...
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import Avatar from '../components/Avatar.vue';
|
||||
export default defineComponent({
|
||||
name: 'AccountPage',
|
||||
components: {
|
||||
Avatar,
|
||||
....
|
||||
}
|
||||
|
||||
</script>
|
||||
```
|
||||
|
||||
## Next steps
|
||||
|
||||
At this stage you have a fully functional application!
|
||||
|
||||
- Got a question? [Ask here](https://github.com/supabase/supabase/discussions).
|
||||
- Sign in: [app.supabase.io](https://app.supabase.io)
|
||||
+104
-123
@@ -1,13 +1,13 @@
|
||||
---
|
||||
id: with-react
|
||||
title: "Quickstart: React"
|
||||
title: 'Quickstart: React'
|
||||
description: Learn how to use Supabase in your React App.
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
import Tabs from '@theme/Tabs'
|
||||
import TabItem from '@theme/TabItem'
|
||||
|
||||
## Intro
|
||||
## Intro
|
||||
|
||||
This example provides the steps to build a simple user management app (from scratch!) using Supabase and React. It includes:
|
||||
|
||||
@@ -21,7 +21,7 @@ By the end of this guide you'll have an app which allows users to login and upda
|
||||
|
||||

|
||||
|
||||
<!-- ## Video demo
|
||||
<!-- ## Video demo
|
||||
|
||||
Coming soon.
|
||||
|
||||
@@ -29,7 +29,7 @@ Use the annotated timeline to step through this tutorial.
|
||||
|
||||
## Instant deploy
|
||||
|
||||
Want to deploy a completed version of this example without following the guide?
|
||||
Want to deploy a completed version of this example without following the guide?
|
||||
|
||||
[](https://vercel.com/new/git/external?repository-url=https%3A%2F%2Fgithub.com%2Fsupabase%2Fsupabase%2Ftree%2Fmaster%2Fexamples%2Freact-user-management&project-name=supabase-user-management&repository-name=supabase-user-management&demo-title=Supabase%20User%20Management&demo-description=An%20example%20web%20app%20using%20Supabase%20and%20Next.js&demo-url=https%3A%2F%2Fsupabase-react-user-management.vercel.app&demo-image=https%3A%2F%2Fi.imgur.com%2FZ3HkQqe.png&integration-ids=oac_jUduyjQgOyzev1fjrW83NYOv&external-id=nextjs-user-management)
|
||||
|
||||
@@ -44,20 +44,19 @@ If you want to do it yourself, let's get started: -->
|
||||
|
||||
## Project set up
|
||||
|
||||
Before we start building we're going to set up our Database and API. This is as simple as starting a new Project in Supabase
|
||||
Before we start building we're going to set up our Database and API. This is as simple as starting a new Project in Supabase
|
||||
and then creating a "schema" inside the database.
|
||||
|
||||
### Create a project
|
||||
### Create a project
|
||||
|
||||
1. Go to [app.supabase.io](https://app.supabase.io).
|
||||
1. Click on "New Project".
|
||||
1. Enter your project details.
|
||||
1. Wait for the new database to launch.
|
||||
|
||||
|
||||
### Set up the database schema
|
||||
|
||||
Now we are going to set up the database schema. We can use the "User Management Starter" quickstart in the SQL Editor,
|
||||
Now we are going to set up the database schema. We can use the "User Management Starter" quickstart in the SQL Editor,
|
||||
or you can just copy/paste the SQL from below and run it yourself.
|
||||
|
||||
<Tabs
|
||||
@@ -75,7 +74,7 @@ values={[
|
||||
```
|
||||
|
||||
<video width="99%" muted playsInline controls="true">
|
||||
<source src="/docs/videos/sql-user-management-starter.mp4" type="video/mp4" muted playsInline />
|
||||
<source src="/docs/videos/sql-user-management-starter.mp4" type="video/mp4" muted playsInline />
|
||||
</video>
|
||||
|
||||
</TabItem>
|
||||
@@ -133,11 +132,9 @@ create policy "Anyone can upload an avatar."
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Get the API Keys
|
||||
|
||||
### Get the API Keys
|
||||
|
||||
|
||||
Now that you've created some database tables, you are ready to insert data using the auto-generated API.
|
||||
Now that you've created some database tables, you are ready to insert data using the auto-generated API.
|
||||
We just need to get the URL and `anon` key from the API settings.
|
||||
|
||||
<Tabs
|
||||
@@ -155,7 +152,7 @@ values={[
|
||||
```
|
||||
|
||||
<video width="99%" muted playsInline controls="true">
|
||||
<source src="/docs/videos/api/api-url-and-key.mp4" type="video/mp4" muted playsInline />
|
||||
<source src="/docs/videos/api/api-url-and-key.mp4" type="video/mp4" muted playsInline />
|
||||
</video>
|
||||
|
||||
</TabItem>
|
||||
@@ -167,7 +164,7 @@ Let's start building the React app from scratch.
|
||||
|
||||
### Initialize a React app
|
||||
|
||||
We can use [Create React App](https://create-react-app.dev/docs/getting-started/) to initialize
|
||||
We can use [Create React App](https://create-react-app.dev/docs/getting-started/) to initialize
|
||||
an app called `supabase-react`:
|
||||
|
||||
```bash
|
||||
@@ -181,7 +178,7 @@ Then let's install the only additional dependency: [supabase-js](https://github.
|
||||
npm install @supabase/supabase-js
|
||||
```
|
||||
|
||||
And finally we want to save the environment variables in a `.env`.
|
||||
And finally we want to save the environment variables in a `.env`.
|
||||
All we need are the API URL and the `anon` key that you copied [earlier](#get-the-api-keys).
|
||||
|
||||
```bash title=".env"
|
||||
@@ -201,7 +198,7 @@ const supabaseAnonKey = process.env.REACT_APP_SUPABASE_ANON_KEY
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
|
||||
```
|
||||
|
||||
And one optional step is to update the CSS file `src/index.css` to make the app look nice.
|
||||
And one optional step is to update the CSS file `src/index.css` to make the app look nice.
|
||||
You can find the full contents of this file [here](https://raw.githubusercontent.com/supabase/supabase/master/examples/react-user-management/src/index.css).
|
||||
|
||||
### Set up a Login component
|
||||
@@ -216,7 +213,9 @@ export default function Auth() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [email, setEmail] = useState('')
|
||||
|
||||
const handleLogin = async (email) => {
|
||||
const handleLogin = async (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const { error } = await supabase.auth.signIn({ email })
|
||||
@@ -231,30 +230,27 @@ export default function Auth() {
|
||||
|
||||
return (
|
||||
<div className="row flex flex-center">
|
||||
<div className="col-6 form-widget">
|
||||
<div className="col-6 form-widget" aria-live="polite">
|
||||
<h1 className="header">Supabase + React</h1>
|
||||
<p className="description">Sign in via magic link with your email below</p>
|
||||
<div>
|
||||
<input
|
||||
className="inputField"
|
||||
type="email"
|
||||
placeholder="Your email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleLogin(email)
|
||||
}}
|
||||
className={'button block'}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? <span>Loading</span> : <span>Send magic link</span>}
|
||||
</button>
|
||||
</div>
|
||||
{loading ? (
|
||||
'Sending magic link...'
|
||||
) : (
|
||||
<form onSubmit={handleLogin}>
|
||||
<label htmlFor="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
className="inputField"
|
||||
type="email"
|
||||
placeholder="Your email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<button className="button block" aria-live="polite">
|
||||
Send magic link
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -271,7 +267,7 @@ Let's create a new component for that called `Account.js`.
|
||||
import { useState, useEffect } from 'react'
|
||||
import { supabase } from './supabaseClient'
|
||||
|
||||
export default function Account({ session }) {
|
||||
const Account = ({ session }) => {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [username, setUsername] = useState(null)
|
||||
const [website, setWebsite] = useState(null)
|
||||
@@ -281,7 +277,7 @@ export default function Account({ session }) {
|
||||
getProfile()
|
||||
}, [session])
|
||||
|
||||
async function getProfile() {
|
||||
const getProfile = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const user = supabase.auth.user()
|
||||
@@ -308,7 +304,9 @@ export default function Account({ session }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProfile({ username, website, avatar_url }) {
|
||||
const updateProfile = async (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const user = supabase.auth.user()
|
||||
@@ -336,52 +334,47 @@ export default function Account({ session }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="form-widget">
|
||||
<div>
|
||||
<label htmlFor="email">Email</label>
|
||||
<input id="email" type="text" value={session.user.email} disabled />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="username">Name</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username || ''}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="website">Website</label>
|
||||
<input
|
||||
id="website"
|
||||
type="website"
|
||||
value={website || ''}
|
||||
onChange={(e) => setWebsite(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
className="button block primary"
|
||||
onClick={() => updateProfile({ username, website, avatar_url })}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Loading ...' : 'Update'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button className="button block" onClick={() => supabase.auth.signOut()}>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
<div aria-live="polite">
|
||||
{loading ? (
|
||||
'Saving ...'
|
||||
) : (
|
||||
<form onSubmit={updateProfile} className="form-widget">
|
||||
<div>Email: {session.user.email}</div>
|
||||
<div>
|
||||
<label htmlFor="username">Name</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username || ''}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="website">Website</label>
|
||||
<input
|
||||
id="website"
|
||||
type="url"
|
||||
value={website || ''}
|
||||
onChange={(e) => setWebsite(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button className="button block primary" disabled={loading}>
|
||||
Update profile
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
<button type="button" className="button block" onClick={() => supabase.auth.signOut()}>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Account
|
||||
```
|
||||
|
||||
|
||||
|
||||
### Launch!
|
||||
|
||||
Now that we have all the components in place, let's update `App.js`:
|
||||
@@ -393,7 +386,7 @@ import { supabase } from './supabaseClient'
|
||||
import Auth from './Auth'
|
||||
import Account from './Account'
|
||||
|
||||
export default function Home() {
|
||||
export default () => {
|
||||
const [session, setSession] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -420,7 +413,6 @@ npm start
|
||||
|
||||
And then open the browser to [localhost:3000](http://localhost:3000) and you should see the completed app.
|
||||
|
||||
|
||||

|
||||
|
||||
## Bonus: Profile photos
|
||||
@@ -434,8 +426,9 @@ Let's create an avatar for the user so that they can upload a profile photo. We
|
||||
```jsx title="src/Avatar.js"
|
||||
import { useEffect, useState } from 'react'
|
||||
import { supabase } from './supabaseClient'
|
||||
import VisuallyHidden from '@reach/visually-hidden'
|
||||
|
||||
export default function Avatar({ url, size, onUpload }) {
|
||||
export default ({ url, size, onUpload }) => {
|
||||
const [avatarUrl, setAvatarUrl] = useState(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
@@ -443,7 +436,7 @@ export default function Avatar({ url, size, onUpload }) {
|
||||
if (url) downloadImage(url)
|
||||
}, [url])
|
||||
|
||||
async function downloadImage(path) {
|
||||
const downloadImage = async (path) => {
|
||||
try {
|
||||
const { data, error } = await supabase.storage.from('avatars').download(path)
|
||||
if (error) {
|
||||
@@ -456,8 +449,7 @@ export default function Avatar({ url, size, onUpload }) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function uploadAvatar(event) {
|
||||
const uploadAvatar = async (event) => {
|
||||
try {
|
||||
setUploading(true)
|
||||
|
||||
@@ -470,9 +462,7 @@ export default function Avatar({ url, size, onUpload }) {
|
||||
const fileName = `${Math.random()}.${fileExt}`
|
||||
const filePath = `${fileName}`
|
||||
|
||||
let { error: uploadError } = await supabase.storage
|
||||
.from('avatars')
|
||||
.upload(filePath, file)
|
||||
let { error: uploadError } = await supabase.storage.from('avatars').upload(filePath, file)
|
||||
|
||||
if (uploadError) {
|
||||
throw uploadError
|
||||
@@ -487,44 +477,36 @@ export default function Avatar({ url, size, onUpload }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt="Avatar"
|
||||
className="avatar image"
|
||||
style={{ height: size, width: size }}
|
||||
/>
|
||||
) : (
|
||||
<div className="avatar no-image" style={{ height: size, width: size }} />
|
||||
)}
|
||||
<div style={{ width: size }}>
|
||||
<div style={{ width: size }} aria-live="polite">
|
||||
<img
|
||||
src={avatarUrl ? avatarUrl : `https://place-hold.it/${size}x${size}`}
|
||||
alt={avatarUrl ? 'Avatar' : 'No image'}
|
||||
className="avatar image"
|
||||
style={{ height: size, width: size }}
|
||||
/>
|
||||
{uploading ? "Uploading..." : (
|
||||
<label className="button primary block" htmlFor="single">
|
||||
{uploading ? 'Uploading ...' : 'Upload'}
|
||||
Upload an avatar
|
||||
</label>
|
||||
<input
|
||||
style={{
|
||||
visibility: 'hidden',
|
||||
position: 'absolute',
|
||||
}}
|
||||
type="file"
|
||||
id="single"
|
||||
accept="image/*"
|
||||
onChange={uploadAvatar}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</div>
|
||||
<VisuallyHidden>
|
||||
<input
|
||||
type="file"
|
||||
id="single"
|
||||
accept="image/*"
|
||||
onChange={uploadAvatar}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</VisuallyHidden>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### Add the new widget
|
||||
|
||||
And then we can add the widget to the Account page:
|
||||
|
||||
|
||||
```jsx title="src/Account.js"
|
||||
// Import the new component
|
||||
import Avatar from './Avatar'
|
||||
@@ -545,7 +527,6 @@ return (
|
||||
{/* ... */}
|
||||
</div>
|
||||
)
|
||||
|
||||
```
|
||||
|
||||
## Next steps
|
||||
|
||||
@@ -38,7 +38,7 @@ Note: For RedwoodJS apps, port will be 8910
|
||||
|
||||
### GitHub
|
||||
|
||||
If you get stuck at any point, take a look at [this repo](https://github.com/dthyresson/redwoodjs-supabase-quickstart).
|
||||
If you get stuck at any point, take a look at [this repo](https://github.com/redwoodjs/redwoodjs-supabase-quickstart).
|
||||
|
||||
<!-- ## Video demo
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ module.exports = {
|
||||
},
|
||||
algolia: {
|
||||
apiKey: '766d56f13dd1e82f43253559b7c86636',
|
||||
appId: 'supabase',
|
||||
indexName: 'supabase',
|
||||
},
|
||||
image: '/img/supabase-og-image.png', // used for meta tag, in particular og:image and twitter:image
|
||||
|
||||
Generated
+232
-12
@@ -11,6 +11,7 @@
|
||||
"@docusaurus/core": "^2.0.0-beta.6",
|
||||
"@docusaurus/preset-classic": "^2.0.0-beta.6",
|
||||
"@kiwicopple/prism-react-renderer": "https://github.com/kiwicopple/prism-react-renderer.git",
|
||||
"@octokit/core": "^3.5.1",
|
||||
"jsrsasign": "^10.4.1",
|
||||
"mermaid": "^8.13.8",
|
||||
"react": "^17.0.2",
|
||||
@@ -2619,6 +2620,100 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/auth-token": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-2.5.0.tgz",
|
||||
"integrity": "sha512-r5FVUJCOLl19AxiuZD2VRZ/ORjp/4IN98Of6YJoJOkY75CIBuYfmiNHGrDwXr+aLGG55igl9QrxX3hbiXlLb+g==",
|
||||
"dependencies": {
|
||||
"@octokit/types": "^6.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/core": {
|
||||
"version": "3.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/core/-/core-3.5.1.tgz",
|
||||
"integrity": "sha512-omncwpLVxMP+GLpLPgeGJBF6IWJFjXDS5flY5VbppePYX9XehevbDykRH9PdCdvqt9TS5AOTiDide7h0qrkHjw==",
|
||||
"dependencies": {
|
||||
"@octokit/auth-token": "^2.4.4",
|
||||
"@octokit/graphql": "^4.5.8",
|
||||
"@octokit/request": "^5.6.0",
|
||||
"@octokit/request-error": "^2.0.5",
|
||||
"@octokit/types": "^6.0.3",
|
||||
"before-after-hook": "^2.2.0",
|
||||
"universal-user-agent": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/endpoint": {
|
||||
"version": "6.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-6.0.12.tgz",
|
||||
"integrity": "sha512-lF3puPwkQWGfkMClXb4k/eUT/nZKQfxinRWJrdZaJO85Dqwo/G0yOC434Jr2ojwafWJMYqFGFa5ms4jJUgujdA==",
|
||||
"dependencies": {
|
||||
"@octokit/types": "^6.0.3",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"universal-user-agent": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/endpoint/node_modules/is-plain-object": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
||||
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/graphql": {
|
||||
"version": "4.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-4.8.0.tgz",
|
||||
"integrity": "sha512-0gv+qLSBLKF0z8TKaSKTsS39scVKF9dbMxJpj3U0vC7wjNWFuIpL/z76Qe2fiuCbDRcJSavkXsVtMS6/dtQQsg==",
|
||||
"dependencies": {
|
||||
"@octokit/request": "^5.6.0",
|
||||
"@octokit/types": "^6.0.3",
|
||||
"universal-user-agent": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/openapi-types": {
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-11.2.0.tgz",
|
||||
"integrity": "sha512-PBsVO+15KSlGmiI8QAzaqvsNlZlrDlyAJYcrXBCvVUxCp7VnXjkwPoFHgjEJXx3WF9BAwkA6nfCUA7i9sODzKA=="
|
||||
},
|
||||
"node_modules/@octokit/request": {
|
||||
"version": "5.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/request/-/request-5.6.3.tgz",
|
||||
"integrity": "sha512-bFJl0I1KVc9jYTe9tdGGpAMPy32dLBXXo1dS/YwSCTL/2nd9XeHsY616RE3HPXDVk+a+dBuzyz5YdlXwcDTr2A==",
|
||||
"dependencies": {
|
||||
"@octokit/endpoint": "^6.0.1",
|
||||
"@octokit/request-error": "^2.1.0",
|
||||
"@octokit/types": "^6.16.1",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"node-fetch": "^2.6.7",
|
||||
"universal-user-agent": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/request-error": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-2.1.0.tgz",
|
||||
"integrity": "sha512-1VIvgXxs9WHSjicsRwq8PlR2LR2x6DwsJAaFgzdi0JfJoGSO8mYI/cHJQ+9FbN21aa+DrgNLnwObmyeSC8Rmpg==",
|
||||
"dependencies": {
|
||||
"@octokit/types": "^6.0.3",
|
||||
"deprecation": "^2.0.0",
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/request/node_modules/is-plain-object": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
||||
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/types": {
|
||||
"version": "6.34.0",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.34.0.tgz",
|
||||
"integrity": "sha512-s1zLBjWhdEI2zwaoSgyOFoKSl109CUcVBCc7biPJ3aAf6LGLU6szDvi31JPU7bxfla2lqfhjbbg/5DdFNxOwHw==",
|
||||
"dependencies": {
|
||||
"@octokit/openapi-types": "^11.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@polka/url": {
|
||||
"version": "1.0.0-next.20",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.20.tgz",
|
||||
@@ -3670,6 +3765,11 @@
|
||||
"resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz",
|
||||
"integrity": "sha1-3DQxT05nkxgJP8dgJyUl+UvyXBY="
|
||||
},
|
||||
"node_modules/before-after-hook": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.2.tgz",
|
||||
"integrity": "sha512-3pZEU3NT5BFUo/AD5ERPWOgQOCZITni6iavr5AUw5AUwQjMlI0kzu5btnyD39AF0gUEsDPwJT+oY1ORBJijPjQ=="
|
||||
},
|
||||
"node_modules/big.js": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
|
||||
@@ -6265,6 +6365,11 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/deprecation": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz",
|
||||
"integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ=="
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz",
|
||||
@@ -11628,9 +11733,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prismjs": {
|
||||
"version": "1.25.0",
|
||||
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.25.0.tgz",
|
||||
"integrity": "sha512-WCjJHl1KEWbnkQom1+SzftbtXMKQoezOCYs5rECqMN+jP+apI7ftoflyqigqzopSO3hMhTEb0mFClA8lkolgEg=="
|
||||
"version": "1.27.0",
|
||||
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.27.0.tgz",
|
||||
"integrity": "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
@@ -14844,6 +14952,11 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/universal-user-agent": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.0.tgz",
|
||||
"integrity": "sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w=="
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
|
||||
@@ -15020,9 +15133,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/url-parse": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.3.tgz",
|
||||
"integrity": "sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ==",
|
||||
"version": "1.5.10",
|
||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
|
||||
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
|
||||
"dependencies": {
|
||||
"querystringify": "^2.1.1",
|
||||
"requires-port": "^1.0.0"
|
||||
@@ -18139,6 +18252,98 @@
|
||||
"fastq": "^1.6.0"
|
||||
}
|
||||
},
|
||||
"@octokit/auth-token": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-2.5.0.tgz",
|
||||
"integrity": "sha512-r5FVUJCOLl19AxiuZD2VRZ/ORjp/4IN98Of6YJoJOkY75CIBuYfmiNHGrDwXr+aLGG55igl9QrxX3hbiXlLb+g==",
|
||||
"requires": {
|
||||
"@octokit/types": "^6.0.3"
|
||||
}
|
||||
},
|
||||
"@octokit/core": {
|
||||
"version": "3.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/core/-/core-3.5.1.tgz",
|
||||
"integrity": "sha512-omncwpLVxMP+GLpLPgeGJBF6IWJFjXDS5flY5VbppePYX9XehevbDykRH9PdCdvqt9TS5AOTiDide7h0qrkHjw==",
|
||||
"requires": {
|
||||
"@octokit/auth-token": "^2.4.4",
|
||||
"@octokit/graphql": "^4.5.8",
|
||||
"@octokit/request": "^5.6.0",
|
||||
"@octokit/request-error": "^2.0.5",
|
||||
"@octokit/types": "^6.0.3",
|
||||
"before-after-hook": "^2.2.0",
|
||||
"universal-user-agent": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"@octokit/endpoint": {
|
||||
"version": "6.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-6.0.12.tgz",
|
||||
"integrity": "sha512-lF3puPwkQWGfkMClXb4k/eUT/nZKQfxinRWJrdZaJO85Dqwo/G0yOC434Jr2ojwafWJMYqFGFa5ms4jJUgujdA==",
|
||||
"requires": {
|
||||
"@octokit/types": "^6.0.3",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"universal-user-agent": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"is-plain-object": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
||||
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"@octokit/graphql": {
|
||||
"version": "4.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-4.8.0.tgz",
|
||||
"integrity": "sha512-0gv+qLSBLKF0z8TKaSKTsS39scVKF9dbMxJpj3U0vC7wjNWFuIpL/z76Qe2fiuCbDRcJSavkXsVtMS6/dtQQsg==",
|
||||
"requires": {
|
||||
"@octokit/request": "^5.6.0",
|
||||
"@octokit/types": "^6.0.3",
|
||||
"universal-user-agent": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"@octokit/openapi-types": {
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-11.2.0.tgz",
|
||||
"integrity": "sha512-PBsVO+15KSlGmiI8QAzaqvsNlZlrDlyAJYcrXBCvVUxCp7VnXjkwPoFHgjEJXx3WF9BAwkA6nfCUA7i9sODzKA=="
|
||||
},
|
||||
"@octokit/request": {
|
||||
"version": "5.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/request/-/request-5.6.3.tgz",
|
||||
"integrity": "sha512-bFJl0I1KVc9jYTe9tdGGpAMPy32dLBXXo1dS/YwSCTL/2nd9XeHsY616RE3HPXDVk+a+dBuzyz5YdlXwcDTr2A==",
|
||||
"requires": {
|
||||
"@octokit/endpoint": "^6.0.1",
|
||||
"@octokit/request-error": "^2.1.0",
|
||||
"@octokit/types": "^6.16.1",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"node-fetch": "^2.6.7",
|
||||
"universal-user-agent": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"is-plain-object": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
||||
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"@octokit/request-error": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-2.1.0.tgz",
|
||||
"integrity": "sha512-1VIvgXxs9WHSjicsRwq8PlR2LR2x6DwsJAaFgzdi0JfJoGSO8mYI/cHJQ+9FbN21aa+DrgNLnwObmyeSC8Rmpg==",
|
||||
"requires": {
|
||||
"@octokit/types": "^6.0.3",
|
||||
"deprecation": "^2.0.0",
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"@octokit/types": {
|
||||
"version": "6.34.0",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.34.0.tgz",
|
||||
"integrity": "sha512-s1zLBjWhdEI2zwaoSgyOFoKSl109CUcVBCc7biPJ3aAf6LGLU6szDvi31JPU7bxfla2lqfhjbbg/5DdFNxOwHw==",
|
||||
"requires": {
|
||||
"@octokit/openapi-types": "^11.2.0"
|
||||
}
|
||||
},
|
||||
"@polka/url": {
|
||||
"version": "1.0.0-next.20",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.20.tgz",
|
||||
@@ -18942,6 +19147,11 @@
|
||||
"resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz",
|
||||
"integrity": "sha1-3DQxT05nkxgJP8dgJyUl+UvyXBY="
|
||||
},
|
||||
"before-after-hook": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.2.tgz",
|
||||
"integrity": "sha512-3pZEU3NT5BFUo/AD5ERPWOgQOCZITni6iavr5AUw5AUwQjMlI0kzu5btnyD39AF0gUEsDPwJT+oY1ORBJijPjQ=="
|
||||
},
|
||||
"big.js": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
|
||||
@@ -20963,6 +21173,11 @@
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
|
||||
"integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak="
|
||||
},
|
||||
"deprecation": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz",
|
||||
"integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ=="
|
||||
},
|
||||
"destroy": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz",
|
||||
@@ -24919,9 +25134,9 @@
|
||||
"requires": {}
|
||||
},
|
||||
"prismjs": {
|
||||
"version": "1.25.0",
|
||||
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.25.0.tgz",
|
||||
"integrity": "sha512-WCjJHl1KEWbnkQom1+SzftbtXMKQoezOCYs5rECqMN+jP+apI7ftoflyqigqzopSO3hMhTEb0mFClA8lkolgEg=="
|
||||
"version": "1.27.0",
|
||||
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.27.0.tgz",
|
||||
"integrity": "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA=="
|
||||
},
|
||||
"process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
@@ -27381,6 +27596,11 @@
|
||||
"unist-util-is": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"universal-user-agent": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.0.tgz",
|
||||
"integrity": "sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w=="
|
||||
},
|
||||
"universalify": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
|
||||
@@ -27517,9 +27737,9 @@
|
||||
}
|
||||
},
|
||||
"url-parse": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.3.tgz",
|
||||
"integrity": "sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ==",
|
||||
"version": "1.5.10",
|
||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
|
||||
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
|
||||
"requires": {
|
||||
"querystringify": "^2.1.1",
|
||||
"requires-port": "^1.0.0"
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"@docusaurus/core": "^2.0.0-beta.6",
|
||||
"@docusaurus/preset-classic": "^2.0.0-beta.6",
|
||||
"@kiwicopple/prism-react-renderer": "https://github.com/kiwicopple/prism-react-renderer.git",
|
||||
"@octokit/core": "^3.5.1",
|
||||
"jsrsasign": "^10.4.1",
|
||||
"mermaid": "^8.13.8",
|
||||
"react": "^17.0.2",
|
||||
|
||||
+19
-20
@@ -57,6 +57,9 @@ module.exports = {
|
||||
'guides/with-angular',
|
||||
'guides/with-expo',
|
||||
'guides/with-flutter',
|
||||
'guides/with-ionic-angular',
|
||||
'guides/with-ionic-react',
|
||||
'guides/with-ionic-vue',
|
||||
'guides/with-nextjs',
|
||||
'guides/with-react',
|
||||
'guides/with-redwoodjs',
|
||||
@@ -166,26 +169,6 @@ module.exports = {
|
||||
collapsed: false,
|
||||
items: ['guides/storage'],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Integrations',
|
||||
collapsed: false,
|
||||
items: [
|
||||
'guides/integrations/appsmith',
|
||||
'guides/integrations/auth0',
|
||||
'guides/integrations/draftbit',
|
||||
'guides/integrations/pgmustard',
|
||||
'guides/integrations/prisma',
|
||||
'guides/integrations/snaplet',
|
||||
'guides/integrations/vercel',
|
||||
],
|
||||
},
|
||||
// {
|
||||
// type: 'category',
|
||||
// label: 'Storage',
|
||||
// collapsed: false,
|
||||
// items: ['guides/storage/storage-sample'],
|
||||
// },
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Platform',
|
||||
@@ -198,6 +181,21 @@ module.exports = {
|
||||
collapsed: false,
|
||||
items: ['guides/hosting/overview', 'guides/hosting/docker'],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Integrations',
|
||||
collapsed: false,
|
||||
items: [
|
||||
'guides/integrations/appsmith',
|
||||
'guides/integrations/auth0',
|
||||
'guides/integrations/draftbit',
|
||||
'guides/integrations/pgmustard',
|
||||
'guides/integrations/plasmic',
|
||||
'guides/integrations/prisma',
|
||||
'guides/integrations/snaplet',
|
||||
'guides/integrations/vercel',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'See Also',
|
||||
@@ -210,6 +208,7 @@ module.exports = {
|
||||
'company/terms',
|
||||
'company/privacy',
|
||||
'company/aup',
|
||||
'company/sla',
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
+61
-1
@@ -14,13 +14,73 @@ info:
|
||||
- [ ] Generating types directly from your database schema
|
||||
- [ ] Generating API and validation schemas from your database
|
||||
|
||||
## Getting started
|
||||
|
||||
```bash
|
||||
supabase help
|
||||
```
|
||||
|
||||
Follow the [Local Development guide](/docs/guides/local-development) for a quick overview of the CLI functionality.
|
||||
|
||||
## Installing the CLI
|
||||
|
||||
Installation instructions can be found [here](https://github.com/supabase/cli#install-the-cli).
|
||||
### macOS
|
||||
|
||||
Available via [Homebrew](https://brew.sh). To install:
|
||||
|
||||
```sh
|
||||
brew install supabase/tap/supabase
|
||||
```
|
||||
|
||||
To upgrade:
|
||||
|
||||
```sh
|
||||
brew upgrade supabase
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
Available via [Scoop](https://scoop.sh). To install:
|
||||
|
||||
```powershell
|
||||
scoop bucket add supabase https://github.com/supabase/scoop-bucket.git
|
||||
scoop install supabase
|
||||
```
|
||||
|
||||
To upgrade:
|
||||
|
||||
```powershell
|
||||
scoop update supabase
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
Available via [Homebrew](https://brew.sh) and Linux packages.
|
||||
|
||||
#### via Homebrew
|
||||
|
||||
To install:
|
||||
|
||||
```sh
|
||||
brew install supabase/tap/supabase
|
||||
```
|
||||
|
||||
To upgrade:
|
||||
|
||||
```sh
|
||||
brew upgrade supabase
|
||||
```
|
||||
|
||||
#### via Linux packages
|
||||
|
||||
Linux packages are provided in [Releases](https://github.com/supabase/cli/releases).
|
||||
To install, download the `.apk`/`.deb`/`.rpm` file depending on your package manager
|
||||
and run `sudo apk add --allow-untrusted <...>.apk`/`sudo dpkg -i <...>.deb`/`sudo rpm -i <...>.rpm` respectively.
|
||||
|
||||
## Support
|
||||
|
||||
Report issues to our [issue tracker](https://github.com/supabase/cli/issues).
|
||||
|
||||
definition: spec/combined.json
|
||||
libraries:
|
||||
- name: 'sh'
|
||||
|
||||
+25
-1
@@ -1213,6 +1213,14 @@ pages:
|
||||
.or():
|
||||
description: |
|
||||
Finds all rows satisfying at least one of the filters.
|
||||
notes: |
|
||||
- `.or()` expects you to use the raw [PostgREST syntax](https://postgrest.org/en/stable/api.html#horizontal-filtering-rows) for the filter names and values.
|
||||
|
||||
```dart
|
||||
.or('id.in.(6,7),arraycol.cs.{"a","b"}') // Use Postgres list () and 'in' for in_ filter. Array {} and 'cs' for contains.
|
||||
.or('id.in.(${mylist.join(',')}),arraycol.cs.{${mylistArray.join(',')}}') // You can insert a Dart list for list or array column.
|
||||
.or('id.in.(${mylist.join(',')}),rangecol.cs.(${mylistRange.join(',')}]') // You can insert a Dart list for list or range column.
|
||||
```
|
||||
examples:
|
||||
- name: With `select()`
|
||||
isSpotlight: true
|
||||
@@ -1248,6 +1256,16 @@ pages:
|
||||
.not():
|
||||
description: |
|
||||
Finds all rows which doesn't satisfy the filter.
|
||||
notes: |
|
||||
- `.not()` expects you to use the raw [PostgREST syntax](https://postgrest.org/en/stable/api.html#horizontal-filtering-rows) for the filter names and values.
|
||||
|
||||
```dart
|
||||
.not('name','eq','Paris')
|
||||
.not('arraycol','cs','{"a","b"}') // Use Postgres array {} for array column and 'cs' for contains.
|
||||
.not('rangecol','cs','(1,2]') // Use Postgres range syntax for range column.
|
||||
.not('id','in','(6,7)') // Use Postgres list () and 'in' for in_ filter.
|
||||
.not('id','in','(${mylist.join(',')})') // You can insert a Dart list array.
|
||||
```
|
||||
examples:
|
||||
- name: With `select()`
|
||||
isSpotlight: true
|
||||
@@ -2144,7 +2162,13 @@ pages:
|
||||
description: |
|
||||
Finds all rows whose `column` satisfies the filter.
|
||||
notes: |
|
||||
- `.filter()` expects you to use the raw [PostgREST syntax](https://postgrest.org/en/stable/api.html#horizontal-filtering-rows) for the filter values, so it should only be used as an escape hatch in case other filters don't work.
|
||||
- `.filter()` expects you to use the raw [PostgREST syntax](https://postgrest.org/en/stable/api.html#horizontal-filtering-rows) for the filter names and values, so it should only be used as an escape hatch in case other filters don't work.
|
||||
```dart
|
||||
.filter('arraycol','cs','{"a","b"}') // Use Postgres array {} and 'cs' for contains.
|
||||
.filter('rangecol','cs','(1,2]') // Use Postgres range syntax for range column.
|
||||
.filter('id','in','(6,7)') // Use Postgres list () and 'in' for in_ filter.
|
||||
.filter('id','cs','{${mylist.join(',')}}') // You can insert a Dart array list.
|
||||
```
|
||||
examples:
|
||||
- name: With `select()`
|
||||
isSpotlight: true
|
||||
|
||||
+108
-48
@@ -370,6 +370,9 @@ pages:
|
||||
res.status(200).json(allOnlineUsers);
|
||||
};
|
||||
```
|
||||
|
||||
For more advance type-support, check out [`postgrest-js-tools`](https://github.com/mzalevski/postgrest-js-tools).
|
||||
|
||||
|
||||
### Update types automatically with GitHub Actions
|
||||
|
||||
@@ -528,6 +531,9 @@ pages:
|
||||
```
|
||||
- name: Sign in with redirect.
|
||||
description: |
|
||||
Note that the `redirectTo` param is only relevant for OAuth logins, where the login flow is managed by
|
||||
the Auth server. If you are using email/phone logins you should set up your own redirects (within the email/sms template).
|
||||
|
||||
Sometimes you want to control where the user is redirected to after they are logged in. Supabase supports this for
|
||||
any URL path on your website (the base domain must be the same as the domain in your Auth settings).
|
||||
js: |
|
||||
@@ -676,7 +682,7 @@ pages:
|
||||
withCredentials: true,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': bearer, // Your own auth
|
||||
'X-Supabase-Auth': token, // Set the Supabase user
|
||||
}
|
||||
@@ -800,8 +806,7 @@ pages:
|
||||
js: |
|
||||
```js
|
||||
const { data: user, error } = await supabase.auth.api.deleteUser(
|
||||
'715ed5db-f090-4b8c-a067-640ecee36aa0',
|
||||
'YOUR_SERVICE_ROLE_KEY'
|
||||
'715ed5db-f090-4b8c-a067-640ecee36aa0'
|
||||
)
|
||||
```
|
||||
|
||||
@@ -892,55 +897,55 @@ pages:
|
||||
isSpotlight: true
|
||||
js: |
|
||||
```js
|
||||
const { data: user, error } = await supabase.auth.api.updateUserById({
|
||||
uid: '6aa5d0d4-2a9f-4483-b6c8-0cf4c6c98ac4',
|
||||
attributes: { email: 'new@email.com' } },
|
||||
})
|
||||
const { data: user, error } = await supabase.auth.api.updateUserById(
|
||||
'6aa5d0d4-2a9f-4483-b6c8-0cf4c6c98ac4',
|
||||
{ email: 'new@email.com' }
|
||||
)
|
||||
```
|
||||
- name: Updates a user's password.
|
||||
isSpotlight: true
|
||||
js: |
|
||||
```js
|
||||
const { data: user, error } = await supabase.auth.api.updateUserById({
|
||||
uid: '6aa5d0d4-2a9f-4483-b6c8-0cf4c6c98ac4',
|
||||
attributes: { password: 'new@email.com' } },
|
||||
})
|
||||
const { data: user, error } = await supabase.auth.api.updateUserById(
|
||||
'6aa5d0d4-2a9f-4483-b6c8-0cf4c6c98ac4',
|
||||
{ password: 'new_password' }
|
||||
)
|
||||
```
|
||||
- name: Updates a user's metadata.
|
||||
isSpotlight: true
|
||||
js: |
|
||||
```js
|
||||
const { data: user, error } = await supabase.auth.api.updateUserById({
|
||||
uid: '6aa5d0d4-2a9f-4483-b6c8-0cf4c6c98ac4',
|
||||
attributes: { user_metadata: { hello: 'world' } },
|
||||
})
|
||||
const { data: user, error } = await supabase.auth.api.updateUserById(
|
||||
'6aa5d0d4-2a9f-4483-b6c8-0cf4c6c98ac4',
|
||||
{ user_metadata: { hello: 'world' } }
|
||||
)
|
||||
```
|
||||
- name: Updates a user's app_metadata.
|
||||
isSpotlight: true
|
||||
js: |
|
||||
```js
|
||||
const { data: user, error } = await supabase.auth.api.updateUserById({
|
||||
uid: '6aa5d0d4-2a9f-4483-b6c8-0cf4c6c98ac4',
|
||||
attributes: { app_metadata: { plan: 'trial' } },
|
||||
})
|
||||
const { data: user, error } = await supabase.auth.api.updateUserById(
|
||||
'6aa5d0d4-2a9f-4483-b6c8-0cf4c6c98ac4',
|
||||
{ app_metadata: { plan: 'trial' } }
|
||||
)
|
||||
```
|
||||
- name: Confirms a user's email address.
|
||||
isSpotlight: true
|
||||
js: |
|
||||
```js
|
||||
const { data: user, error } = await supabase.auth.api.updateUserById({
|
||||
uid: '6aa5d0d4-2a9f-4483-b6c8-0cf4c6c98ac4',
|
||||
attributes: { email_confirm: true },
|
||||
})
|
||||
const { data: user, error } = await supabase.auth.api.updateUserById(
|
||||
'6aa5d0d4-2a9f-4483-b6c8-0cf4c6c98ac4',
|
||||
{ email_confirm: true }
|
||||
)
|
||||
```
|
||||
- name: Confirms a user's phone number.
|
||||
isSpotlight: true
|
||||
js: |
|
||||
```js
|
||||
const { data: user, error } = await supabase.auth.api.updateUserById({
|
||||
uid: '6aa5d0d4-2a9f-4483-b6c8-0cf4c6c98ac4',
|
||||
attributes: { phone_number: true },
|
||||
})
|
||||
const { data: user, error } = await supabase.auth.api.updateUserById(
|
||||
'6aa5d0d4-2a9f-4483-b6c8-0cf4c6c98ac4',
|
||||
{ phone_number: true }
|
||||
)
|
||||
```
|
||||
|
||||
select():
|
||||
@@ -1098,6 +1103,9 @@ pages:
|
||||
])
|
||||
```
|
||||
- name: Bulk create
|
||||
description: |
|
||||
When running a bulk create, the operation is handled in a single transaction. If any of the inserts fail, all other operations are
|
||||
rolled back.
|
||||
js: |
|
||||
```js
|
||||
const { data, error } = await supabase
|
||||
@@ -1882,6 +1890,14 @@ pages:
|
||||
|
||||
.or():
|
||||
$ref: '@supabase/postgrest-js."lib/PostgrestFilterBuilder".PostgrestFilterBuilder.or'
|
||||
notes: |
|
||||
- `.or()` expects you to use the raw [PostgREST syntax](https://postgrest.org/en/stable/api.html#horizontal-filtering-rows) for the filter names and values.
|
||||
|
||||
```js
|
||||
.or('id.in.(6,7), arraycol.cs.{"a","b"}') // Use Postgres list () for in filter. Array {} for array column and 'cs' for contains.
|
||||
.or(`id.in.(${arrList}),arraycol.cs.{${arr}}`) // You can insert a javascipt array for list or array on array column.
|
||||
.or(`id.in.(${arrList}),rangecol.cs.[${arrRange})`) // You can insert a javascipt array for list or range on a range column.
|
||||
```
|
||||
examples:
|
||||
- name: With `select()`
|
||||
isSpotlight: true
|
||||
@@ -1913,6 +1929,16 @@ pages:
|
||||
|
||||
.not():
|
||||
$ref: '@supabase/postgrest-js."lib/PostgrestFilterBuilder".PostgrestFilterBuilder.not'
|
||||
notes: |
|
||||
- `.not()` expects you to use the raw [PostgREST syntax](https://postgrest.org/en/stable/api.html#horizontal-filtering-rows) for the filter names and values.
|
||||
|
||||
```js
|
||||
.not('name','eq','Paris')
|
||||
.not('arraycol','cs','{"a","b"}') // Use Postgres array {} for array column and 'cs' for contains.
|
||||
.not('rangecol','cs','(1,2]') // Use Postgres range syntax for range column.
|
||||
.not('id','in','(6,7)') // Use Postgres list () for in filter.
|
||||
.not('id','in',`(${arr})`) // You can insert a javascript array.
|
||||
```
|
||||
examples:
|
||||
- name: With `select()`
|
||||
isSpotlight: true
|
||||
@@ -2357,6 +2383,16 @@ pages:
|
||||
|
||||
.contains():
|
||||
$ref: '@supabase/postgrest-js."lib/PostgrestFilterBuilder".PostgrestFilterBuilder.cs'
|
||||
notes: |
|
||||
- `.contains()` can work on array columns or range columns.
|
||||
It is very useful for finding rows where a tag array contains all the values in the filter array.
|
||||
|
||||
```js
|
||||
.contains('arraycol',["a","b"]) // You can use a javascript array for an array column
|
||||
.contains('arraycol','{"a","b"}') // You can use a string with Postgres array {} for array column.
|
||||
.contains('rangecol','(1,2]') // Use Postgres range syntax for range column.
|
||||
.contains('rangecol',`(${arr}]`) // You can insert an array into a string.
|
||||
```
|
||||
examples:
|
||||
- name: With `select()`
|
||||
isSpotlight: true
|
||||
@@ -2394,6 +2430,15 @@ pages:
|
||||
|
||||
.containedBy():
|
||||
$ref: '@supabase/postgrest-js."lib/PostgrestFilterBuilder".PostgrestFilterBuilder.cd'
|
||||
notes: |
|
||||
- `.containedBy()` can work on array columns or range columns.
|
||||
|
||||
```js
|
||||
.containedBy('arraycol',["a","b"]) // You can use a javascript array for an array column
|
||||
.containedBy('arraycol','{"a","b"}') // You can use a string with Postgres array {} for array column.
|
||||
.containedBy('rangecol','(1,2]') // Use Postgres range syntax for range column.
|
||||
.containedBy('rangecol',`(${arr}]`) // You can insert an array into a string.
|
||||
```
|
||||
examples:
|
||||
- name: With `select()`
|
||||
isSpotlight: true
|
||||
@@ -2439,7 +2484,7 @@ pages:
|
||||
const { data, error } = await supabase
|
||||
.from('countries')
|
||||
.select('name, id, population_range_millions')
|
||||
.rangeLt('population_range_millions', [150, 250])
|
||||
.rangeLt('population_range_millions', '[150, 250]')
|
||||
```
|
||||
- name: With `update()`
|
||||
js: |
|
||||
@@ -2447,7 +2492,7 @@ pages:
|
||||
const { data, error } = await supabase
|
||||
.from('countries')
|
||||
.update({ name: 'Mordor' })
|
||||
.rangeLt('population_range_millions', [150, 250])
|
||||
.rangeLt('population_range_millions', '[150, 250]')
|
||||
```
|
||||
- name: With `delete()`
|
||||
js: |
|
||||
@@ -2455,7 +2500,7 @@ pages:
|
||||
const { data, error } = await supabase
|
||||
.from('countries')
|
||||
.delete()
|
||||
.rangeLt('population_range_millions', [150, 250])
|
||||
.rangeLt('population_range_millions', '[150, 250]')
|
||||
```
|
||||
- name: With `rpc()`
|
||||
js: |
|
||||
@@ -2463,7 +2508,7 @@ pages:
|
||||
// Only valid if the Postgres function returns a table type.
|
||||
const { data, error } = await supabase
|
||||
.rpc('echo_all_countries')
|
||||
.rangeLt('population_range_millions', [150, 250])
|
||||
.rangeLt('population_range_millions', '[150, 250]')
|
||||
```
|
||||
|
||||
.rangeGt():
|
||||
@@ -2476,7 +2521,7 @@ pages:
|
||||
const { data, error } = await supabase
|
||||
.from('countries')
|
||||
.select('name, id, population_range_millions')
|
||||
.rangeGt('population_range_millions', [150, 250])
|
||||
.rangeGt('population_range_millions', '[150, 250]')
|
||||
```
|
||||
- name: With `update()`
|
||||
js: |
|
||||
@@ -2484,7 +2529,7 @@ pages:
|
||||
const { data, error } = await supabase
|
||||
.from('countries')
|
||||
.update({ name: 'Mordor' })
|
||||
.rangeGt('population_range_millions', [150, 250])
|
||||
.rangeGt('population_range_millions', '[150, 250]')
|
||||
```
|
||||
- name: With `delete()`
|
||||
js: |
|
||||
@@ -2492,7 +2537,7 @@ pages:
|
||||
const { data, error } = await supabase
|
||||
.from('countries')
|
||||
.delete()
|
||||
.rangeGt('population_range_millions', [150, 250])
|
||||
.rangeGt('population_range_millions', '[150, 250]')
|
||||
```
|
||||
- name: With `rpc()`
|
||||
js: |
|
||||
@@ -2500,7 +2545,7 @@ pages:
|
||||
// Only valid if the Postgres function returns a table type.
|
||||
const { data, error } = await supabase
|
||||
.rpc('echo_all_countries')
|
||||
.rangeGt('population_range_millions', [150, 250])
|
||||
.rangeGt('population_range_millions', '[150, 250]')
|
||||
```
|
||||
|
||||
.rangeGte():
|
||||
@@ -2513,7 +2558,7 @@ pages:
|
||||
const { data, error } = await supabase
|
||||
.from('countries')
|
||||
.select('name, id, population_range_millions')
|
||||
.rangeGte('population_range_millions', [150, 250])
|
||||
.rangeGte('population_range_millions', '[150, 250]')
|
||||
```
|
||||
- name: With `update()`
|
||||
js: |
|
||||
@@ -2521,7 +2566,7 @@ pages:
|
||||
const { data, error } = await supabase
|
||||
.from('countries')
|
||||
.update({ name: 'Mordor' })
|
||||
.rangeGte('population_range_millions', [150, 250])
|
||||
.rangeGte('population_range_millions', '[150, 250]')
|
||||
```
|
||||
- name: With `delete()`
|
||||
js: |
|
||||
@@ -2529,7 +2574,7 @@ pages:
|
||||
const { data, error } = await supabase
|
||||
.from('countries')
|
||||
.delete()
|
||||
.rangeGte('population_range_millions', [150, 250])
|
||||
.rangeGte('population_range_millions', '[150, 250]')
|
||||
```
|
||||
- name: With `rpc()`
|
||||
js: |
|
||||
@@ -2537,7 +2582,7 @@ pages:
|
||||
// Only valid if the Postgres function returns a table type.
|
||||
const { data, error } = await supabase
|
||||
.rpc('echo_all_countries')
|
||||
.rangeGte('population_range_millions', [150, 250])
|
||||
.rangeGte('population_range_millions', '[150, 250]')
|
||||
```
|
||||
|
||||
.rangeLte():
|
||||
@@ -2550,7 +2595,7 @@ pages:
|
||||
const { data, error } = await supabase
|
||||
.from('countries')
|
||||
.select('name, id, population_range_millions')
|
||||
.rangeLte('population_range_millions', [150, 250])
|
||||
.rangeLte('population_range_millions', '[150, 250]')
|
||||
```
|
||||
- name: With `update()`
|
||||
js: |
|
||||
@@ -2558,7 +2603,7 @@ pages:
|
||||
const { data, error } = await supabase
|
||||
.from('countries')
|
||||
.update({ name: 'Mordor' })
|
||||
.rangeLte('population_range_millions', [150, 250])
|
||||
.rangeLte('population_range_millions', '[150, 250]')
|
||||
```
|
||||
- name: With `delete()`
|
||||
js: |
|
||||
@@ -2566,7 +2611,7 @@ pages:
|
||||
const { data, error } = await supabase
|
||||
.from('countries')
|
||||
.delete()
|
||||
.rangeLte('population_range_millions', [150, 250])
|
||||
.rangeLte('population_range_millions', '[150, 250]')
|
||||
```
|
||||
- name: With `rpc()`
|
||||
js: |
|
||||
@@ -2574,7 +2619,7 @@ pages:
|
||||
// Only valid if the Postgres function returns a table type.
|
||||
const { data, error } = await supabase
|
||||
.rpc('echo_all_countries')
|
||||
.rangeLte('population_range_millions', [150, 250])
|
||||
.rangeLte('population_range_millions', '[150, 250]')
|
||||
```
|
||||
|
||||
.rangeAdjacent():
|
||||
@@ -2587,7 +2632,7 @@ pages:
|
||||
const { data, error } = await supabase
|
||||
.from('countries')
|
||||
.select('name, id, population_range_millions')
|
||||
.rangeAdjacent('population_range_millions', [70, 185])
|
||||
.rangeAdjacent('population_range_millions', '[70, 185]')
|
||||
```
|
||||
- name: With `update()`
|
||||
js: |
|
||||
@@ -2595,7 +2640,7 @@ pages:
|
||||
const { data, error } = await supabase
|
||||
.from('countries')
|
||||
.update({ name: 'Mordor' })
|
||||
.rangeAdjacent('population_range_millions', [70, 185])
|
||||
.rangeAdjacent('population_range_millions', '[70, 185]')
|
||||
```
|
||||
- name: With `delete()`
|
||||
js: |
|
||||
@@ -2603,7 +2648,7 @@ pages:
|
||||
const { data, error } = await supabase
|
||||
.from('countries')
|
||||
.delete()
|
||||
.rangeAdjacent('population_range_millions', [70, 185])
|
||||
.rangeAdjacent('population_range_millions', '[70, 185]')
|
||||
```
|
||||
- name: With `rpc()`
|
||||
js: |
|
||||
@@ -2611,11 +2656,20 @@ pages:
|
||||
// Only valid if the Postgres function returns a table type.
|
||||
const { data, error } = await supabase
|
||||
.rpc('echo_all_countries')
|
||||
.rangeAdjacent('population_range_millions', [70, 185])
|
||||
.rangeAdjacent('population_range_millions', '[70, 185]')
|
||||
```
|
||||
|
||||
.overlaps():
|
||||
$ref: '@supabase/postgrest-js."lib/PostgrestFilterBuilder".PostgrestFilterBuilder.ov'
|
||||
notes: |
|
||||
- `.overlaps()` can work on array columns or range columns.
|
||||
|
||||
```js
|
||||
.overlaps('arraycol',["a","b"]) // You can use a javascript array for an array column
|
||||
.overlaps('arraycol','{"a","b"}') // You can use a string with Postgres array {} for array column.
|
||||
.overlaps('rangecol','(1,2]') // Use Postgres range syntax for range column.
|
||||
.overlaps('rangecol',`(${arr}]`) // You can insert an array into a string.
|
||||
```
|
||||
examples:
|
||||
- name: With `select()`
|
||||
isSpotlight: true
|
||||
@@ -2713,7 +2767,13 @@ pages:
|
||||
.filter():
|
||||
$ref: '@supabase/postgrest-js."lib/PostgrestFilterBuilder".PostgrestFilterBuilder.filter'
|
||||
notes: |
|
||||
- `.filter()` expects you to use the raw [PostgREST syntax](https://postgrest.org/en/stable/api.html#horizontal-filtering-rows) for the filter values, so it should only be used as an escape hatch in case other filters don't work.
|
||||
- `.filter()` expects you to use the raw [PostgREST syntax](https://postgrest.org/en/stable/api.html#horizontal-filtering-rows) for the filter names and values, so it should only be used as an escape hatch in case other filters don't work.
|
||||
```js
|
||||
.filter('arraycol','cs','{"a","b"}') // Use Postgres array {} for array column and 'cs' for contains.
|
||||
.filter('rangecol','cs','(1,2]') // Use Postgres range syntax for range column.
|
||||
.filter('id','in','(6,7)') // Use Postgres list () for in filter.
|
||||
.filter('id','in',`(${arr})`) // You can insert a javascript array.
|
||||
```
|
||||
examples:
|
||||
- name: With `select()`
|
||||
isSpotlight: true
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import supabase from './repos/supabase.json'
|
||||
import realtime from './repos/realtime.json'
|
||||
// import marketplace from './repos/marketplace.json'
|
||||
import postgres from './repos/postgres.json'
|
||||
import doctestJs from './repos/doctest-js.json'
|
||||
import postgrestJs from './repos/postgrest-js.json'
|
||||
import postgresApi from './repos/pg-api.json'
|
||||
|
||||
const repos = [supabase, realtime, postgrestJs, postgres, doctestJs, postgresApi]
|
||||
|
||||
export { repos, postgrestJs }
|
||||
@@ -1,117 +0,0 @@
|
||||
{
|
||||
"id": 288653469,
|
||||
"node_id": "MDEwOlJlcG9zaXRvcnkyODg2NTM0Njk=",
|
||||
"name": ".github",
|
||||
"full_name": "supabase/.github",
|
||||
"private": false,
|
||||
"owner": {
|
||||
"login": "supabase",
|
||||
"id": 54469796,
|
||||
"node_id": "MDEyOk9yZ2FuaXphdGlvbjU0NDY5Nzk2",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/54469796?v=4",
|
||||
"gravatar_id": "",
|
||||
"url": "https://api.github.com/users/supabase",
|
||||
"html_url": "https://github.com/supabase",
|
||||
"followers_url": "https://api.github.com/users/supabase/followers",
|
||||
"following_url": "https://api.github.com/users/supabase/following{/other_user}",
|
||||
"gists_url": "https://api.github.com/users/supabase/gists{/gist_id}",
|
||||
"starred_url": "https://api.github.com/users/supabase/starred{/owner}{/repo}",
|
||||
"subscriptions_url": "https://api.github.com/users/supabase/subscriptions",
|
||||
"organizations_url": "https://api.github.com/users/supabase/orgs",
|
||||
"repos_url": "https://api.github.com/users/supabase/repos",
|
||||
"events_url": "https://api.github.com/users/supabase/events{/privacy}",
|
||||
"received_events_url": "https://api.github.com/users/supabase/received_events",
|
||||
"type": "Organization",
|
||||
"site_admin": false
|
||||
},
|
||||
"html_url": "https://github.com/supabase/.github",
|
||||
"description": "Org-wide default community health files & templates.",
|
||||
"fork": false,
|
||||
"url": "https://api.github.com/repos/supabase/.github",
|
||||
"forks_url": "https://api.github.com/repos/supabase/.github/forks",
|
||||
"keys_url": "https://api.github.com/repos/supabase/.github/keys{/key_id}",
|
||||
"collaborators_url": "https://api.github.com/repos/supabase/.github/collaborators{/collaborator}",
|
||||
"teams_url": "https://api.github.com/repos/supabase/.github/teams",
|
||||
"hooks_url": "https://api.github.com/repos/supabase/.github/hooks",
|
||||
"issue_events_url": "https://api.github.com/repos/supabase/.github/issues/events{/number}",
|
||||
"events_url": "https://api.github.com/repos/supabase/.github/events",
|
||||
"assignees_url": "https://api.github.com/repos/supabase/.github/assignees{/user}",
|
||||
"branches_url": "https://api.github.com/repos/supabase/.github/branches{/branch}",
|
||||
"tags_url": "https://api.github.com/repos/supabase/.github/tags",
|
||||
"blobs_url": "https://api.github.com/repos/supabase/.github/git/blobs{/sha}",
|
||||
"git_tags_url": "https://api.github.com/repos/supabase/.github/git/tags{/sha}",
|
||||
"git_refs_url": "https://api.github.com/repos/supabase/.github/git/refs{/sha}",
|
||||
"trees_url": "https://api.github.com/repos/supabase/.github/git/trees{/sha}",
|
||||
"statuses_url": "https://api.github.com/repos/supabase/.github/statuses/{sha}",
|
||||
"languages_url": "https://api.github.com/repos/supabase/.github/languages",
|
||||
"stargazers_url": "https://api.github.com/repos/supabase/.github/stargazers",
|
||||
"contributors_url": "https://api.github.com/repos/supabase/.github/contributors",
|
||||
"subscribers_url": "https://api.github.com/repos/supabase/.github/subscribers",
|
||||
"subscription_url": "https://api.github.com/repos/supabase/.github/subscription",
|
||||
"commits_url": "https://api.github.com/repos/supabase/.github/commits{/sha}",
|
||||
"git_commits_url": "https://api.github.com/repos/supabase/.github/git/commits{/sha}",
|
||||
"comments_url": "https://api.github.com/repos/supabase/.github/comments{/number}",
|
||||
"issue_comment_url": "https://api.github.com/repos/supabase/.github/issues/comments{/number}",
|
||||
"contents_url": "https://api.github.com/repos/supabase/.github/contents/{+path}",
|
||||
"compare_url": "https://api.github.com/repos/supabase/.github/compare/{base}...{head}",
|
||||
"merges_url": "https://api.github.com/repos/supabase/.github/merges",
|
||||
"archive_url": "https://api.github.com/repos/supabase/.github/{archive_format}{/ref}",
|
||||
"downloads_url": "https://api.github.com/repos/supabase/.github/downloads",
|
||||
"issues_url": "https://api.github.com/repos/supabase/.github/issues{/number}",
|
||||
"pulls_url": "https://api.github.com/repos/supabase/.github/pulls{/number}",
|
||||
"milestones_url": "https://api.github.com/repos/supabase/.github/milestones{/number}",
|
||||
"notifications_url": "https://api.github.com/repos/supabase/.github/notifications{?since,all,participating}",
|
||||
"labels_url": "https://api.github.com/repos/supabase/.github/labels{/name}",
|
||||
"releases_url": "https://api.github.com/repos/supabase/.github/releases{/id}",
|
||||
"deployments_url": "https://api.github.com/repos/supabase/.github/deployments",
|
||||
"created_at": "2020-08-19T06:35:23Z",
|
||||
"updated_at": "2020-12-04T06:26:42Z",
|
||||
"pushed_at": "2020-09-18T03:24:56Z",
|
||||
"git_url": "git://github.com/supabase/.github.git",
|
||||
"ssh_url": "git@github.com:supabase/.github.git",
|
||||
"clone_url": "https://github.com/supabase/.github.git",
|
||||
"svn_url": "https://github.com/supabase/.github",
|
||||
"homepage": "https://supabase.com/",
|
||||
"size": 10,
|
||||
"stargazers_count": 2,
|
||||
"watchers_count": 2,
|
||||
"language": null,
|
||||
"has_issues": true,
|
||||
"has_projects": true,
|
||||
"has_downloads": true,
|
||||
"has_wiki": true,
|
||||
"has_pages": false,
|
||||
"forks_count": 0,
|
||||
"mirror_url": null,
|
||||
"archived": false,
|
||||
"disabled": false,
|
||||
"open_issues_count": 0,
|
||||
"license": null,
|
||||
"forks": 0,
|
||||
"open_issues": 0,
|
||||
"watchers": 2,
|
||||
"default_branch": "main",
|
||||
"temp_clone_token": null,
|
||||
"organization": {
|
||||
"login": "supabase",
|
||||
"id": 54469796,
|
||||
"node_id": "MDEyOk9yZ2FuaXphdGlvbjU0NDY5Nzk2",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/54469796?v=4",
|
||||
"gravatar_id": "",
|
||||
"url": "https://api.github.com/users/supabase",
|
||||
"html_url": "https://github.com/supabase",
|
||||
"followers_url": "https://api.github.com/users/supabase/followers",
|
||||
"following_url": "https://api.github.com/users/supabase/following{/other_user}",
|
||||
"gists_url": "https://api.github.com/users/supabase/gists{/gist_id}",
|
||||
"starred_url": "https://api.github.com/users/supabase/starred{/owner}{/repo}",
|
||||
"subscriptions_url": "https://api.github.com/users/supabase/subscriptions",
|
||||
"organizations_url": "https://api.github.com/users/supabase/orgs",
|
||||
"repos_url": "https://api.github.com/users/supabase/repos",
|
||||
"events_url": "https://api.github.com/users/supabase/events{/privacy}",
|
||||
"received_events_url": "https://api.github.com/users/supabase/received_events",
|
||||
"type": "Organization",
|
||||
"site_admin": false
|
||||
},
|
||||
"network_count": 0,
|
||||
"subscribers_count": 2
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
{
|
||||
"id": 298483351,
|
||||
"node_id": "MDEwOlJlcG9zaXRvcnkyOTg0ODMzNTE=",
|
||||
"name": "benchmarks",
|
||||
"full_name": "supabase/benchmarks",
|
||||
"private": false,
|
||||
"owner": {
|
||||
"login": "supabase",
|
||||
"id": 54469796,
|
||||
"node_id": "MDEyOk9yZ2FuaXphdGlvbjU0NDY5Nzk2",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/54469796?v=4",
|
||||
"gravatar_id": "",
|
||||
"url": "https://api.github.com/users/supabase",
|
||||
"html_url": "https://github.com/supabase",
|
||||
"followers_url": "https://api.github.com/users/supabase/followers",
|
||||
"following_url": "https://api.github.com/users/supabase/following{/other_user}",
|
||||
"gists_url": "https://api.github.com/users/supabase/gists{/gist_id}",
|
||||
"starred_url": "https://api.github.com/users/supabase/starred{/owner}{/repo}",
|
||||
"subscriptions_url": "https://api.github.com/users/supabase/subscriptions",
|
||||
"organizations_url": "https://api.github.com/users/supabase/orgs",
|
||||
"repos_url": "https://api.github.com/users/supabase/repos",
|
||||
"events_url": "https://api.github.com/users/supabase/events{/privacy}",
|
||||
"received_events_url": "https://api.github.com/users/supabase/received_events",
|
||||
"type": "Organization",
|
||||
"site_admin": false
|
||||
},
|
||||
"html_url": "https://github.com/supabase/benchmarks",
|
||||
"description": "Infrastucture benchmarks",
|
||||
"fork": false,
|
||||
"url": "https://api.github.com/repos/supabase/benchmarks",
|
||||
"forks_url": "https://api.github.com/repos/supabase/benchmarks/forks",
|
||||
"keys_url": "https://api.github.com/repos/supabase/benchmarks/keys{/key_id}",
|
||||
"collaborators_url": "https://api.github.com/repos/supabase/benchmarks/collaborators{/collaborator}",
|
||||
"teams_url": "https://api.github.com/repos/supabase/benchmarks/teams",
|
||||
"hooks_url": "https://api.github.com/repos/supabase/benchmarks/hooks",
|
||||
"issue_events_url": "https://api.github.com/repos/supabase/benchmarks/issues/events{/number}",
|
||||
"events_url": "https://api.github.com/repos/supabase/benchmarks/events",
|
||||
"assignees_url": "https://api.github.com/repos/supabase/benchmarks/assignees{/user}",
|
||||
"branches_url": "https://api.github.com/repos/supabase/benchmarks/branches{/branch}",
|
||||
"tags_url": "https://api.github.com/repos/supabase/benchmarks/tags",
|
||||
"blobs_url": "https://api.github.com/repos/supabase/benchmarks/git/blobs{/sha}",
|
||||
"git_tags_url": "https://api.github.com/repos/supabase/benchmarks/git/tags{/sha}",
|
||||
"git_refs_url": "https://api.github.com/repos/supabase/benchmarks/git/refs{/sha}",
|
||||
"trees_url": "https://api.github.com/repos/supabase/benchmarks/git/trees{/sha}",
|
||||
"statuses_url": "https://api.github.com/repos/supabase/benchmarks/statuses/{sha}",
|
||||
"languages_url": "https://api.github.com/repos/supabase/benchmarks/languages",
|
||||
"stargazers_url": "https://api.github.com/repos/supabase/benchmarks/stargazers",
|
||||
"contributors_url": "https://api.github.com/repos/supabase/benchmarks/contributors",
|
||||
"subscribers_url": "https://api.github.com/repos/supabase/benchmarks/subscribers",
|
||||
"subscription_url": "https://api.github.com/repos/supabase/benchmarks/subscription",
|
||||
"commits_url": "https://api.github.com/repos/supabase/benchmarks/commits{/sha}",
|
||||
"git_commits_url": "https://api.github.com/repos/supabase/benchmarks/git/commits{/sha}",
|
||||
"comments_url": "https://api.github.com/repos/supabase/benchmarks/comments{/number}",
|
||||
"issue_comment_url": "https://api.github.com/repos/supabase/benchmarks/issues/comments{/number}",
|
||||
"contents_url": "https://api.github.com/repos/supabase/benchmarks/contents/{+path}",
|
||||
"compare_url": "https://api.github.com/repos/supabase/benchmarks/compare/{base}...{head}",
|
||||
"merges_url": "https://api.github.com/repos/supabase/benchmarks/merges",
|
||||
"archive_url": "https://api.github.com/repos/supabase/benchmarks/{archive_format}{/ref}",
|
||||
"downloads_url": "https://api.github.com/repos/supabase/benchmarks/downloads",
|
||||
"issues_url": "https://api.github.com/repos/supabase/benchmarks/issues{/number}",
|
||||
"pulls_url": "https://api.github.com/repos/supabase/benchmarks/pulls{/number}",
|
||||
"milestones_url": "https://api.github.com/repos/supabase/benchmarks/milestones{/number}",
|
||||
"notifications_url": "https://api.github.com/repos/supabase/benchmarks/notifications{?since,all,participating}",
|
||||
"labels_url": "https://api.github.com/repos/supabase/benchmarks/labels{/name}",
|
||||
"releases_url": "https://api.github.com/repos/supabase/benchmarks/releases{/id}",
|
||||
"deployments_url": "https://api.github.com/repos/supabase/benchmarks/deployments",
|
||||
"created_at": "2020-09-25T06:09:13Z",
|
||||
"updated_at": "2021-02-06T11:51:48Z",
|
||||
"pushed_at": "2021-02-06T11:51:44Z",
|
||||
"git_url": "git://github.com/supabase/benchmarks.git",
|
||||
"ssh_url": "git@github.com:supabase/benchmarks.git",
|
||||
"clone_url": "https://github.com/supabase/benchmarks.git",
|
||||
"svn_url": "https://github.com/supabase/benchmarks",
|
||||
"homepage": "https://supabase.com",
|
||||
"size": 9251,
|
||||
"stargazers_count": 13,
|
||||
"watchers_count": 13,
|
||||
"language": "JavaScript",
|
||||
"has_issues": true,
|
||||
"has_projects": true,
|
||||
"has_downloads": true,
|
||||
"has_wiki": true,
|
||||
"has_pages": false,
|
||||
"forks_count": 1,
|
||||
"mirror_url": null,
|
||||
"archived": false,
|
||||
"disabled": false,
|
||||
"open_issues_count": 6,
|
||||
"license": {
|
||||
"key": "mit",
|
||||
"name": "MIT License",
|
||||
"spdx_id": "MIT",
|
||||
"url": "https://api.github.com/licenses/mit",
|
||||
"node_id": "MDc6TGljZW5zZTEz"
|
||||
},
|
||||
"forks": 1,
|
||||
"open_issues": 6,
|
||||
"watchers": 13,
|
||||
"default_branch": "master",
|
||||
"temp_clone_token": null,
|
||||
"organization": {
|
||||
"login": "supabase",
|
||||
"id": 54469796,
|
||||
"node_id": "MDEyOk9yZ2FuaXphdGlvbjU0NDY5Nzk2",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/54469796?v=4",
|
||||
"gravatar_id": "",
|
||||
"url": "https://api.github.com/users/supabase",
|
||||
"html_url": "https://github.com/supabase",
|
||||
"followers_url": "https://api.github.com/users/supabase/followers",
|
||||
"following_url": "https://api.github.com/users/supabase/following{/other_user}",
|
||||
"gists_url": "https://api.github.com/users/supabase/gists{/gist_id}",
|
||||
"starred_url": "https://api.github.com/users/supabase/starred{/owner}{/repo}",
|
||||
"subscriptions_url": "https://api.github.com/users/supabase/subscriptions",
|
||||
"organizations_url": "https://api.github.com/users/supabase/orgs",
|
||||
"repos_url": "https://api.github.com/users/supabase/repos",
|
||||
"events_url": "https://api.github.com/users/supabase/events{/privacy}",
|
||||
"received_events_url": "https://api.github.com/users/supabase/received_events",
|
||||
"type": "Organization",
|
||||
"site_admin": false
|
||||
},
|
||||
"network_count": 1,
|
||||
"subscribers_count": 6
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
{
|
||||
"id": 314160187,
|
||||
"node_id": "MDEwOlJlcG9zaXRvcnkzMTQxNjAxODc=",
|
||||
"name": "cli",
|
||||
"full_name": "supabase/cli",
|
||||
"private": false,
|
||||
"owner": {
|
||||
"login": "supabase",
|
||||
"id": 54469796,
|
||||
"node_id": "MDEyOk9yZ2FuaXphdGlvbjU0NDY5Nzk2",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/54469796?v=4",
|
||||
"gravatar_id": "",
|
||||
"url": "https://api.github.com/users/supabase",
|
||||
"html_url": "https://github.com/supabase",
|
||||
"followers_url": "https://api.github.com/users/supabase/followers",
|
||||
"following_url": "https://api.github.com/users/supabase/following{/other_user}",
|
||||
"gists_url": "https://api.github.com/users/supabase/gists{/gist_id}",
|
||||
"starred_url": "https://api.github.com/users/supabase/starred{/owner}{/repo}",
|
||||
"subscriptions_url": "https://api.github.com/users/supabase/subscriptions",
|
||||
"organizations_url": "https://api.github.com/users/supabase/orgs",
|
||||
"repos_url": "https://api.github.com/users/supabase/repos",
|
||||
"events_url": "https://api.github.com/users/supabase/events{/privacy}",
|
||||
"received_events_url": "https://api.github.com/users/supabase/received_events",
|
||||
"type": "Organization",
|
||||
"site_admin": false
|
||||
},
|
||||
"html_url": "https://github.com/supabase/cli",
|
||||
"description": "Developer tools and helpers.",
|
||||
"fork": false,
|
||||
"url": "https://api.github.com/repos/supabase/cli",
|
||||
"forks_url": "https://api.github.com/repos/supabase/cli/forks",
|
||||
"keys_url": "https://api.github.com/repos/supabase/cli/keys{/key_id}",
|
||||
"collaborators_url": "https://api.github.com/repos/supabase/cli/collaborators{/collaborator}",
|
||||
"teams_url": "https://api.github.com/repos/supabase/cli/teams",
|
||||
"hooks_url": "https://api.github.com/repos/supabase/cli/hooks",
|
||||
"issue_events_url": "https://api.github.com/repos/supabase/cli/issues/events{/number}",
|
||||
"events_url": "https://api.github.com/repos/supabase/cli/events",
|
||||
"assignees_url": "https://api.github.com/repos/supabase/cli/assignees{/user}",
|
||||
"branches_url": "https://api.github.com/repos/supabase/cli/branches{/branch}",
|
||||
"tags_url": "https://api.github.com/repos/supabase/cli/tags",
|
||||
"blobs_url": "https://api.github.com/repos/supabase/cli/git/blobs{/sha}",
|
||||
"git_tags_url": "https://api.github.com/repos/supabase/cli/git/tags{/sha}",
|
||||
"git_refs_url": "https://api.github.com/repos/supabase/cli/git/refs{/sha}",
|
||||
"trees_url": "https://api.github.com/repos/supabase/cli/git/trees{/sha}",
|
||||
"statuses_url": "https://api.github.com/repos/supabase/cli/statuses/{sha}",
|
||||
"languages_url": "https://api.github.com/repos/supabase/cli/languages",
|
||||
"stargazers_url": "https://api.github.com/repos/supabase/cli/stargazers",
|
||||
"contributors_url": "https://api.github.com/repos/supabase/cli/contributors",
|
||||
"subscribers_url": "https://api.github.com/repos/supabase/cli/subscribers",
|
||||
"subscription_url": "https://api.github.com/repos/supabase/cli/subscription",
|
||||
"commits_url": "https://api.github.com/repos/supabase/cli/commits{/sha}",
|
||||
"git_commits_url": "https://api.github.com/repos/supabase/cli/git/commits{/sha}",
|
||||
"comments_url": "https://api.github.com/repos/supabase/cli/comments{/number}",
|
||||
"issue_comment_url": "https://api.github.com/repos/supabase/cli/issues/comments{/number}",
|
||||
"contents_url": "https://api.github.com/repos/supabase/cli/contents/{+path}",
|
||||
"compare_url": "https://api.github.com/repos/supabase/cli/compare/{base}...{head}",
|
||||
"merges_url": "https://api.github.com/repos/supabase/cli/merges",
|
||||
"archive_url": "https://api.github.com/repos/supabase/cli/{archive_format}{/ref}",
|
||||
"downloads_url": "https://api.github.com/repos/supabase/cli/downloads",
|
||||
"issues_url": "https://api.github.com/repos/supabase/cli/issues{/number}",
|
||||
"pulls_url": "https://api.github.com/repos/supabase/cli/pulls{/number}",
|
||||
"milestones_url": "https://api.github.com/repos/supabase/cli/milestones{/number}",
|
||||
"notifications_url": "https://api.github.com/repos/supabase/cli/notifications{?since,all,participating}",
|
||||
"labels_url": "https://api.github.com/repos/supabase/cli/labels{/name}",
|
||||
"releases_url": "https://api.github.com/repos/supabase/cli/releases{/id}",
|
||||
"deployments_url": "https://api.github.com/repos/supabase/cli/deployments",
|
||||
"created_at": "2020-11-19T06:46:55Z",
|
||||
"updated_at": "2021-02-14T08:01:54Z",
|
||||
"pushed_at": "2021-02-14T08:04:07Z",
|
||||
"git_url": "git://github.com/supabase/cli.git",
|
||||
"ssh_url": "git@github.com:supabase/cli.git",
|
||||
"clone_url": "https://github.com/supabase/cli.git",
|
||||
"svn_url": "https://github.com/supabase/cli",
|
||||
"homepage": "https://supabase.com",
|
||||
"size": 131,
|
||||
"stargazers_count": 7,
|
||||
"watchers_count": 7,
|
||||
"language": "TypeScript",
|
||||
"has_issues": true,
|
||||
"has_projects": true,
|
||||
"has_downloads": true,
|
||||
"has_wiki": true,
|
||||
"has_pages": false,
|
||||
"forks_count": 1,
|
||||
"mirror_url": null,
|
||||
"archived": false,
|
||||
"disabled": false,
|
||||
"open_issues_count": 2,
|
||||
"license": {
|
||||
"key": "mit",
|
||||
"name": "MIT License",
|
||||
"spdx_id": "MIT",
|
||||
"url": "https://api.github.com/licenses/mit",
|
||||
"node_id": "MDc6TGljZW5zZTEz"
|
||||
},
|
||||
"forks": 1,
|
||||
"open_issues": 2,
|
||||
"watchers": 7,
|
||||
"default_branch": "main",
|
||||
"temp_clone_token": null,
|
||||
"organization": {
|
||||
"login": "supabase",
|
||||
"id": 54469796,
|
||||
"node_id": "MDEyOk9yZ2FuaXphdGlvbjU0NDY5Nzk2",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/54469796?v=4",
|
||||
"gravatar_id": "",
|
||||
"url": "https://api.github.com/users/supabase",
|
||||
"html_url": "https://github.com/supabase",
|
||||
"followers_url": "https://api.github.com/users/supabase/followers",
|
||||
"following_url": "https://api.github.com/users/supabase/following{/other_user}",
|
||||
"gists_url": "https://api.github.com/users/supabase/gists{/gist_id}",
|
||||
"starred_url": "https://api.github.com/users/supabase/starred{/owner}{/repo}",
|
||||
"subscriptions_url": "https://api.github.com/users/supabase/subscriptions",
|
||||
"organizations_url": "https://api.github.com/users/supabase/orgs",
|
||||
"repos_url": "https://api.github.com/users/supabase/repos",
|
||||
"events_url": "https://api.github.com/users/supabase/events{/privacy}",
|
||||
"received_events_url": "https://api.github.com/users/supabase/received_events",
|
||||
"type": "Organization",
|
||||
"site_admin": false
|
||||
},
|
||||
"network_count": 1,
|
||||
"subscribers_count": 3
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
{
|
||||
"id": 236905403,
|
||||
"node_id": "MDEwOlJlcG9zaXRvcnkyMzY5MDU0MDM=",
|
||||
"name": "doctest-js",
|
||||
"full_name": "supabase/doctest-js",
|
||||
"private": false,
|
||||
"owner": {
|
||||
"login": "supabase",
|
||||
"id": 54469796,
|
||||
"node_id": "MDEyOk9yZ2FuaXphdGlvbjU0NDY5Nzk2",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/54469796?v=4",
|
||||
"gravatar_id": "",
|
||||
"url": "https://api.github.com/users/supabase",
|
||||
"html_url": "https://github.com/supabase",
|
||||
"followers_url": "https://api.github.com/users/supabase/followers",
|
||||
"following_url": "https://api.github.com/users/supabase/following{/other_user}",
|
||||
"gists_url": "https://api.github.com/users/supabase/gists{/gist_id}",
|
||||
"starred_url": "https://api.github.com/users/supabase/starred{/owner}{/repo}",
|
||||
"subscriptions_url": "https://api.github.com/users/supabase/subscriptions",
|
||||
"organizations_url": "https://api.github.com/users/supabase/orgs",
|
||||
"repos_url": "https://api.github.com/users/supabase/repos",
|
||||
"events_url": "https://api.github.com/users/supabase/events{/privacy}",
|
||||
"received_events_url": "https://api.github.com/users/supabase/received_events",
|
||||
"type": "Organization",
|
||||
"site_admin": false
|
||||
},
|
||||
"html_url": "https://github.com/supabase/doctest-js",
|
||||
"description": "Run JSDoc style doc examples as tests within your test suite",
|
||||
"fork": false,
|
||||
"url": "https://api.github.com/repos/supabase/doctest-js",
|
||||
"forks_url": "https://api.github.com/repos/supabase/doctest-js/forks",
|
||||
"keys_url": "https://api.github.com/repos/supabase/doctest-js/keys{/key_id}",
|
||||
"collaborators_url": "https://api.github.com/repos/supabase/doctest-js/collaborators{/collaborator}",
|
||||
"teams_url": "https://api.github.com/repos/supabase/doctest-js/teams",
|
||||
"hooks_url": "https://api.github.com/repos/supabase/doctest-js/hooks",
|
||||
"issue_events_url": "https://api.github.com/repos/supabase/doctest-js/issues/events{/number}",
|
||||
"events_url": "https://api.github.com/repos/supabase/doctest-js/events",
|
||||
"assignees_url": "https://api.github.com/repos/supabase/doctest-js/assignees{/user}",
|
||||
"branches_url": "https://api.github.com/repos/supabase/doctest-js/branches{/branch}",
|
||||
"tags_url": "https://api.github.com/repos/supabase/doctest-js/tags",
|
||||
"blobs_url": "https://api.github.com/repos/supabase/doctest-js/git/blobs{/sha}",
|
||||
"git_tags_url": "https://api.github.com/repos/supabase/doctest-js/git/tags{/sha}",
|
||||
"git_refs_url": "https://api.github.com/repos/supabase/doctest-js/git/refs{/sha}",
|
||||
"trees_url": "https://api.github.com/repos/supabase/doctest-js/git/trees{/sha}",
|
||||
"statuses_url": "https://api.github.com/repos/supabase/doctest-js/statuses/{sha}",
|
||||
"languages_url": "https://api.github.com/repos/supabase/doctest-js/languages",
|
||||
"stargazers_url": "https://api.github.com/repos/supabase/doctest-js/stargazers",
|
||||
"contributors_url": "https://api.github.com/repos/supabase/doctest-js/contributors",
|
||||
"subscribers_url": "https://api.github.com/repos/supabase/doctest-js/subscribers",
|
||||
"subscription_url": "https://api.github.com/repos/supabase/doctest-js/subscription",
|
||||
"commits_url": "https://api.github.com/repos/supabase/doctest-js/commits{/sha}",
|
||||
"git_commits_url": "https://api.github.com/repos/supabase/doctest-js/git/commits{/sha}",
|
||||
"comments_url": "https://api.github.com/repos/supabase/doctest-js/comments{/number}",
|
||||
"issue_comment_url": "https://api.github.com/repos/supabase/doctest-js/issues/comments{/number}",
|
||||
"contents_url": "https://api.github.com/repos/supabase/doctest-js/contents/{+path}",
|
||||
"compare_url": "https://api.github.com/repos/supabase/doctest-js/compare/{base}...{head}",
|
||||
"merges_url": "https://api.github.com/repos/supabase/doctest-js/merges",
|
||||
"archive_url": "https://api.github.com/repos/supabase/doctest-js/{archive_format}{/ref}",
|
||||
"downloads_url": "https://api.github.com/repos/supabase/doctest-js/downloads",
|
||||
"issues_url": "https://api.github.com/repos/supabase/doctest-js/issues{/number}",
|
||||
"pulls_url": "https://api.github.com/repos/supabase/doctest-js/pulls{/number}",
|
||||
"milestones_url": "https://api.github.com/repos/supabase/doctest-js/milestones{/number}",
|
||||
"notifications_url": "https://api.github.com/repos/supabase/doctest-js/notifications{?since,all,participating}",
|
||||
"labels_url": "https://api.github.com/repos/supabase/doctest-js/labels{/name}",
|
||||
"releases_url": "https://api.github.com/repos/supabase/doctest-js/releases{/id}",
|
||||
"deployments_url": "https://api.github.com/repos/supabase/doctest-js/deployments",
|
||||
"created_at": "2020-01-29T04:51:25Z",
|
||||
"updated_at": "2021-02-14T08:46:07Z",
|
||||
"pushed_at": "2020-12-22T16:32:15Z",
|
||||
"git_url": "git://github.com/supabase/doctest-js.git",
|
||||
"ssh_url": "git@github.com:supabase/doctest-js.git",
|
||||
"clone_url": "https://github.com/supabase/doctest-js.git",
|
||||
"svn_url": "https://github.com/supabase/doctest-js",
|
||||
"homepage": "",
|
||||
"size": 1623,
|
||||
"stargazers_count": 52,
|
||||
"watchers_count": 52,
|
||||
"language": "JavaScript",
|
||||
"has_issues": true,
|
||||
"has_projects": true,
|
||||
"has_downloads": true,
|
||||
"has_wiki": false,
|
||||
"has_pages": false,
|
||||
"forks_count": 2,
|
||||
"mirror_url": null,
|
||||
"archived": false,
|
||||
"disabled": false,
|
||||
"open_issues_count": 2,
|
||||
"license": {
|
||||
"key": "mit",
|
||||
"name": "MIT License",
|
||||
"spdx_id": "MIT",
|
||||
"url": "https://api.github.com/licenses/mit",
|
||||
"node_id": "MDc6TGljZW5zZTEz"
|
||||
},
|
||||
"forks": 2,
|
||||
"open_issues": 2,
|
||||
"watchers": 52,
|
||||
"default_branch": "master",
|
||||
"temp_clone_token": null,
|
||||
"organization": {
|
||||
"login": "supabase",
|
||||
"id": 54469796,
|
||||
"node_id": "MDEyOk9yZ2FuaXphdGlvbjU0NDY5Nzk2",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/54469796?v=4",
|
||||
"gravatar_id": "",
|
||||
"url": "https://api.github.com/users/supabase",
|
||||
"html_url": "https://github.com/supabase",
|
||||
"followers_url": "https://api.github.com/users/supabase/followers",
|
||||
"following_url": "https://api.github.com/users/supabase/following{/other_user}",
|
||||
"gists_url": "https://api.github.com/users/supabase/gists{/gist_id}",
|
||||
"starred_url": "https://api.github.com/users/supabase/starred{/owner}{/repo}",
|
||||
"subscriptions_url": "https://api.github.com/users/supabase/subscriptions",
|
||||
"organizations_url": "https://api.github.com/users/supabase/orgs",
|
||||
"repos_url": "https://api.github.com/users/supabase/repos",
|
||||
"events_url": "https://api.github.com/users/supabase/events{/privacy}",
|
||||
"received_events_url": "https://api.github.com/users/supabase/received_events",
|
||||
"type": "Organization",
|
||||
"site_admin": false
|
||||
},
|
||||
"network_count": 2,
|
||||
"subscribers_count": 3
|
||||
}
|
||||
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user