Merge pull request #9208 from supabase/master

Prod deploy Studio
This commit is contained in:
Joshen Lim authored and GitHub committed 2022-09-29 15:08:49 +08:00
commit d45bb73031
70 files changed
+5966 -52975

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
node_modules/
.vercel
.next
.env.local
.env.production
.env.dev
.env.*
+1
View File
@@ -225,6 +225,7 @@ Our approach for client libraries is modular. Each sub-library is a standalone i
- [Portuguese (Brazilian) / Português Brasileiro](/i18n/README.pt-br.md)
- [Romanian / Română](/i18n/README.ro.md)
- [Russian / Pусский](/i18n/README.ru.md)
- [Serbian / Srpski](/i18n/README.sr.md)
- [Sinhala / සිංහල](/i18n/README.si.md)
- [Spanish / Español](/i18n/README.es.md)
- [Simplified Chinese / 简体中文](/i18n/README.zh-cn.md)
@@ -26,7 +26,7 @@ Important notes:
- Since the generator uses JSON API, there is no way to determine if a column is an Array. It will generate array types as `string`, even though Supabase handles this automatically and returns arrays.
You can fix this manually in the files by changing the type, e.g. `names: string` -> `names: string[]`
- The types won't automatically stay in sync with your database, so make sure to regenerate your types after your make changes to your database.
- The types won't automatically stay in sync with your database, so make sure to regenerate your types after you make changes to your database.
After you have generated your types, you can use them in your TypeScript projects:
@@ -16,7 +16,7 @@ Azure OAuth consists of four broad steps:
- Create an application under Azure Active Directory.
- Obtain a `Application (client) ID` with “Sign In with Azure” capabilities. This will be used as the `client id`.
- Create a `Secret ID` with “Sign In with Azure” capabilities. The value of the secret will be used as the `client secret`.
- Whitelist the callback url of your application.
- Add the callback url of your application to the allowlist.
## Steps
@@ -0,0 +1,113 @@
---
id: auth-captcha
title: 'Enable Captcha Protection'
description: Add Captcha Protection to your Supabase project
---
import Tabs from '@theme/Tabs'
import TabItem from '@theme/TabItem'
# hCaptcha guide
Supabase provides you with the option of adding captcha to your sign-in, sign-up, and password reset forms. This keeps your website safe from bots and malicious scripts. Supabase authentication has support for [hCaptcha](https://www.hcaptcha.com/).
### Sign up for hCaptcha
Go to the [hCaptcha](https://www.hcaptcha.com/) website and sign up for an account. On the welcome page, copy the **Sitekey** and **Secret key**.
If you have already signed up and didn't copy this information from the welcome page, you can get the **Secret key** from the settings page.
![site_secret_settings.png](/img/guides/auth-captcha/site_secret_settings.png)
The **Sitekey** can be found in the **Settings** of the active site you created.
![sites_dashboard.png](/img/guides/auth-captcha/sites_dashboard.png)
In the Settings page, look for the **Sitekey** section and copy the key.
![sitekey_settings.png](/img/guides/auth-captcha/sitekey_settings.png)
### Enable hCaptcha protection for your Supabase project
Navigate to the **[Authentication](https://app.supabase.com/project/_/auth/settings)** page in the Supabase Dashboard and find the **Enable hCaptcha protection** toggle under the **Security and Protection** section.
![supabase_auth_general_settings.png](/img/guides/auth-captcha/supabase_auth_general_settings.png)
Enter your hCaptcha **Secret key** and click **Save**.
### Add the hCaptcha frontend component
The frontend requires some changes to provide the captcha on-screen for the user. This example uses React and the hCaptcha React component, but hCaptcha can be used with any JavaScript framework.
Install `@hcaptcha/react-hcaptcha` in your project as a dependency.
```bash
npm install @hcaptcha/react-hcaptcha
```
Now import the `HCaptcha` component from the `@hcaptcha/react-hcaptcha` library.
```javascript
import HCaptcha from '@hcaptcha/react-hcaptcha'
```
Let’s create a empty state to store our `captchaToken`
```jsx
const [captchaToken, setCaptchaToken] = useState()
```
Now lets add the HCaptcha component to the JSX section of our code
```html
<HCaptcha />
```
We will pass it the sitekey we copied from the hCaptcha website as a property along with a onVerify property which takes a callback function. This callback function will have a token as one of its properties. Let's set the token in the state using `setCaptchaToken`
```jsx
<HCaptcha
sitekey="your-sitekey"
  onVerify={(token) => { setCaptchaToken(token) }
/>
```
Now lets use the captcha token we receive in our Supabase signUp function.
```jsx
await supabase.auth.signUp({
email,
password,
options: { captchaToken },
})
```
We will also need to reset the captcha challenge after we have made a call to the function above.
Create a ref to use on our HCaptcha component.
```jsx
const captcha = useRef()
```
Let's add a ref attribute on the `HCaptcha` component and assign the `captcha` constant to it.
```jsx
<HCaptcha
ref={captcha}
sitekey="your-sitekey"
onVerify={(token) => {
setCaptchaToken(token)
}}
/>
```
Reset the `captcha` after the signUp function is called using the following code:
```jsx
captcha.current.resetCaptcha()
```
In order to test that this works locally we will need to use something like [ngrok](https://ngrok.com/) or add an entry to your hosts file. You can read more about this in the [hCaptcha docs](https://docs.hcaptcha.com/#local-development).
Run the application and you should now be provided with a captcha challenge.
@@ -13,18 +13,12 @@ Using [npm](https://npmjs.org):
```sh
npm install @supabase/auth-helpers-sveltekit
# Main component for Svelte based frameworks (optional but recommended)
npm install @supabase/auth-helpers-svelte
```
Using [yarn](https://yarnpkg.com/):
```sh
yarn add @supabase/auth-helpers-sveltekit
# Main component for Svelte based frameworks (optional but recommended)
yarn add @supabase/auth-helpers-svelte
```
This library supports the following tooling versions:
@@ -39,34 +33,484 @@ Set up the fillowing env vars. For local development you can set them in a `.env
```bash
# Find these in your Supabase project settings > API
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key
PUBLIC_SUPABASE_URL=https://your-project.supabase.co
PUBLIC_SUPABASE_ANON_KEY=your-anon-key
```
### SupabaseClient and SupaAuthHelper component setup
### SupabaseClient setup
We will start off by creating a `db.ts` file inside of our `src/lib` directory. Now lets instantiate our `supabaseClient` by using our `createSupabaseClient` function from the `@supabase/auth-helpers-sveltekit` library.
We will start off by creating a `db.ts` file inside of our `src/lib` directory. Now lets instantiate our `supabaseClient`.
```ts
// src/lib/db.ts
import { createSupabaseClient } from '@supabase/auth-helpers-sveltekit'
import { createClient } from '@supabase/supabase-js';
import { setupSupabaseHelpers } from '@supabase/auth-helpers-sveltekit';
import { dev } from '$app/environment';
import { env } from '$env/dynamic/public';
// or use the static env
// import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
export const supabaseClient = createClient(env.PUBLIC_SUPABASE_URL, env.PUBLIC_SUPABASE_ANON_KEY, {
persistSession: false,
autoRefreshToken: false
});
setupSupabaseHelpers({
supabaseClient,
cookieOptions: {
secure: !dev
}
});
```
### Initialize the client
Edit your `+layout.svelte` file and set up the client side.
```html
<!-- src/routes/+layout.svelte -->
<script lang="ts">
// we need to make sure the supabase instance is initialized on the client
import '$lib/db';
import { startSupabaseSessionSync } from '@supabase/auth-helpers-sveltekit';
import { page } from '$app/stores';
import { invalidateAll } from '$app/navigation';
// this sets up automatic token refreshing
startSupabaseSessionSync({
page,
handleRefresh: () => invalidateAll()
});
</script>
<slot />
```
### Hooks setup
Our `hooks.ts` file is where the heavy lifting of this library happens:
```ts
// src/hooks.server.ts
// we need to make sure the supabase instance is initialized on the server
import '$lib/db';
import { dev } from '$app/environment';
import { auth } from '@supabase/auth-helpers-sveltekit/server';
export const handle = auth();
// use the sequence helper if you have additional Handle methods
import { sequence } from '@sveltejs/kit/hooks';
export const handle = sequence(auth(), yourHandler);
```
There are three handle methods available:
- `callback()`:
This will create a handler for `/api/auth/callback`. The `client` forwards the session details here every time `onAuthStateChange` fires on the client side. This is needed to set up the cookies for your application so that SSR works seamlessly.
- `session()`:
This will parse the session from the cookie and populate it in locals
- `auth()`:
a shorthand for `sequence(callback(), session())` that uses both handlers
### Send session to client
In order to make the session available to the UI (pages, layouts) we need to pass the session in the root layout load function:
```ts
// src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
return {
session: locals.session
};
};
```
### Typings
In order to get the most out of TypeScript and it´s intellisense, you should import our types into the `app.d.ts` type definition file that comes with your SvelteKit project.
```ts
// src/app.d.ts
/// <reference types="@sveltejs/kit" />
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
// and what to do when importing types
declare namespace App {
interface Locals {
session: import('@supabase/auth-helpers-sveltekit').SupabaseSession;
}
interface PageData {
session: import('@supabase/auth-helpers-sveltekit').SupabaseSession;
}
// interface Error {}
// interface Platform {}
}
```
### Signing out
This library has provided a method to delete the session cookie.
Create a server only page with an action to delete the cookie
```ts
// src/routes/logout/+page.server.ts
import { deleteSession } from '@supabase/auth-helpers-sveltekit/server';
import { redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions: Actions = {
async default({ cookies }) {
deleteSession(cookies);
throw redirect(303, '/');
}
};
```
Then make a **POST** request with a form to the server only page above.
```html
<script lang="ts">
import { enhanceAndInvalidate } from '@supabase/auth-helpers-sveltekit';
</script>
<form action="/logout" method="post" use:enhanceAndInvalidate>
<button type="submit">Sign out</button>
</form>
```
### Basic Setup
You can now determine if a user is authenticated on the client-side by checking that the `user` object in `$page.data.session` is defined.
```html
<!-- src/routes/+page.svelte -->
<script>
import { page } from '$app/stores';
</script>
{#if !$page.data.session.user}
<h1>I am not logged in</h1>
{:else}
<h1>Welcome {$page.data.session.user.email}</h1>
<p>I am logged in!</p>
{/if}
```
## Client-side data fetching with RLS
For [row level security](https://supabase.com/docs/learn/auth-deep-dive/auth-row-level-security) to work properly when fetching data client-side, you need to make sure to import the `{ supabaseClient }` from `$lib/db` and only run your query once the user is defined client-side in `$page.data.session`:
```html
<script>
import { supabaseClient } from '$lib/db';
import { page } from '$app/stores';
let loadedData = [];
async function loadData() {
const { data } = await supabaseClient.from('test').select('*').limit(20);
loadedData = data;
}
$: if ($page.data.session.user) {
loadData();
}
</script>
{#if $page.data.session.user}
<p>client-side data fetching with RLS</p>
<pre>{JSON.stringify(loadedData, null, 2)}</pre>
{/if}
```
## Server-side data fetching with RLS
```html
<!-- src/routes/profile/+page.svelte -->
<script>
/** @type {import('./$types').PageData} */
export let data;
$: ({ user, tableData } = data);
</script>
<div>Protected content for {user.email}</div>
<pre>{JSON.stringify(tableData, null, 2)}</pre>
<pre>{JSON.stringify(user, null, 2)}</pre>
```
For [row level security](https://supabase.com/docs/learn/auth-deep-dive/auth-row-level-security) to work in a server environment, you need to use the `withAuth` helper to check if the user is authenticated. The helper extends the event with `session` and `getSupabaseClient()`:
```ts
// src/routes/profile/+page.ts
import type { PageLoad } from './$types';
import { withAuth } from '@supabase/auth-helpers-sveltekit';
import { redirect } from '@sveltejs/kit';
interface TestTable {
id: string;
created_at: string;
}
export const load: PageLoad = withAuth(async ({ getSupabaseClient, session }) => {
if (!session.user) {
throw redirect(303, '/');
}
const { data: tableData } = await getSupabaseClient()
.from<TestTable>('test')
.select('*');
return {
user: session.user,
tableData
};
);
```
**Caution:**
Always use the instance returned by `getSupabaseClient()` directly!
```ts
// Bad
const supabaseClient = getSupabaseClient();
await supabaseClient.from('table1').select();
await supabaseClient.from('table2').select();
// Good
await getSupabaseClient().from('table1').select();
await getSupabaseClient().from('table2').select();
```
## Protecting API routes
Wrap an API Route to check that the user has a valid session. If they're not logged in the session is `null`.
```ts
// src/routes/api/protected-route/+server.ts
import type { RequestHandler } from './$types';
import { withAuth } from '@supabase/auth-helpers-sveltekit';
import { json, redirect } from '@sveltejs/kit';
interface TestTable {
id: string;
created_at: string;
}
export const GET: RequestHandler = withAuth(async ({ session, getSupabaseClient }) => {
if (!session.user) {
throw redirect(303, '/');
}
const { data } = await getSupabaseClient()
.from<TestTable>('test')
.select('*');
return json({ data });
);
```
If you visit `/api/protected-route` without a valid session cookie, you will get a 303 response.
## Protecting Actions
Wrap an Action to check that the user has a valid session. If they're not logged in the session is `null`.
```ts
// src/routes/posts/+page.server.ts
import type { Actions } from './$types';
import { withAuth } from '@supabase/auth-helpers-sveltekit';
import { error, invalid } from '@sveltejs/kit';
export const actions: Actions = {
createPost: withAuth(async ({ session, getSupabaseClient, request }) => {
if (!session.user) {
// the user is not signed in
throw error(403, { message: 'Unauthorized' });
}
// we are save, let the user create the post
const formData = await request.formData();
const content = formData.get('content');
const { error: createPostError, data: newPost } = await getSupabaseClient()
.from('posts')
.insert({ content });
if (createPostError) {
return invalid(500, {
supabaseErrorMessage: createPostError.message
});
}
return {
newPost
};
})
};
```
If you try to submit a form with the action `?/createPost` without a valid session cookie, you will get a 403 error response.
## Saving and deleting the session
Use `saveSession` to save the session cookies:
```ts
import type { Actions } from './$types';
import { supabaseClient } from '$lib/db';
import { invalid, redirect } from '@sveltejs/kit';
import { saveSession } from '@supabase/auth-helpers-sveltekit/server';
export const actions: Actions = {
async signin({ request, cookies, url }) {
const formData = await request.formData();
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const { data, error } = await supabaseClient.auth.api.signInWithEmail(
email,
password,
{
redirectTo: `${url.origin}/logging-in`
}
);
if (error || !data) {
if (error?.status === 400) {
return invalid(400, {
error: 'Invalid credentials',
values: {
email
}
});
}
return invalid(500, {
error: 'Server error. Try again later.',
values: {
email
}
});
}
saveSession(cookies, data);
throw redirect(303, '/dashboard');
}
};
```
Use `deleteSession` to delete the session cookies:
```ts
import type { Actions } from './$types';
import { deleteSession } from '@supabase/auth-helpers-sveltekit/server';
import { redirect } from '@sveltejs/kit';
export const actions: Actions = {
async logout({ cookies }) {
deleteSession(cookies);
throw redirect(303, '/');
}
};
```
## Custom session namespace
If you wan´t to use something else than `locals.session` and `$page.data.session` you can do so by updating the types and creating three helper functions:
```ts
// src/app.d.ts
declare namespace App {
interface Locals {
mySupabaseSession: import('@supabase/auth-helpers-sveltekit').SupabaseSession;
}
interface PageData {
mySupabaseSession: import('@supabase/auth-helpers-sveltekit').SupabaseSession;
}
}
// src/hooks.server.ts
setupSupabaseServer({
supabaseClient,
cookieOptions: {
secure: !dev
},
// --- change location within locals ---
getSessionFromLocals: (locals) => locals.mySupabaseSession,
setSessionToLocals: (locals, session) => (locals.mySupabaseSession = session)
});
// src/lib/db.ts
setupSupabaseClient({
supabaseClient,
// --- change location within pageData ---
getSessionFromPageData: (data) => data.mySupabaseSession
});
```
## Migration from 0.6.11 and below to 0.7.0
There are quite a lot of breaking changes in the latest 0.7.0 version of this library.
### Environment variable prefix has changed
0.6.11 and below: `VITE_`
0.7.0: `PUBLIC_`
e.g. `VITE_SUPABASE_URL` is now `PUBLIC_SUPABASE_URL`
### Setup Supabase client
0.6.11 and below:
```js
// src/lib/db.ts
import { createSupabaseClient } from '@supabase/auth-helpers-sveltekit';
const { supabaseClient } = createSupabaseClient(
import.meta.env.VITE_SUPABASE_URL as string,
import.meta.env.VITE_SUPABASE_ANON_KEY as string
)
);
export { supabaseClient }
export { supabaseClient };
```
Edit your `__layout.svelte` file and add import the `SupaAuthHelper` component, the `supabaseClient` we just instantiated and the `session` store.
0.7.0:
```js
// src/lib/db.ts
import { createClient } from '@supabase/supabase-js';
import { setupSupabaseHelpers } from '@supabase/auth-helpers-sveltekit';
import { dev } from '$app/environment';
import { env } from '$env/dynamic/public';
// or use the static env
// import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
export const supabaseClient = createClient(env.PUBLIC_SUPABASE_URL, env.PUBLIC_SUPABASE_ANON_KEY, {
persistSession: false,
autoRefreshToken: false
});
setupSupabaseHelpers({
supabaseClient,
cookieOptions: {
secure: !dev
}
});
```
### Initialize client side code
0.6.11 and below:
```html
// src/routes/__layout.svelte
<!-- src/routes/__layout.svelte -->
<script>
import { session } from '$app/stores'
import { supabaseClient } from '$lib/db'
import { SupaAuthHelper } from '@supabase/auth-helpers-svelte'
import { session } from '$app/stores';
import { supabaseClient } from '$lib/db';
import { SupaAuthHelper } from '@supabase/auth-helpers-svelte';
</script>
<SupaAuthHelper {supabaseClient} {session}>
@@ -74,38 +518,74 @@ Edit your `__layout.svelte` file and add import the `SupaAuthHelper` component,
</SupaAuthHelper>
```
0.7.0:
```html
<!-- src/routes/+layout.svelte -->
<script lang="ts">
// we need to make sure the supabase instance is initialized on the client
import '$lib/db';
import { startSupabaseSessionSync } from '@supabase/auth-helpers-sveltekit';
import { page } from '$app/stores';
import { invalidateAll } from '$app/navigation';
// this sets up automatic token refreshing
startSupabaseSessionSync({
page,
handleRefresh: () => invalidateAll()
});
</script>
<slot />
```
> We no longer require the @supabase/auth-helpers-svelte library as the @supabase/auth-helpers-sveltekit library handles all the client side code too
### Hooks setup
Our `hooks.ts` file is where the heavy lifting of this library happens, we need to import our function to handle the sign in, signing out and cookie creation phase. we can import all the hooks using `handleAuth` function and destructure its returned data.
0.6.11 and below:
```ts
// src/hooks.ts
import { handleAuth } from '@supabase/auth-helpers-sveltekit'
import type { GetSession, Handle } from '@sveltejs/kit'
import { sequence } from '@sveltejs/kit/hooks'
import { handleAuth } from '@supabase/auth-helpers-sveltekit';
import type { GetSession, Handle } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
export const handle: Handle = sequence(...handleAuth())
export const handle: Handle = sequence(...handleAuth());
export const getSession: GetSession = async (event) => {
const { user, accessToken, error } = event.locals
return {
user,
accessToken,
error,
const { user, accessToken, error } = event.locals;
return {
user,
accessToken,
error
}
}
```
These will create the handlers under the hood that perform different parts of the authentication flow:
0.7.0:
```ts
// src/hooks.server.ts
// we need to make sure the supabase instance is initialized on the server
import '$lib/db';
import { dev } from '$app/environment';
import { auth } from '@supabase/auth-helpers-sveltekit/server';
- `/api/auth/callback`: The `UserHelper` forwards the session details here every time `onAuthStateChange` fires on the client side. This is needed to set up the cookies for your application so that SSR works seamlessly.
- `/api/auth/user`: You can fetch user profile information in JSON format.
- `/api/auth/logout`: You can logout the user.
export const handle = auth();
```
**Optional** *if using additional handle methods*
```ts
// src/hooks.server.ts
// we need to make sure the supabase instance is initialized on the server
import '$lib/db';
import { dev } from '$app/environment';
import { auth } from '@supabase/auth-helpers-sveltekit/server';
import { sequence } from '@sveltejs/kit/hooks';
export const handle = sequence(auth(), yourHandler);
```
### Typings
In order to get the most out of TypeScript and its intellisense, you should import our types into the `app.d.ts` type definition file that comes with your SvelteKit project.
0.6.11 and below:
```ts
// src/app.d.ts
/// <reference types="@sveltejs/kit" />
@@ -113,193 +593,197 @@ In order to get the most out of TypeScript and its intellisense, you should impo
// for information about these interfaces
declare namespace App {
interface UserSession {
user: import('@supabase/supabase-js').User
accessToken?: string
user: import('@supabase/supabase-js').User;
accessToken?: string;
}
interface Locals extends UserSession {
error: import('@supabase/supabase-js').ApiError
error: import('@supabase/supabase-js').ApiError;
}
interface Session extends UserSession {} // interface Platform {} // interface Stuff {}
interface Session extends UserSession {}
// interface Platform {}
// interface Stuff {}
}
```
### Signing out
This library has provided a dedicated endpoint for you to use to sign a user out. This endpoint will sign the user out of the Gotrue server, clear the cookies that were set when the user logged in and redirect the user to a configurable path.
The logout handler endpoint is `/api/auth/logout`, this will take a `GET` request which means it can be used as the href for a normal `a` tag in your html.
```html
<a href="/api/auth/logout">Sign out</a>
```
### Logout handler configuration
In your `src/hooks.ts` file the logout handler is already setup and you can configure the redirect path from here.
> By default the redirect path after logging out will be `/`.
0.7.0:
```ts
export const handle = sequence(
...handleAuth({
logout: { returnTo: '/auth/signin' },
})
)
```
// src/app.d.ts
/// <reference types="@sveltejs/kit" />
### Basic Setup
You can now determine if a user is authenticated on the client-side by checking that the `user` object returned by the `$session` store is defined.
```html
// example
<script>
import { session } from '$app/stores'
</script>
{#if !$session.user}
<h1>I am not logged in</h1>
{:else}
<h1>Welcome {$session.user.email}</h1>
<p>I am logged in!</p>
{/if}
```
## Client-side data fetching with RLS
For [row level security](https://supabase.com/docs/learn/auth-deep-dive/auth-row-level-security) to work properly when fetching data client-side, you need to make sure to import the `{ supabaseClient }` from `@supabase/auth-helpers-sveltekit` and only run your query once the user is defined client-side in the `$session`:
```html
<script>
import Auth from 'supabase-ui-svelte';
import { error, isLoading } from '@supabase/auth-helpers-svelte';
import { supabaseClient } from '$lib/db';
import { session } from '$app/stores';
let loadedData = [];
async function loadData() {
const { data } = await supabaseClient.from('test').select('*').single();
loadedData = data
}
$: {
if ($session.user && $session.user.id) {
loadData();
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
// and what to do when importing types
declare namespace App {
interface Locals {
session: import('@supabase/auth-helpers-sveltekit').SupabaseSession;
}
interface PageData {
session: import('@supabase/auth-helpers-sveltekit').SupabaseSession;
}
// interface Error {}
// interface Platform {}
}
```
### Checking user on the client
0.6.11 and below:
```html
<!-- src/routes/index.svelte -->
<script>
import { session } from '$app/stores';
</script>
{#if !$session.user}
{#if $error}
<p>{$error.message}</p>
{/if}
<h1>{$isLoading ? `Loading...` : `Loaded!`}</h1>
<Auth
supabaseClient={supabaseClient}
providers={['google', 'github']}
/>
<h1>I am not logged in</h1>
{:else}
<a href=="/api/auth/logout">Sign out</a>
<p>user:</p>
<pre>{JSON.stringify($session.user, null, 2)}</pre>
<p>client-side data fetching with RLS</p>
<pre>{JSON.stringify(loadedData, null, 2)}</pre>
<h1>Welcome {$session.user.email}</h1>
<p>I am logged in!</p>
{/if}
```
### Server-side data fetching with RLS
For [row level security](https://supabase.com/docs/learn/auth-deep-dive/auth-row-level-security) to work in a server environment, you need to inject the request context into the supabase client:
0.7.0:
```html
<!-- src/routes/profile.svelte -->
<!-- src/routes/+page.svelte -->
<script>
export let user
export let data
import { page } from '$app/stores';
</script>
{#if !$page.data.session.user}
<h1>I am not logged in</h1>
{:else}
<h1>Welcome {$page.data.session.user.email}</h1>
<p>I am logged in!</p>
{/if}
```
### withPageAuth
0.6.11 and below:
```html
<!-- // src/routes/protected-route.svelte -->
<script lang="ts" context="module">
import { supabaseServerClient, withPageAuth } from '@supabase/auth-helpers-sveltekit';
import type { Load } from './__types/protected-page';
export const load: Load = async ({ session }) =>
withPageAuth(
{
redirectTo: '/',
user: session.user
},
async () => {
const { data } = await supabaseServerClient(session.accessToken).from('test').select('*');
return { props: { data, user: session.user } };
}
);
</script>
<script>
export let data;
export let user;
</script>
<div>Protected content for {user.email}</div>
<p>server-side fetched data with RLS:</p>
<pre>{JSON.stringify(data, null, 2)}</pre>
<p>user:</p>
<pre>{JSON.stringify(user, null, 2)}</pre>
```
0.7.0:
```html
<!-- src/routes/protected-route/+page.svelte -->
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
$: ({ tableData, user } = data);
</script>
<div>Protected content for {user.email}</div>
<p>server-side fetched data with RLS:</p>
<pre>{JSON.stringify(tableData, null, 2)}</pre>
<p>user:</p>
<pre>{JSON.stringify(user, null, 2)}</pre>
```
```ts
// src/routes/profile.ts
import {
supabaseServerClient,
withApiAuth,
} from '@supabase/auth-helpers-sveltekit'
import type { RequestHandler } from './__types/profile'
// src/routes/protected-route/+page.ts
import { withAuth } from '@supabase/auth-helpers-sveltekit';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
interface TestTable {
id: string
created_at: string
}
export const load: PageLoad = withAuth(async ({ session, getSupabaseClient }) => {
if (!session.user) {
throw redirect(303, '/');
}
interface GetOutput {
user: User
data: TestTable[]
}
export const GET: RequestHandler<GetOutput> = async ({ locals }) =>
withApiAuth(
{
redirectTo: '/',
user: locals.user,
},
async () => {
const { data } = await supabaseServerClient(session.accessToken)
.from<TestTable>('test')
.select('*')
return {
body: {
user: locals.user,
data,
},
}
}
)
const { data: tableData } = await getSupabaseClient().from('test').select('*');
return { tableData, user: session.user };
});
```
## Protecting API routes
Wrap an API Route to check that the user has a valid session. If they're not logged in the handler will return a
303 and redirect header.
### withApiAuth
0.6.11 and below:
```ts
// src/routes/api/protected-route.ts
import {
supabaseServerClient,
withApiAuth,
} from '@supabase/auth-helpers-sveltekit'
import type { RequestHandler } from './__types/protected-route'
import { supabaseServerClient, withApiAuth } from "@supabase/auth-helpers-sveltekit";
import type { RequestHandler } from "./__types/protected-route";
interface TestTable {
id: string
created_at: string
id: string;
created_at: string;
}
interface GetOutput {
data: TestTable[]
data: TestTable[];
}
export const GET: RequestHandler<GetOutput> = async ({ locals, request }) =>
withApiAuth({ user: locals.user }, async () => {
// Run queries with RLS on the server
const { data } = await supabaseServerClient(request)
.from('test')
.select('*')
const { data } = await supabaseServerClient(request).from("test").select("*");
return {
status: 200,
body: { data },
}
})
body: { data }
};
});
```
If you visit `/api/protected-route` without a valid session cookie, you will get a 303 response.
0.7.0:
```ts
// src/routes/api/protected-route/+server.ts
import type { RequestHandler } from './$types';
import { withAuth } from '@supabase/auth-helpers-sveltekit';
import { json, redirect } from '@sveltejs/kit';
interface TestTable {
id: string;
created_at: string;
}
export const GET: RequestHandler = withAuth(async ({ session, getSupabaseClient }) => {
if (!session.user) {
throw redirect(303, '/');
}
const { data } = await getSupabaseClient()
.from<TestTable>('test')
.select('*');
return json({ data });
);
```
## Additional Links
@@ -22,7 +22,7 @@ Keycloak OAuth consists of five broad steps:
- Ensure that the new client has the "Client Protocol" set to "openid-connect" and the "Access Type" is set to "confidential".
- The `Client ID` of the client created will be used as the `client id`.
- Obtain the `Secret` from the credentials tab which will be used as the `client secret`.
- Whitelist the callback url of your application.
- Add the callback url of your application to your allowlist.
## Steps
+172 -38
View File
@@ -4,78 +4,212 @@ title: Logging
description: Getting started with Supabase Platform Log Browser
---
The Supabase Platform provides a log explorer that allows log tracing and debugging. Currently, PostgreSQL and Cloudflare edge logs are available.
import Tabs from '@theme/Tabs'
import TabItem from '@theme/TabItem'
import ThemedImage from '@theme/ThemedImage';
import useBaseUrl from '@docusaurus/useBaseUrl';
The Supabase Platform includes a Logs Explorer that allows log tracing and debugging. Log retention is based on your [project's pricing plan](https://supabase.com/pricing).
:::note
The features discussed in this article are only available through the Supabase Platform and are not available on self-hosted.
These features are not currently available for self-hosting and local development.<br/>
This is on the roadmap and you can follow the progress in the [Logflare repository](https://github.com/Logflare/logflare).
:::
## Product Logs
As well as a Log Explorer, Supabase provides a logging interface specific to each product.
Supabase provides a logging interface specific to each product. You can use simple regular expressions for keywords and patterns to search log event messages. You can also export and download the log events matching your query as a spreadsheet.
### API Logs
<!--
To update the screenshots, ensure that at least one log line is selected to display the metadata. Can use meme.town as an example.
-->
<Tabs>
<TabItem value="api" label="API" default>
[API logs](https://app.supabase.com/project/_/database/api-logs) show all network requests and response for the REST and GraphQL [APIs](../../guides/api).
![API Logs](/img/guides/platform/logs/logs-api.png)
The API Logs can be found under `Database > API Logs`. These show all network requests for the REST and GraphQL [API](../../guides/api).
</TabItem>
<TabItem value="postgres" label="Postgres">
### Postgres Logs
[Postgres logs](https://app.supabase.com/project/_/database/postgres-logs) show all queries and activity for your [database](../../guides/database).
![Postgres Logs](/img/guides/platform/logs/logs-database.png)
The Postgres Logs can be found under `Database > Postgres Logs`. These show all queries and activity for your [Database](../../guides/database).
</TabItem>
<TabItem value="auth" label="Auth">
## Log Explorer
[Auth logs](https://app.supabase.com/project/_/auth/logs) show all server logs for your [Auth usage](../../guides/auth).
![Sidebar navigation steps](/img/guides/platform/logs/sidebar-navigation.png)
![Auth Logs](/img/guides/platform/logs/logs-auth.png)
The log browser can be accessed in the sidebar under **Logs Explorer**. The **Logs Explorer** is for querying and aggregating project logs across products using SQL `SELECT` queries.
</TabItem>
<TabItem value="storage" label="Storage">
### Example
[Storage logs](https://app.supabase.com/project/_/storage/logs) shows all server logs for your [Storage API](../../guides/storage).
For example, you may enter the following into the SQL editor to query for each user's IP address:
![Storage Logs](/img/guides/platform/logs/logs-storage.png)
</TabItem>
<TabItem value="realtime" label="Realtime">
[Realtime logs](https://app.supabase.com/project/_/database/realtime-logs) show all server logs for your [Realtime API usage](../../guides/realtime).
![Realtime Logs](/img/guides/platform/logs/logs-realtime.png)
</TabItem>
<TabItem value="functions" label="Edge Functions">
For each [Edge Function](https://app.supabase.com/project/_/functions), logs are available under the following tabs:
**Invocations**
The Invocations tab displays the edge logs of function calls.
![Function Edge Logs](/img/guides/platform/logs/logs-functions-edge.png)
**Logs**
The Logs tab displays logs emitted during function execution.
![Function Logs](/img/guides/platform/logs/logs-functions.png)
</TabItem>
</Tabs>
## Logs Explorer
The [Logs Explorer](https://app.supabase.com/project/_/logs-explorer) exposes logs from each part of the Supabase stack as a separate table that can be queried and joined using SQL.
![Logs Explorer](/img/guides/platform/logs/logs-explorer.png)
You can access the following logs from the **Sources** drop-down:
- `auth_logs`: GoTrue server logs, containing authentication/authorization activity.
- `edge_logs`: Edge network logs, containing request and response metadata retrieved from Cloudflare.
- `function_edge_logs`: Edge network logs for only edge functions, containing network requests and response metadata for each execution.
- `function_logs`: Function internal logs, containing any `console` logging from within the edge function.
- `postgres_logs`: Postgres database logs, containing statements executed by connected applications.
- `realtime_logs`: Realtime server logs, containing client connection information.
- `storage_logs`: Storage server logs, containing object upload and retrieval information.
## Querying with the Logs Explorer
The Logs Explorer uses BigQuery and supports all [available SQL functions and operators](https://cloud.google.com/bigquery/docs/reference/standard-sql/functions-and-operators).
### Timestamp Display and Behavior
Each log entry is stored with a `timestamp` as a `TIMESTAMP` data type. Use the appropriate [timestamp function](https://cloud.google.com/bigquery/docs/reference/standard-sql/timestamp_functions#timestamp) to utilize the `timestamp` field in a query.
Raw top-level timestamp values are rendered as unix microsecond. To render the timestamps in a human-readable format, use the `DATETIME()` function to convert the unix timestamp display into an ISO-8601 timestamp.
```sql
SELECT timestamp, h.x_real_ip
FROM edge_logs
LEFT JOIN UNNEST(metadata) as m ON TRUE
LEFT JOIN UNNEST(m.request) AS r ON TRUE
LEFT JOIN UNNEST(r.headers) AS h ON TRUE
WHERE h.x_real_ip IS NOT NULL
-- timestamp column without datetime()
select timestamp from ....
-- 1664270180000
-- timestamp column with datetime()
select datetime(timestamp) from ....
-- 2022-09-27T09:17:10.439Z
```
![SELECT query example](/img/guides/platform/logs/select-query.png)
The list of supported product sources can be found under the **Sources** dropdown.
### Unnesting Arrays
To query the metadata in the above example, you can unnest the field and "join" the unnested data. Clicking on the log row shows that log metadata is stored as an array of objects.
Each log event stores metadata an array of objects with multiple levels, and can be seen by selecting single log events in the Logs Explorer. To query arrays, use `unnest()` on each array field and add it to the query as a join. This allows you to reference the nested objects with an alias and select their individual fields.
In order to query any value that is an array, we would need to `UNNEST()` that field and add it to the query as a join, thereby allowing us to reference the nested fields within the array.
For example, to query the edge logs without any joins:
:::caution
```sql
select timestamp, metadata from edge_logs t
```
The resulting `metadata` key is rendered as an array of objects in the Logs Explorer. In the following diagram, each box represents a nested array of objects:
Large projects may run into a `Resources Exceeded` memory limit error when selecting large objects with many nested keys. To avoid this error, select individual keys separately or reduce the queried date range.
:::
<!-- Scene is here https://app.excalidraw.com/s/8gj16loJfGZ/3HzccK9MyLx -->
### Functions
<ThemedImage
alt="Without Unnesting"
sources={{
light: useBaseUrl("/img/guides/platform/logs/unnesting-none.png"),
dark: useBaseUrl("/img/guides/platform/logs/unnesting-none-dark.png"),
}}
style={{maxHeight: 200}}
/>
You may have also noticed from the above examples that we are able to use certain SQL functions within our queries. The querying engine currently supported is BigQuery, hence you may use [any valid BigQuery function](https://cloud.google.com/bigquery/docs/reference/standard-sql/functions-and-operators) within your query.
#### Timestamp Behavior
Perform a `cross join unnest()` to work with the keys nested in the `metadata` key.
Each log entry is stored with a `timestamp`. In order to utilize the `timestamp` field in a query, you can use the appropriate [timestamp functions](https://cloud.google.com/bigquery/docs/reference/standard-sql/timestamp_functions#timestamp).
To query for a nested value, add a join for each array level:
:::note
```sql
select timestamp, request.method, header.cf_ipcountry
from edge_logs t
cross join unnest(t.metadata) as metadata
cross join unnest(metadata.request) as request
cross join unnest(request.headers) as header
```
In the Log Explorer, timestamps are rendered as unix microsecond timestamps. SQL queries, however, should always use the `TIMESTAMP` data type. If you are using a unix timestamp value in a query, cast the value to a `TIMESTAMP` data type.
This surfaces the following columns available for selection:
:::
<ThemedImage
alt="With Two Level Unnesting"
sources={{
light: useBaseUrl("/img/guides/platform/logs/unnesting-2.png"),
dark: useBaseUrl("/img/guides/platform/logs/unnesting-2-dark.png"),
}}
style={{maxHeight: 200}}
/>
## Templates
This allows you to select the `method` and `cf_ipcountry` columns. In JS dot notation, the full paths for each selected column are:
Templates are available to help craft you log queries. Templates are available under the **Templates** tab, or under the **Templates** Dropdown in the **Query** tab.
- `metadata[].request[].method`
- `metadata[].request[].headers[].cf_ipcountry`
### LIMIT and Result Row Limitations
The Logs Explorer has a maximum of 1000 rows per run. Use `LIMIT` to optimize your queries by reducing the number of rows returned further.
### Best Practices
1. Include a filter over **timestamp**
Querying your entire log history might seem appealing. For **Enterprise** customers that have a large retention range, you run the risk of timeouts due additional time required to scan the larger dataset.
2. Avoid selecting large nested objects. Select individual values instead.
When querying large objects, the columnar storage engine selects each column associated with each nested key, resulting in a large number of columns being selected. This inadvertently impacts the query speed and may result in timeouts or memory errors, especially for projects with a lot of logs.
Instead, select only the values required.
```sql
-- ❌ Avoid doing this
select
datetime(timestamp),
m as metadata -- <- metadata contains many nested keys
from edge_logs t
cross join unnest(t.metadata) as m;
-- ✅ Do this
select
datetime(timestamp),
r.method -- <- select only the required values
from edge_logs t
cross join unnest(t.metadata) as m
cross join unnest(m.request) as r
```
### Examples and Templates
The Logs Explorer includes **Templates** (available in the Templates tab or the dropdown in the Query tab) to help you get started.
For example, you can enter the following query in the SQL Editor to retrieve each user's IP address:
```sql
select datetime(timestamp), h.x_real_ip
from edge_logs
cross join unnest(metadata) as m
cross join unnest(m.request) AS r
cross join unnest(r.headers) AS h
where h.x_real_ip is not null and r.method = "GET"
```
+1
View File
@@ -79,6 +79,7 @@ const sidebars = {
items: [
'guides/auth/row-level-security',
'guides/auth/managing-user-data',
'guides/auth/auth-captcha',
],
},
{
+3 -3
View File
@@ -117,9 +117,9 @@ const sidebars = {
'generated/single',
'generated/maybesingle',
'generated/db-csv',
'generated/db-geojson',
'generated/db-explain',
'generated/db-rollback',
// 'generated/db-geojson',
// 'generated/db-explain',
// 'generated/db-rollback',
],
collapsed: true,
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 952 KiB

After

Width:  |  Height:  |  Size: 989 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 926 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1021 KiB

After

Width:  |  Height:  |  Size: 974 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 781 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 818 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 937 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 962 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 923 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 901 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+7
View File
@@ -0,0 +1,7 @@
node_modules/
.vercel
.next
.env.local
.env.production
.env.dev
.env.*
+206
View File
@@ -0,0 +1,206 @@
<p align="center">
<img width="300" src="https://raw.githubusercontent.com/supabase/supabase/master/packages/common/assets/images/supabase-logo-wordmark--light.svg#gh-light-mode-only">
<img width="300" src="https://raw.githubusercontent.com/supabase/supabase/master/packages/common/assets/images/supabase-logo-wordmark--dark.svg#gh-dark-mode-only">
</p>
---
# Supabase
[Supabase](https://supabase.com) je open source Firebase alternativa. Mi razvijamo funkcionalnosti Firebase-a koristeći profesionalne open source alate.
- [x] Hostovana Postgres Baza. [Dokumentacija](https://supabase.com/docs/guides/database)
- [x] Autentifikacija i Autorizacija. [Dokumentacija](https://supabase.com/docs/guides/auth)
- [x] Auto-generisani API-ji.
- [x] REST. [Dokumentacija](https://supabase.com/docs/guides/api#rest-api)
- [x] Subskripcije u realnom vremenu. [Dokumentacija](https://supabase.com/docs/guides/api#realtime-api)
- [x] GraphQL (Beta). [Dokumentacija](https://supabase.com/docs/guides/api#graphql-api)
- [x] Funkcije.
- [x] Funkcije u bazi. [Dokumentacija](https://supabase.com/docs/guides/database/functions)
- [x] Edge Funkcije [Dokumentacija](https://supabase.com/docs/guides/functions)
- [x] Skladištenje fajlova. [Dokumentacija](https://supabase.com/docs/guides/storage)
- [x] Kontrolna tabla
![Supabase kontrolna tabla](https://raw.githubusercontent.com/supabase/supabase/master/apps/www/public/images/github/supabase-dashboard.png)
## Dokumentacija
Za punu dokumentaciju, posetite [supabase.com/docs](https://supabase.com/docs)
Da vidite kako možete doprineti razvoju, posetite [Getting Started](./DEVELOPERS.md)
## Zajednica & Podrška
- [Forum zajednice](https://github.com/supabase/supabase/discussions). Najbolje mesto za: pomoć u razvijanju, diskusije o najboljim praksama u vezi baze.
- [GitHub Issues](https://github.com/supabase/supabase/issues). Najbolje mesto za: bagove i greške sa kojima se susretnete tokom korišćenja Supabase.
- [Email Podrška](https://supabase.com/docs/support#business-support). Najbolje mesto za: probleme sa vašom bazom ili infrastrukturom.
- [Discord](https://discord.supabase.com). Najbolje mesto za: deljenje vaših aplikacija i druženje sa zajednicom.
## Status
- [x] Alfa: Testiramo Supabase sa zatvorenim setom klijenata
- [x] Javna Alfa: Bilo ko može da se prijavi na [app.supabase.com](https://app.supabase.com). Ali ne budite grubi, biće nekih poteškoća
- [x] Javna Beta: Dovoljno stabilno za većinu ne-enterprise projekata
- [ ] Objavljeno: spremno za produkciju
Trenutno smo u javnoj beti. Pratite "releasove" ovog repozitorijuma da bi ste ostali u toku sa bitnim objavama.
<kbd><img src="https://raw.githubusercontent.com/supabase/supabase/d5f7f413ab356dc1a92075cb3cee4e40a957d5b1/web/static/watch-repo.gif" alt="Pratite ovaj repo"/></kbd>
---
## Kako radi
Supabase je kombinacija open source alata. Razvijamo funkcionalnosti Firebase-a koristeći profesionalne open source projekte. Ako već postoje alati i zajednice, sa MIT, Apache 2 ili ekvivalentnim otvorenim licencama, koristićemo i podržati taj alat. Ako alat ne postoji, mi ga razvijamo i open sourcujemo sami. Supabase nije 1-na-1 mapiranje Firebase-a. Naš cilj je da pružimo developerima iskustvo u razvoju slično kao Firebase koristeći open source alate.
**Arhitektura**
Supabase je [hostovana platforma](https://app.supabase.com). Možete se prijaviti i početi sa korišćenjem Supabase-a bez instaliranja bilo čega.
Takođe možete da [hostujete sami](https://supabase.com/docs/guides/hosting/overview) i da [razvijate u lokalu](https://supabase.com/docs/guides/local-development).
![Arhitektura](https://user-images.githubusercontent.com/70828596/187547862-ffa9d058-0c3a-4851-a3e7-92ccfca4b596.png)
- [PostgreSQL](https://www.postgresql.org/) je sistem objektno-relacione baze sa preko 30 godina aktivnog razvoja čime je zaslužio jaku reputaciju za pouzdanost, robustnost funkcionalnosti i performanse.
- [Realtime](https://github.com/supabase/realtime) je Elixir server koji vam omogućava da slušate PostgreSQL inserte, updateove, i brisanja koristeći websokete. Realtime polluje Postgres-ovu ugrađenu funkcionalnost replikacije za promene nad bazom, konvertuje promene u JSON, zatim broadcastuje taj JSON kroz websoket autorizovanim klijentima.
- [PostgREST](http://postgrest.org/) je web server koji pretvara vašu PostgreSQL bazu direktno u RESTful API
- [Storage](https://github.com/supabase/storage-api) daje RESTful interfejs za upravljanje fajlovima sačuvanim na S3, koristeći Postgres za upravljanje permisijama.
- [postgres-meta](https://github.com/supabase/postgres-meta) je RESTful API za upravljanje vašom Postgres isntancom, omogućavajući vam da dohvatite tabele, dodate role, izvršavate upite, itd.
- [GoTrue](https://github.com/netlify/gotrue) je SWT baziran API za upravljanje korisnicima i izdavanje SWT tokena.
- [Kong](https://github.com/Kong/kong) je cloud-nativan API gateway.
#### Klijentske biblioteke
Naš pristup klijentskim bibliotekama je modularan. Svaka pod-biblioteka je samostalna implementacija za jedan eksterni sistem. Ovo je jedan od načina na koji podržavamo postojeće alate.
<table style="table-layout:fixed; white-space: nowrap;">
<tr>
<th>Jezik</th>
<th>Klijent</th>
<th colspan="5">Funkcionalni klijenti (spakovani u Supabase klijenta)</th>
</tr>
<tr>
<th></th>
<th>Supabase</th>
<th><a href="https://github.com/postgrest/postgrest" target="_blank" rel="noopener noreferrer">PostgREST</a></th>
<th><a href="https://github.com/supabase/gotrue" target="_blank" rel="noopener noreferrer">GoTrue</a></th>
<th><a href="https://github.com/supabase/realtime" target="_blank" rel="noopener noreferrer">Realtime</a></th>
<th><a href="https://github.com/supabase/storage-api" target="_blank" rel="noopener noreferrer">Storage</a></th>
<th>Functions</th>
</tr>
<!-- TEMPLATE FOR NEW ROW -->
<!-- START ROW
<tr>
<td>lang</td>
<td><a href="https://github.com/supabase-community/supabase-lang" target="_blank" rel="noopener noreferrer">supabase-lang</a></td>
<td><a href="https://github.com/supabase-community/postgrest-lang" target="_blank" rel="noopener noreferrer">postgrest-lang</a></td>
<td><a href="https://github.com/supabase-community/gotrue-lang" target="_blank" rel="noopener noreferrer">gotrue-lang</a></td>
<td><a href="https://github.com/supabase-community/realtime-lang" target="_blank" rel="noopener noreferrer">realtime-lang</a></td>
<td><a href="https://github.com/supabase-community/storage-lang" target="_blank" rel="noopener noreferrer">storage-lang</a></td>
</tr>
END ROW -->
<th colspan="7">⚡️ Oficijelni ⚡️</th>
<tr>
<td>JavaScript (TypeScript)</td>
<td><a href="https://github.com/supabase/supabase-js" target="_blank" rel="noopener noreferrer">supabase-js</a></td>
<td><a href="https://github.com/supabase/postgrest-js" target="_blank" rel="noopener noreferrer">postgrest-js</a></td>
<td><a href="https://github.com/supabase/gotrue-js" target="_blank" rel="noopener noreferrer">gotrue-js</a></td>
<td><a href="https://github.com/supabase/realtime-js" target="_blank" rel="noopener noreferrer">realtime-js</a></td>
<td><a href="https://github.com/supabase/storage-js" target="_blank" rel="noopener noreferrer">storage-js</a></td>
<td><a href="https://github.com/supabase/functions-js" target="_blank" rel="noopener noreferrer">functions-js</a></td>
</tr>
<th colspan="7">💚 Od zajednice 💚</th>
<tr>
<td>C#</td>
<td><a href="https://github.com/supabase-community/supabase-csharp" target="_blank" rel="noopener noreferrer">supabase-csharp</a></td>
<td><a href="https://github.com/supabase-community/postgrest-csharp" target="_blank" rel="noopener noreferrer">postgrest-csharp</a></td>
<td><a href="https://github.com/supabase-community/gotrue-csharp" target="_blank" rel="noopener noreferrer">gotrue-csharp</a></td>
<td><a href="https://github.com/supabase-community/realtime-csharp" target="_blank" rel="noopener noreferrer">realtime-csharp</a></td>
<td><a href="https://github.com/supabase-community/storage-csharp" target="_blank" rel="noopener noreferrer">storage-csharp</a></td>
<td><a href="https://github.com/supabase-community/functions-csharp" target="_blank" rel="noopener noreferrer">functions-csharp</a></td>
</tr>
<tr>
<td>Dart (Flutter)</td>
<td><a href="https://github.com/supabase/supabase-dart" target="_blank" rel="noopener noreferrer">supabase-dart</a></td>
<td><a href="https://github.com/supabase/postgrest-dart" target="_blank" rel="noopener noreferrer">postgrest-dart</a></td>
<td><a href="https://github.com/supabase/gotrue-dart" target="_blank" rel="noopener noreferrer">gotrue-dart</a></td>
<td><a href="https://github.com/supabase/realtime-dart" target="_blank" rel="noopener noreferrer">realtime-dart</a></td>
<td><a href="https://github.com/supabase/storage-dart" target="_blank" rel="noopener noreferrer">storage-dart</a></td>
<td><a href="https://github.com/supabase-community/functions-dart" target="_blank" rel="noopener noreferrer">functions-dart</a></td>
</tr>
<tr>
<td>Go</td>
<td>-</td>
<td><a href="https://github.com/supabase-community/postgrest-go" target="_blank" rel="noopener noreferrer">postgrest-go</a></td>
<td>-</td>
<td>-</td>
<td><a href="https://github.com/supabase-community/storage-go" target="_blank" rel="noopener noreferrer">storage-go</a></td>
<td>-</td>
</tr>
<tr>
<td>Java</td>
<td>-</td>
<td>-</td>
<td><a href="https://github.com/supabase-community/gotrue-java" target="_blank" rel="noopener noreferrer">gotrue-java</a></td>
<td>-</td>
<td>-</td>
<td>-</td>
</tr>
<tr>
<td>Kotlin</td>
<td>-</td>
<td><a href="https://github.com/supabase-community/postgrest-kt" target="_blank" rel="noopener noreferrer">postgrest-kt</a></td>
<td><a href="https://github.com/supabase-community/gotrue-kt" target="_blank" rel="noopener noreferrer">gotrue-kt</a></td>
<td>-</td>
<td>-</td>
<td>-</td>
</tr>
<tr>
<td>Python</td>
<td><a href="https://github.com/supabase-community/supabase-py" target="_blank" rel="noopener noreferrer">supabase-py</a></td>
<td><a href="https://github.com/supabase-community/postgrest-py" target="_blank" rel="noopener noreferrer">postgrest-py</a></td>
<td><a href="https://github.com/supabase-community/gotrue-py" target="_blank" rel="noopener noreferrer">gotrue-py</a></td>
<td><a href="https://github.com/supabase-community/realtime-py" target="_blank" rel="noopener noreferrer">realtime-py</a></td>
<td><a href="https://github.com/supabase-community/storage-py" target="_blank" rel="noopener noreferrer">storage-py</a></td>
<td><a href="https://github.com/supabase-community/functions-py" target="_blank" rel="noopener noreferrer">functions-py</a></td>
</tr>
<tr>
<td>Ruby</td>
<td><a href="https://github.com/supabase-community/supabase-rb" target="_blank" rel="noopener noreferrer">supabase-rb</a></td>
<td><a href="https://github.com/supabase-community/postgrest-rb" target="_blank" rel="noopener noreferrer">postgrest-rb</a></td>
<td>-</td>
<td>-</td>
<td>-</td>
<td>-</td>
</tr>
<tr>
<td>Rust</td>
<td>-</td>
<td><a href="https://github.com/supabase-community/postgrest-rs" target="_blank" rel="noopener noreferrer">postgrest-rs</a></td>
<td>-</td>
<td>-</td>
<td>-</td>
<td>-</td>
</tr>
<tr>
<td>Swift</td>
<td><a href="https://github.com/supabase-community/supabase-swift" target="_blank" rel="noopener noreferrer">supabase-swift</a></td>
<td><a href="https://github.com/supabase-community/postgrest-swift" target="_blank" rel="noopener noreferrer">postgrest-swift</a></td>
<td><a href="https://github.com/supabase-community/gotrue-swift" target="_blank" rel="noopener noreferrer">gotrue-swift</a></td>
<td><a href="https://github.com/supabase-community/realtime-swift" target="_blank" rel="noopener noreferrer">realtime-swift</a></td>
<td><a href="https://github.com/supabase-community/storage-swift" target="_blank" rel="noopener noreferrer">storage-swift</a></td>
<td>-</td>
</tr>
</table>
<!--- Remove this list if you're translating to another language, it's hard to keep updated across multiple files-->
<!--- Keep only the link to the list of translation files-->
## Prevodi
- [Lista prevoda](/i18n/languages.md) <!--- Keep only this -->
---
## Sponzori
[![Novi sponzor](https://user-images.githubusercontent.com/10214025/90518111-e74bbb00-e198-11ea-8f88-c9e3c1aa4b5b.png)](https://github.com/sponsors/supabase)
+1
View File
@@ -30,6 +30,7 @@
- [Portuguese (Brazilian) / Português Brasileiro](/i18n/README.pt-br.md)
- [Romanian / Română](/i18n/README.ro.md)
- [Russian / Pусский](/i18n/README.ru.md)
- [Serbian / Srpski](/i18n/README.sr.md)
- [Sinhala / සිංහල](/i18n/README.si.md)
- [Spanish / Español](/i18n/README.es.md)
- [Simplified Chinese / 简体中文](/i18n/README.zh-cn.md)
File diff suppressed because it is too large. Load diff
+28 -12
View File
@@ -6,25 +6,41 @@
# docker build --target production --no-cache -t supabase/studio:latest .
# docker builder prune
FROM node:14-slim as base
RUN apt-get update && apt-get install -y \
FROM node:16-slim as base
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
build-essential \
&& rm -rf /var/lib/apt/lists/*
FROM base as builder
WORKDIR /usr/src/app
# Do `npm ci` separately so we can cache `node_modules`
# https://nodejs.org/en/docs/guides/nodejs-docker-webapp/
COPY package*.json ./
RUN npm clean-install && npm cache clean --force
COPY . .
COPY package*.json .
COPY turbo.json .
COPY packages packages
COPY studio studio
# the following command is still unsupported for npm // https://github.com/vercel/turborepo/issues/1830
# RUN npm i -g turbo
# RUN turbo prune --scope=studio --docker
# RUN npm clean-install && npm cache clean --force
ENTRYPOINT ["docker-entrypoint.sh"]
FROM base as dev
FROM builder as dev
COPY --from=builder /usr/src/app ./
RUN npm clean-install && npm cache clean --force
EXPOSE 8082
CMD ["npm", "run", "dev"]
CMD ["npx", "turbo", "run", "dev", "--filter=studio"]
FROM base as production
RUN npm run build && rm -rf .next/cache/webpack && npm prune --production
FROM builder as productionprep
WORKDIR /app
COPY --from=builder /usr/src/app ./
RUN npm clean-install \
&& npm cache clean --force \
&& npx turbo run build --scope=studio --include-dependencies --no-deps \
&& rm -rf ./studio/.next/cache/webpack \
&& npm prune --production
FROM node:16-slim as production
COPY --from=productionprep /app /app
WORKDIR /app/studio
EXPOSE 3000
CMD ["npm", "run", "start"]
@@ -156,11 +156,11 @@ const UserDropdown: FC<Props> = ({ user, canRemoveUser }) => {
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'bg-scale-100 rounded py-1 px-2 leading-none shadow',
'border-scale-200 border',
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
'border border-scale-200',
].join(' ')}
>
<span className="text-scale-1200 text-xs">
<span className="text-xs text-scale-1200">
You need additional permissions to delete users
</span>
</div>
@@ -53,8 +53,8 @@ const Users = () => {
}
return (
<div className="">
<div className="flex justify-between px-6 pt-6 pb-2">
<div className="overflow-scroll">
<div className="justify-between px-6 pt-6 pb-2 md:flex">
<div className="relative flex space-x-4">
<Input
size="small"
@@ -94,7 +94,7 @@ const Users = () => {
</Listbox.Option>
</Listbox>
</div>
<div className="flex items-center">
<div className="mt-4 flex items-center md:mt-0">
<Button
className="mr-2"
size="tiny"
@@ -108,9 +108,9 @@ const Users = () => {
{inviteEnabled && <InviteUserModal />}
</div>
</div>
<section className="overflow-visible mt-4 px-6">
<div className="relative section-block--body rounded">
<div className="align-middle inline-block min-w-full">
<section className="mt-4 overflow-visible px-6">
<div className="section-block--body relative overflow-scroll rounded">
<div className="inline-block min-w-full align-middle">
<UsersList />
</div>
</div>
@@ -20,10 +20,10 @@ const UsersList = ({}) => {
<>
<Table.th>Email</Table.th>
<Table.th>Phone</Table.th>
<Table.th className="hidden 2xl:table-cell">Provider</Table.th>
<Table.th className="hidden 2xl:table-cell">Created</Table.th>
<Table.th className="hidden xl:table-cell">Last Sign In</Table.th>
<Table.th className="hidden lg:table-cell">User UID</Table.th>
<Table.th className="table-cell">Provider</Table.th>
<Table.th className="table-cell">Created</Table.th>
<Table.th className="table-cell">Last Sign In</Table.th>
<Table.th className="table-cell">User UID</Table.th>
<Table.th></Table.th>
</>
}
@@ -34,7 +34,7 @@ const UsersList = ({}) => {
{/* @ts-ignore */}
<Table.td
colSpan={7}
className="h-14 p-4 whitespace-nowrap border-t leading-5 text-gray-300 text-sm"
className="h-14 whitespace-nowrap border-t p-4 text-sm leading-5 text-gray-300"
>
<div className="flex items-center space-x-3 opacity-75">
<IconAlertCircle size={16} strokeWidth={2} />
@@ -19,24 +19,24 @@ const UserListItem: FC<Props> = ({ user, canRemoveUser }) => {
const lastSignedIn = getDateFromIsoString(user.last_sign_in_at)
return (
<Table.tr key={user.id}>
<Table.tr className="relative" key={user.id}>
<Table.td className="whitespace-nowrap">
<div className="flex items-center gap-2">
<span className="text-scale-1200">{!user.email ? '-' : user.email}</span>
<span className="text-sm text-scale-1200">{!user.email ? '-' : user.email}</span>
</div>
</Table.td>
<Table.td className="whitespace-nowrap">
<span className="text-scale-1200">{!user.phone ? '-' : user.phone}</span>
</Table.td>
<Table.td className="hidden 2xl:table-cell">
<span className="text-scale-1200 capitalize">
<Table.td className="table-cell">
<span className="capitalize text-scale-1200">
{user?.raw_app_meta_data?.provider || user?.app_metadata?.provider}
</span>
</Table.td>
<Table.td className="hidden 2xl:table-cell">
<Table.td className="table-cell">
<span className="text-scale-1200">{createdAt?.format('DD MMM, YYYY HH:mm')}</span>
</Table.td>
<Table.td className="hidden xl:table-cell">
<Table.td className="table-cell">
{!isUserConfirmed ? (
<Badge color="yellow">Waiting for verification..</Badge>
) : user.last_sign_in_at ? (
@@ -45,10 +45,13 @@ const UserListItem: FC<Props> = ({ user, canRemoveUser }) => {
'Never'
)}
</Table.td>
<Table.td className="hidden lg:table-cell">
<SimpleCodeBlock metastring="" className="font-xs bash">
{user.id}
</SimpleCodeBlock>
<Table.td className="table-cell">
<div className="flex max-w-[72px] items-baseline">
<SimpleCodeBlock metastring="" className="font-xs bash">
{user.id}
</SimpleCodeBlock>
<div>...</div>
</div>
</Table.td>
<Table.td className="text-right">
<UserDropdown user={user} canRemoveUser={canRemoveUser} />
@@ -462,14 +462,6 @@ export const ALL_TIMEZONES = [
text: '(UTC) Coordinated Universal Time',
utc: ['America/Danmarkshavn', 'Etc/GMT'],
},
{
value: 'GMT Standard Time',
abbr: 'GMT',
offset: 0,
isdst: false,
text: '(UTC) Edinburgh, London',
utc: ['Europe/Isle_of_Man', 'Europe/Guernsey', 'Europe/Jersey', 'Europe/London'],
},
{
value: 'British Summer Time',
abbr: 'BST',
@@ -6,3 +6,9 @@ export interface Timezone {
text: string
utc: string[]
}
export interface Time {
h: number
m: number
s: number
}
@@ -0,0 +1,63 @@
import dayjs from 'dayjs'
import { ALL_TIMEZONES } from './PITR.constants'
import { Time, Timezone } from './PITR.types'
export const getDatesBetweenRange = (start: Date, end: Date) => {
const arr = []
for (let i, dt = new Date(start); dt <= new Date(end); dt.setDate(dt.getDate() + 1)) {
arr.push(new Date(dt))
}
return arr
}
export const getClientTimezone = () => {
const defaultTz = dayjs.tz.guess()
const utcTz = ALL_TIMEZONES.find((option) => option.value === 'UTC')
const timezone = ALL_TIMEZONES.find((option) => {
if (option.utc.includes(defaultTz)) return option
else return undefined
})
return timezone ?? (utcTz || ALL_TIMEZONES[0])
}
export const checkMatchingDates = (selectedDate: Date | undefined, targetDate: Date) => {
if (!selectedDate) return false
const formattedSelectedDate = new Date(selectedDate.getTime())
formattedSelectedDate.setHours(0, 0, 0, 0)
const formattedTargetDate = new Date(targetDate.getTime())
formattedTargetDate.setHours(0, 0, 0, 0)
return Number(formattedSelectedDate) === Number(formattedTargetDate)
}
export const formatNumberToTwoDigits = (number: Number) => {
return number.toLocaleString('en-US', {
minimumIntegerDigits: 2,
useGrouping: false,
})
}
// Formats Time object to hh:mm:ss
export const formatTimeToTimeString = (time: Time) => {
return `${formatNumberToTwoDigits(time.h)}:${formatNumberToTwoDigits(
time.m
)}:${formatNumberToTwoDigits(time.s)}`
}
export const getTimezoneOffsetText = (timezone: Timezone) => {
return timezone.text.split(')')[0].slice(1)
}
export const getTimezoneOffset = (timezone: Timezone) => {
return timezone.text.split(')')[0].slice(4)
}
export const convertDatetimetoUnixS = (date: Date, time: Time, timezone: Timezone) => {
const dateString = dayjs(date).format('YYYY-MM-DD')
const timeString = formatTimeToTimeString(time)
const timezoneOffset = getTimezoneOffset(timezone)
const datetimestringWithTimezone = `${dateString}T${timeString}${timezoneOffset || '+00:00'}`
return dayjs(datetimestringWithTimezone).unix()
}
@@ -0,0 +1,44 @@
import Link from 'next/link'
import { useRouter } from 'next/router'
import { Button, IconCalendar } from '@supabase/ui'
import { FormPanel } from 'components/ui/Forms'
const PITRNotice = ({}) => {
const router = useRouter()
const { ref } = router.query
return (
<FormPanel
disabled={true}
footer={
<div className="flex items-center justify-between p-6">
<span className="text-scale-1000 text-sm">
Recovery retention period can be increased - reach out to us!
</span>
<Link href={`/support/new?ref=${ref}&category=sales`}>
<a>
<Button as="span" type="default">
Contact support
</Button>
</a>
</Link>
</div>
}
>
<div className="p-6 flex space-x-6">
<div className="h-10 w-10 rounded flex items-center justify-center bg-scale-700">
<IconCalendar strokeWidth={2} />
</div>
<div className="space-y-2">
<p className="text-sm">Recovery retention period</p>
<p className="text-sm text-scale-1100">
Database changes are logged every <span className="text-scale-1200">2 minutes</span>,
with a total recovery period of up to <span className="text-scale-1200">7 days</span>.
</p>
</div>
</div>
</FormPanel>
)
}
export default PITRNotice
@@ -0,0 +1,389 @@
import dayjs from 'dayjs'
import { format } from 'date-fns'
import { useEffect, useState } from 'react'
import DatePicker from 'react-datepicker'
import {
Button,
Modal,
IconChevronLeft,
IconChevronRight,
IconHelpCircle,
Alert,
} from '@supabase/ui'
import * as Tooltip from '@radix-ui/react-tooltip'
import { useStore } from 'hooks'
import { post } from 'lib/common/fetch'
import { API_URL, PROJECT_STATUS } from 'lib/constants'
import { FormHeader, FormPanel } from 'components/ui/Forms'
import InformationBox from 'components/ui/InformationBox'
import TimeInput from './TimeInput'
import TimezoneSelection from './TimezoneSelection'
import { Time, Timezone } from './PITR.types'
import PITRStatus from './PITRStatus'
import {
checkMatchingDates,
convertDatetimetoUnixS,
formatNumberToTwoDigits,
formatTimeToTimeString,
getClientTimezone,
getDatesBetweenRange,
} from './PITR.utils'
import { useRouter } from 'next/router'
const DEFAULT_TIME = { h: 0, m: 0, s: 0 }
const PITRSelection = ({}) => {
const router = useRouter()
const { ref } = router.query
const { app, ui, backups } = useStore()
const projectId = ui.selectedProject?.id ?? -1
const [selectedDate, setSelectedDate] = useState<Date>()
const [selectedTime, setSelectedTime] = useState<Time>({ h: 0, m: 0, s: 0 })
const [selectedTimezone, setSelectedTimezone] = useState<Timezone>(getClientTimezone())
const [showConfiguration, setShowConfiguration] = useState(false)
const [showConfirmation, setShowConfirmation] = useState(false)
const [isRestoring, setIsRestoring] = useState(false)
useEffect(() => {
if (selectedDate) {
if (selectedDate < earliestAvailableBackupFormatted) {
setSelectedDate(earliestAvailableBackupFormatted)
} else if (selectedDate > latestAvailableBackupFormatted) {
setSelectedDate(latestAvailableBackupFormatted)
}
}
}, [selectedTimezone])
useEffect(() => {
const formattedSelectedTime = dayjs(formatTimeToTimeString(selectedTime), 'HH:mm:ss', true)
const formattedEarliestTime = dayjs(
formatTimeToTimeString(earliestAvailableBackupTime),
'HH:mm:ss',
true
)
const formattedLatestTime = dayjs(
formatTimeToTimeString(latestAvailableBackupTime),
'HH:mm:ss',
true
)
if (isSelectedOnEarliest && formattedSelectedTime.isBefore(formattedEarliestTime)) {
return setSelectedTime(earliestAvailableBackupTime)
}
if (isSelectedOnLatest && formattedSelectedTime.isAfter(formattedLatestTime)) {
return setSelectedTime(latestAvailableBackupTime)
}
}, [selectedDate, selectedTimezone])
const { earliestPhysicalBackupDateUnix, latestPhysicalBackupDateUnix } =
backups?.configuration?.physicalBackupData ?? {}
const earliestAvailableBackup = dayjs(earliestPhysicalBackupDateUnix * 1000).tz(
selectedTimezone?.utc[0]
)
const latestAvailableBackup = dayjs(latestPhysicalBackupDateUnix * 1000).tz(
selectedTimezone?.utc[0]
)
// Start: Variables specifically for date picker component
// Required as it only works with vanilla Date object which is not timezone localized
const earliestAvailableBackupFormatted = new Date(earliestAvailableBackup.format('DD-MMM-YYYY'))
const latestAvailableBackupFormatted = new Date(latestAvailableBackup.format('DD-MMM-YYYY'))
const isSelectedOnEarliest = checkMatchingDates(selectedDate, earliestAvailableBackupFormatted)
const isSelectedOnLatest = checkMatchingDates(selectedDate, latestAvailableBackupFormatted)
const availableDates = getDatesBetweenRange(
earliestAvailableBackupFormatted,
latestAvailableBackupFormatted
)
// End: Variables specifically for date picker component
const earliestAvailableBackupTime = {
h: earliestAvailableBackup.hour(),
m: earliestAvailableBackup.minute(),
s: earliestAvailableBackup.second(),
}
const latestAvailableBackupTime = {
h: latestAvailableBackup.hour(),
m: latestAvailableBackup.minute(),
s: latestAvailableBackup.second(),
}
// This will be the actual unix timestamp for the backup
const recoveryTimeTargetUnix = selectedDate
? convertDatetimetoUnixS(selectedDate, selectedTime, selectedTimezone)
: 0
// Formatting from the unix again just to double check correctness
const recoveryTimeString = dayjs(recoveryTimeTargetUnix * 1000)
.tz(selectedTimezone?.utc[0])
.format('DD MMM YYYY HH:mm:ss')
const isSelectedOutOfRange =
selectedDate &&
(recoveryTimeTargetUnix < earliestPhysicalBackupDateUnix ||
recoveryTimeTargetUnix > latestPhysicalBackupDateUnix)
const onUpdateDate = (date: Date) => setSelectedDate(date)
const onCancel = () => {
setShowConfiguration(false)
setSelectedDate(undefined)
setSelectedTime(DEFAULT_TIME)
setSelectedTimezone(getClientTimezone())
}
const onConfirmRestore = async () => {
if (!recoveryTimeTargetUnix) return
setIsRestoring(true)
const projectRef = ref as string
const { error } = await post(`${API_URL}/database/${projectRef}/backups/pitr`, {
recovery_time_target_unix: recoveryTimeTargetUnix,
})
if (error) {
ui.setNotification({
error,
category: 'error',
message: `Failed to being restoration: ${error.message}`,
})
setIsRestoring(false)
} else {
setTimeout(() => {
setShowConfirmation(false)
app.onProjectStatusUpdated(projectId, PROJECT_STATUS.RESTORING)
router.push(`/project/${projectRef}`)
}, 3000)
}
}
return (
<>
<FormHeader
title="Restore your database from a backup"
description="Database changes are watched and recorded, so that you can restore your database to any point in time"
/>
{!showConfiguration ? (
<PITRStatus
selectedTimezone={selectedTimezone}
onUpdateTimezone={setSelectedTimezone}
onSetConfiguration={() => setShowConfiguration(true)}
/>
) : (
<FormPanel
disabled={true}
footer={
<div className="flex items-center justify-end gap-3 p-6">
<Button type="default" onClick={onCancel}>
Cancel
</Button>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<Button
as="span"
type="warning"
disabled={isSelectedOutOfRange}
onClick={() => setShowConfirmation(true)}
>
Review restore details
</Button>
</Tooltip.Trigger>
{isSelectedOutOfRange && (
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'bg-scale-100 rounded py-1 px-2 leading-none shadow',
'border-scale-200 border w-48 text-center',
].join(' ')}
>
<span className="text-scale-1200 text-xs">
Selected date is out of range where backups are available
</span>
</div>
</Tooltip.Content>
)}
</Tooltip.Root>
</div>
}
>
<div className="flex justify-between px-10 py-6 space-x-10">
<div className="w-1/3 space-y-2">
<DatePicker
inline
selected={selectedDate}
onChange={onUpdateDate}
dayClassName={() => 'cursor-pointer'}
minDate={earliestAvailableBackupFormatted}
maxDate={latestAvailableBackupFormatted}
highlightDates={availableDates}
renderCustomHeader={({
date,
decreaseMonth,
increaseMonth,
prevMonthButtonDisabled,
nextMonthButtonDisabled,
}) => (
<div className="flex items-center justify-between px-2 py-2">
<div className="flex w-full items-center justify-between">
<button
onClick={decreaseMonth}
disabled={prevMonthButtonDisabled}
type="button"
className={`
${prevMonthButtonDisabled && 'cursor-not-allowed opacity-50'}
text-scale-1100 hover:text-scale-1200 focus:outline-none
`}
>
<IconChevronLeft size={16} strokeWidth={2} />
</button>
<span className="text-scale-1100 text-sm">{format(date, 'MMMM yyyy')}</span>
<button
onClick={increaseMonth}
disabled={nextMonthButtonDisabled}
type="button"
className={`
${nextMonthButtonDisabled && 'cursor-not-allowed opacity-50'}
text-scale-1100 hover:text-scale-1200 focus:outline-none
`}
>
<IconChevronRight size={16} strokeWidth={2} />
</button>
</div>
</div>
)}
/>
<div className="flex items-center space-x-2">
<div className="border w-4 h-4 border-scale-800 bg-brand-600" />
<p className="text-xs text-scale-1000">Point in time back up available</p>
</div>
</div>
<div className="w-2/3">
{!selectedDate ? (
<div className="h-full flex items-center justify-center">
<div className="mx-2">
<InformationBox
defaultVisibility
hideCollapse
icon={<IconHelpCircle strokeWidth={2} />}
title="Select a date which you'd like to restore your database to"
/>
</div>
</div>
) : (
<div className="space-y-8 py-2">
<div className="space-y-1">
<p className="text-sm text-scale-1100">Restore database to</p>
<p className="text-3xl">
<span>{dayjs(selectedDate).format('DD MMM YYYY')}</span>
<span>
, {formatNumberToTwoDigits(selectedTime.h)}:
{formatNumberToTwoDigits(selectedTime.m)}:
{formatNumberToTwoDigits(selectedTime.s)}
</span>
</p>
</div>
<div className="space-y-2">
<p className="text-sm text-scale-1100">Time of recovery</p>
<div className="w-[350px]">
<TimezoneSelection
hideLabel
dropdownWidth="w-[400px]"
selectedTimezone={selectedTimezone}
onSelectTimezone={setSelectedTimezone}
/>
</div>
<TimeInput
defaultTime={selectedTime}
minimumTime={isSelectedOnEarliest ? earliestAvailableBackupTime : undefined}
maximumTime={isSelectedOnLatest ? latestAvailableBackupTime : undefined}
onChange={setSelectedTime}
/>
<div className="!mt-4 space-y-1">
{isSelectedOnEarliest && (
<p className="text-sm text-scale-1000">
Earliest available backup on this date is at{' '}
{earliestAvailableBackup.format('HH:mm:ss')}
</p>
)}
{isSelectedOnLatest && (
<p className="text-sm text-scale-1000">
Latest available backup on this date is at{' '}
{latestAvailableBackup.format('HH:mm:ss')}
</p>
)}
</div>
</div>
</div>
)}
</div>
</div>
</FormPanel>
)}
<Modal
closable
size="medium"
visible={showConfirmation}
onCancel={() => setShowConfirmation(false)}
customFooter={
<div className="flex items-center justify-end space-x-2">
<Button
type="default"
disabled={isRestoring}
onClick={() => setShowConfirmation(false)}
>
Cancel
</Button>
<Button
type="warning"
disabled={isRestoring}
loading={isRestoring}
onClick={onConfirmRestore}
>
I understand, begin restore
</Button>
</div>
}
>
<div className="space-y-4 py-3">
<Modal.Content>
<p>Point in time recovery review</p>
</Modal.Content>
<Modal.Seperator />
<Modal.Content>
<div className="py-2 space-y-1">
<p className="text-sm text-scale-1100"> Your database will be restored to</p>
<p className="text-2xl">{recoveryTimeString}</p>
<p className="text-lg">{selectedTimezone?.text}</p>
</div>
</Modal.Content>
<Modal.Seperator />
<Modal.Content>
<Alert withIcon variant="warning" title="Warning: This action cannot be undone">
<div className="space-y-3">
<p>
Any changes made to your database after this point in time will be lost. This
includes any changes to your project's storage and authentication.
</p>
</div>
</Alert>
</Modal.Content>
<Modal.Seperator />
<Modal.Content>
<p className="text-sm">
Restores may take from a few minutes up to several hours depending on the size of your
database. During this period, your project will not be available.
</p>
</Modal.Content>
</div>
</Modal>
</>
)
}
export default PITRSelection
@@ -0,0 +1,72 @@
import dayjs from 'dayjs'
import { FC } from 'react'
import { observer } from 'mobx-react-lite'
import { Button, IconAlertCircle } from '@supabase/ui'
import { useStore } from 'hooks'
import { Timezone } from './PITR.types'
import { FormPanel } from 'components/ui/Forms'
import TimezoneSelection from './TimezoneSelection'
interface Props {
selectedTimezone: Timezone
onUpdateTimezone: (timezone: Timezone) => void
onSetConfiguration: () => void
}
const PITRStatus: FC<Props> = ({ selectedTimezone, onUpdateTimezone, onSetConfiguration }) => {
const { backups } = useStore()
const { earliestPhysicalBackupDateUnix, latestPhysicalBackupDateUnix } =
backups?.configuration?.physicalBackupData ?? {}
const earliestAvailableBackup = dayjs
.unix(earliestPhysicalBackupDateUnix)
.tz(selectedTimezone?.utc[0])
.format('DD MMM YYYY, HH:mm:ss')
const latestAvailableBackup = dayjs
.unix(latestPhysicalBackupDateUnix)
.tz(selectedTimezone?.utc[0])
.format('DD MMM YYYY, HH:mm:ss')
return (
<>
<FormPanel
disabled={true}
footer={
<div className="flex items-center justify-between p-6">
<div className="flex items-center space-x-4">
<IconAlertCircle className="text-scale-1100" size={18} strokeWidth={1.5} />
<span className="text-scale-1000 text-sm">
You'll be able to pick the right date and time when you begin
</span>
</div>
<Button onClick={() => onSetConfiguration()}>Start a restore</Button>
</div>
}
>
<div className="p-6 space-y-6">
<div className="w-[350px]">
<TimezoneSelection
hideLabel
selectedTimezone={selectedTimezone}
onSelectTimezone={onUpdateTimezone}
/>
</div>
<div className="flex items-center space-x-20">
<div className="space-y-2">
<p className="text-sm text-scale-1100">Database restore available from</p>
<p className="text-2xl">{earliestAvailableBackup}</p>
</div>
<div className="space-y-2">
<p className="text-sm text-scale-1100">Latest restore available at</p>
<p className="text-2xl">{latestAvailableBackup}</p>
</div>
</div>
</div>
</FormPanel>
</>
)
}
export default observer(PITRStatus)
@@ -0,0 +1,187 @@
import dayjs from 'dayjs'
import { isNaN } from 'lodash'
import { ChangeEvent, FC, useEffect, useState } from 'react'
import * as Tooltip from '@radix-ui/react-tooltip'
import { IconClock } from '@supabase/ui'
import { Time } from './PITR.types'
import { formatNumberToTwoDigits, formatTimeToTimeString } from './PITR.utils'
// [Joshen] This is trying to do the same thing as TimeSplitInput.tsx
// so can we please look to try to combine these 2 components together if possible
// The problem with TimeSplitInput is that it's tightly coupled to the date + its
// tightly coupled to the context of a range. Ideally it should be more modular
// which is what this component is trying to achieve
// [Joshen] Potential extension, give option to toggle 24 hours or AM/PM
interface Props {
defaultTime?: Time
minimumTime?: Time
maximumTime?: Time
onChange?: (time: Time) => void
}
const TimeInput: FC<Props> = ({ defaultTime, minimumTime, maximumTime, onChange = () => {} }) => {
const [isFocused, setIsFocused] = useState(false)
const [error, setError] = useState<string>()
const [time, setTime] = useState<Time>(defaultTime || { h: 0, m: 0, s: 0 })
const formattedMinimumTime = minimumTime
? dayjs(formatTimeToTimeString(minimumTime), 'HH:mm:ss', true)
: undefined
const formattedMaximumTime = maximumTime
? dayjs(formatTimeToTimeString(maximumTime), 'HH:mm:ss', true)
: undefined
useEffect(() => {
if (minimumTime || maximumTime) validate(time)
}, [JSON.stringify(minimumTime), JSON.stringify(maximumTime)])
useEffect(() => {
if (defaultTime) {
setTime(defaultTime)
validate(defaultTime)
}
}, [defaultTime])
const validate = (time: Time) => {
let error = undefined
const formattedTime = dayjs(formatTimeToTimeString(time), 'HH:mm:ss', true)
if (!formattedTime.isValid()) {
error = 'Please enter a valid time'
} else if (formattedMinimumTime && formattedTime.isBefore(formattedMinimumTime)) {
error = 'Selected time is before the minimum time allowed'
} else if (formattedMaximumTime && formattedTime.isAfter(formattedMaximumTime)) {
error = 'Selected time is after the maximum time allowed'
}
setError(error)
return error === undefined
}
const onFocus = () => setIsFocused(true)
const onInputChange = (event: ChangeEvent<HTMLInputElement>, unit: 'h' | 'm' | 's') => {
if (isNaN(Number(event.target.value))) return
setTime({ ...time, [unit]: event.target.value })
}
const onInputBlur = (event: ChangeEvent<HTMLInputElement>, unit: 'h' | 'm' | 's') => {
const formattedInput = Number(event.target.value)
const updatedTime = { ...time, [unit]: formattedInput }
setTime(updatedTime)
validate(updatedTime)
onChange(updatedTime)
setIsFocused(false)
}
return (
<>
<div
className={[
'flex items-center justify-between transition',
'rounded-md bg-scaleA-200 border px-3.5 py-2 w-[350px]',
`${
isFocused
? 'border-scale-900'
: error === undefined
? 'border-scale-700'
: 'border-red-800'
}`,
].join(' ')}
>
<IconClock className="text-scale-1100" size={18} strokeWidth={1.5} />
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger className="w-1/4">
<input
type="text"
maxLength={2}
pattern="[0-9]*"
placeholder="HH"
value={formatNumberToTwoDigits(time.h)}
onFocus={onFocus}
aria-label="Hours"
onBlur={(event) => onInputBlur(event, 'h')}
onChange={(event) => onInputChange(event, 'h')}
className="w-full text-sm bg-transparent p-0 text-center outline-none border-none focus:ring-0"
/>
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'bg-scale-100 rounded py-1 px-2 leading-none shadow',
'border-scale-200 border',
].join(' ')}
>
<span className="text-scale-1200 text-xs">Hours (HH)</span>
</div>
</Tooltip.Content>
</Tooltip.Root>
<span>:</span>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger className="w-1/4">
<input
type="text"
maxLength={2}
pattern="[0-9]*"
placeholder="MM"
value={formatNumberToTwoDigits(time.m)}
onFocus={onFocus}
aria-label="Minutes"
onBlur={(event) => onInputBlur(event, 'm')}
onChange={(event) => onInputChange(event, 'm')}
className="w-full text-sm bg-transparent p-0 text-center outline-none border-none focus:ring-0"
/>
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'bg-scale-100 rounded py-1 px-2 leading-none shadow',
'border-scale-200 border',
].join(' ')}
>
<span className="text-scale-1200 text-xs">Minutes (MM)</span>
</div>
</Tooltip.Content>
</Tooltip.Root>
<span>:</span>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger className="w-1/4">
<input
type="text"
maxLength={2}
pattern="[0-9]*"
placeholder="SS"
value={formatNumberToTwoDigits(time.s)}
onFocus={onFocus}
aria-label="Seconds"
onBlur={(event) => onInputBlur(event, 's')}
onChange={(event) => onInputChange(event, 's')}
className="w-full text-sm bg-transparent p-0 text-center outline-none border-none focus:ring-0"
/>
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'bg-scale-100 rounded py-1 px-2 leading-none shadow',
'border-scale-200 border',
].join(' ')}
>
<span className="text-scale-1200 text-xs">Seconds (SS)</span>
</div>
</Tooltip.Content>
</Tooltip.Root>
</div>
{error && <p className="text-sm text-red-900">{error}</p>}
</>
)
}
export default TimeInput
@@ -0,0 +1,78 @@
import { FC, useState, FormEvent } from 'react'
import { Listbox, IconSearch, IconGlobe } from '@supabase/ui'
import { ALL_TIMEZONES } from './PITR.constants'
interface Props {
hideLabel?: boolean
dropdownWidth?: string
selectedTimezone: any
onSelectTimezone: (timezone: any) => void
}
const TimezoneSelection: FC<Props> = ({
hideLabel,
dropdownWidth,
selectedTimezone,
onSelectTimezone,
}) => {
const [searchString, setSearchString] = useState<string>('')
const timezoneOptions =
searchString.length > 0
? ALL_TIMEZONES.map((option) => option.text).filter((option) =>
option.toLowerCase().includes(searchString.toLowerCase())
)
: ALL_TIMEZONES.map((option) => option.text)
return (
<div className="flex justify-between space-x-8">
{!hideLabel && <p className="w-2/5 text-sm">Select timezone</p>}
<div className={`${hideLabel ? 'w-full' : 'w-3/5'}`}>
<Listbox
value={selectedTimezone.text}
icon={<IconGlobe />}
onChange={(text) => {
const selectedTimezone = ALL_TIMEZONES.find((option) => option.text === text)
onSelectTimezone(selectedTimezone)
}}
onBlur={() => setSearchString('')}
>
<div
className={[
'fixed top-0 flex w-full items-center',
'rounded-t-md border-b border-gray-600 bg-gray-500',
'mb-4 space-x-2 px-4 py-2',
].join(' ')}
style={{ zIndex: 1 }}
>
<IconSearch size={14} />
<input
autoFocus
className="placeholder-scale-1000 w-72 bg-transparent text-sm outline-none"
value={searchString}
placeholder={''}
onChange={(e: FormEvent<HTMLInputElement>) => setSearchString(e.currentTarget.value)}
/>
</div>
{/* Whitespace to shift listbox options down for searchfield */}
<div className="h-8" />
{timezoneOptions.map((option) => {
return (
<Listbox.Option key={option} label={option} value={option} className={dropdownWidth}>
<div>{option}</div>
</Listbox.Option>
)
})}
{timezoneOptions.length === 0 && (
<Listbox.Option disabled key="no-results" label="" value="">
No timezones found
</Listbox.Option>
)}
</Listbox>
</div>
</div>
)
}
export default TimezoneSelection
@@ -0,0 +1,4 @@
import PITRNotice from './PITRNotice'
import PITRSelection from './PITRSelection'
export { PITRNotice, PITRSelection }
@@ -1,312 +0,0 @@
import dayjs from 'dayjs'
import Link from 'next/link'
import { FC, FormEvent, useState } from 'react'
import { useRouter } from 'next/router'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import {
IconSearch,
IconInfo,
Input,
Listbox,
Popover,
Button,
Modal,
IconAlertCircle,
} from '@supabase/ui'
import { post } from 'lib/common/fetch'
import { API_URL, PROJECT_STATUS } from 'lib/constants'
import { useStore, checkPermissions } from 'hooks'
import Loading from 'components/ui/Loading'
import ConfirmationModal from 'components/ui/ConfirmationModal'
import BackupsError from '../BackupsError'
import BackupsEmpty from '../BackupsEmpty'
import { ALL_TIMEZONES } from './PITRBackupSelection.constants'
import {
getClientTimezone,
getTimezoneOffsetText,
convertTimeStringtoUnixS,
} from './PITRBackupSelection.utils'
import InformationBox from 'components/ui/InformationBox'
interface Props {}
const PITRBackupSelection: FC<Props> = () => {
const router = useRouter()
const { app, ui, backups } = useStore()
const projectId = ui.selectedProject?.id ?? -1
const projectRef = ui.selectedProject?.ref ?? 'default'
const canTriggerPhysicalBackups = checkPermissions(
PermissionAction.INFRA_EXECUTE,
'queue_job.restore.prepare'
)
const [searchString, setSearchString] = useState<string>('')
const [selectedTimezone, setSelectedTimezone] = useState<any>(getClientTimezone())
const [showConfirmation, setShowConfirmation] = useState<boolean>(false)
const [errors, setErrors] = useState<any>()
const [recoveryPoint, setRecoveryPoint] = useState<string>()
const { configuration } = backups
if (backups.isLoading) {
return <Loading />
} else if (backups.error) {
return <BackupsError />
}
const hasPhysicalBackups =
configuration.physicalBackupData.earliestPhysicalBackupDateUnix !== null &&
configuration.physicalBackupData.latestPhysicalBackupDateUnix !== null &&
configuration.physicalBackupData.earliestPhysicalBackupDateUnix !==
configuration.physicalBackupData.latestPhysicalBackupDateUnix
if (!configuration.walg_enabled) {
return (
<div className="block w-full rounded border border-gray-400 border-opacity-50 bg-gray-300 p-3">
<div className="flex space-x-3">
<IconInfo size={20} strokeWidth={1.5} />
<p className="text-sm">
Point in time backups is an Enterprise feature. Reach out to us{' '}
<Link href={`/support/new?ref=${projectRef}&category=sales`}>
<a className="text-brand-900">here</a>
</Link>{' '}
if you're interested!
</p>
</div>
</div>
)
} else if (!hasPhysicalBackups) {
return <BackupsEmpty />
}
const timezoneOptions =
searchString.length > 0
? ALL_TIMEZONES.map((option) => option.text).filter((option) =>
option.toLowerCase().includes(searchString.toLowerCase())
)
: ALL_TIMEZONES.map((option) => option.text)
const earliestAvailableBackup = dayjs
.unix(configuration.physicalBackupData.earliestPhysicalBackupDateUnix)
.tz(selectedTimezone.utc[0])
.format('YYYY-MM-DDTHH:mm:ss')
const latestAvailableBackup = dayjs
.unix(configuration.physicalBackupData.latestPhysicalBackupDateUnix)
.tz(selectedTimezone.utc[0])
.format('YYYY-MM-DDTHH:mm:ss')
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
// Validation
if (!recoveryPoint) {
setErrors({ recoveryPoint: 'Please enter a date to recover your project from' })
} else {
const recoveryTimeTargetUnix = convertTimeStringtoUnixS(recoveryPoint, selectedTimezone)
const { earliestPhysicalBackupDateUnix, latestPhysicalBackupDateUnix } =
configuration.physicalBackupData
const isOutOfRange =
recoveryTimeTargetUnix < earliestPhysicalBackupDateUnix ||
recoveryTimeTargetUnix > latestPhysicalBackupDateUnix
if (isOutOfRange) {
setErrors({
recoveryPoint: 'Selected date is out of range for available points of recovery',
})
} else {
setErrors(undefined)
setShowConfirmation(true)
}
}
}
const onConfirmRestore = async () => {
if (!recoveryPoint) return
const recoveryTimeTargetUnix = convertTimeStringtoUnixS(recoveryPoint, selectedTimezone)
const { error } = await post(`${API_URL}/database/${projectRef}/backups/pitr`, {
recovery_time_target_unix: recoveryTimeTargetUnix,
})
if (error) {
ui.setNotification({
error,
category: 'error',
message: `Point in time recovery for project failed`,
})
setShowConfirmation(false)
} else {
setTimeout(() => {
setShowConfirmation(false)
app.onProjectStatusUpdated(projectId, PROJECT_STATUS.RESTORING)
ui.setNotification({
category: 'success',
message: `Restoring database back to ${dayjs(recoveryPoint).format(
'DD MMM YYYY, HH:mm:ss'
)} (
${getTimezoneOffsetText(selectedTimezone)})`,
})
router.push(`/project/${projectRef}`)
}, 3000)
}
}
return (
<div className="space-y-6">
{!canTriggerPhysicalBackups && (
<InformationBox
icon={<IconAlertCircle className="text-scale-1100" strokeWidth={2} />}
title="You need additional permissions to trigger a scheduled backup"
/>
)}
<form onSubmit={onSubmit}>
<div className="space-y-4">
<div className="space-y-4 rounded border border-gray-400 border-opacity-50 bg-gray-300 py-3">
<div className="space-y-4 py-2">
<div className="flex justify-between space-x-8">
<p className="w-2/5 pl-4 text-sm">Select timezone</p>
<div className="w-3/5 px-4">
<Listbox
value={selectedTimezone.text}
onChange={(text) => {
const selectedTimezone = ALL_TIMEZONES.find((option) => option.text === text)
setSelectedTimezone(selectedTimezone)
}}
onBlur={() => setSearchString('')}
>
<div
className={[
'fixed top-0 flex w-full items-center',
'rounded-t-md border-b border-gray-600 bg-gray-500',
'mb-4 space-x-2 px-4 py-2',
].join(' ')}
style={{ zIndex: 1 }}
>
<IconSearch size={14} />
<input
autoFocus
className="placeholder-scale-1000 w-72 bg-transparent text-sm outline-none"
value={searchString}
placeholder={''}
onChange={(e: FormEvent<HTMLInputElement>) =>
setSearchString(e.currentTarget.value)
}
/>
</div>
{/* Whitespace to shift listbox options down for searchfield */}
<div className="h-8" />
{timezoneOptions.map((option) => {
return (
<Listbox.Option key={option} label={option} value={option}>
<div>{option}</div>
</Listbox.Option>
)
})}
{timezoneOptions.length === 0 && (
<Listbox.Option disabled key="no-results" label="" value="">
No timezones found
</Listbox.Option>
)}
</Listbox>
</div>
</div>
<div className="flex justify-between space-x-8">
<div className="w-2/5 space-y-2 pl-4">
<p className="text-sm">Earliest point of recovery</p>
</div>
<div className="w-3/5">
<Input
readOnly
disabled
step={1}
type="datetime-local"
className="px-4"
value={earliestAvailableBackup}
/>
</div>
</div>
<div className="flex justify-between space-x-8">
<div className="w-2/5 space-y-2 pl-4">
<p className="text-sm">Latest point of recovery</p>
</div>
<div className="w-3/5">
<Input
readOnly
disabled
step={1}
type="datetime-local"
className="px-4"
value={latestAvailableBackup}
/>
</div>
</div>
</div>
<Popover.Seperator />
<div className="flex justify-between space-x-8 py-2">
<div className="w-2/5 space-y-2 pl-4">
<p className="text-sm">Recovery point</p>
<p className="text-scale-1100 text-sm">
Select a date and time that you would like to restore your project to
</p>
</div>
<div className="w-3/5">
<Input
step={1}
type="datetime-local"
error={errors?.recoveryPoint}
className="px-4"
onChange={(e) => {
setErrors(undefined)
setRecoveryPoint(e.target.value)
}}
/>
</div>
</div>
<Popover.Seperator />
<div className="flex items-center justify-end px-4">
<Button type="default" htmlType="submit">
Restore
</Button>
</div>
</div>
</div>
</form>
<ConfirmationModal
visible={showConfirmation}
size="medium"
header="Confirm to restore"
children={
<Modal.Content>
<div className="space-y-2 py-4">
<div className="space-y-1">
<p className="text-scale-1100 text-sm">
Are you sure you want to restore your database from:
</p>
<p className="text-scale-1200 text-sm">
{dayjs(recoveryPoint).format('DD MMM YYYY, HH:mm:ss')} (
{getTimezoneOffsetText(selectedTimezone)})?
</p>
</div>
<p className="text-scale-1100 text-sm">
This will destroy any new data written since this backup was made.
</p>
</div>
</Modal.Content>
}
buttonLabel="Restore"
buttonLoadingLabel="Restoring"
onSelectCancel={() => setShowConfirmation(false)}
onSelectConfirm={onConfirmRestore}
/>
</div>
)
}
export default PITRBackupSelection
@@ -1,27 +0,0 @@
import dayjs from 'dayjs'
import { ALL_TIMEZONES } from './PITRBackupSelection.constants'
import { Timezone } from './PITRBackupSelection.types'
export const getClientTimezone = () => {
const defaultTz = dayjs.tz.guess()
const timezone = ALL_TIMEZONES.find((option) => {
if (option.utc.includes(defaultTz)) return option
else return undefined
})
return timezone
}
export const getTimezoneOffsetText = (timezone: Timezone) => {
return timezone.text.split(')')[0].slice(1)
}
export const getTimezoneOffset = (timezone: Timezone) => {
return timezone.text.split(')')[0].slice(4)
}
export const convertTimeStringtoUnixS = (datetime: string, timezone: Timezone) => {
// Datetime in the format of YYYY-MM-DDTHH:mm:ss (from datetime-local input)
const timezoneOffset = getTimezoneOffset(timezone)
const datetimestringWithTimezone = `${datetime}${timezoneOffset}`
return dayjs(datetimestringWithTimezone).unix()
}
@@ -1,2 +0,0 @@
import PITRBackupSelection from './PITRBackupSelection'
export default PITRBackupSelection
@@ -10,7 +10,6 @@ import PublicationsList from './Publications/PublicationsList'
import PublicationsTables from './Publications/PublicationsTables'
import BackupsList from './Backups/BackupsList'
import PITRBackupSelection from './Backups/PITRBackupSelection'
import ConnectionPooling from './Pooling/ConnectionPooling'
@@ -29,7 +28,6 @@ export {
PublicationsList,
PublicationsTables,
BackupsList,
PITRBackupSelection,
ConnectionPooling,
CreateTrigger,
DeleteTrigger,
@@ -153,7 +153,7 @@ const LogTable = ({
}, [stringData])
const LogsExplorerTableHeader = () => (
<div className="w-full bg-scale-100 dark:bg-scale-300 rounded-tl rounded-tr border-t border-l border-r flex items-center justify-between px-5 py-2">
<div className="flex w-full items-center justify-between rounded-tl rounded-tr border-t border-l border-r bg-scale-100 px-5 py-2 dark:bg-scale-300">
<div className="flex items-center gap-2">
{data && data.length ? (
<>
@@ -194,7 +194,7 @@ const LogTable = ({
}
return (
<div className="flex justify-center px-5 w-1/2">
<div className="flex w-1/2 justify-center px-5">
<Alert variant="danger" title="Sorry! An error occured when fetching data." withIcon>
<Renderer {...childProps} />
</Alert>
@@ -203,11 +203,11 @@ const LogTable = ({
}
const renderNoResultAlert = () => (
<div className="flex flex-col items-center gap-6 text-center mt-16 opacity-100 scale-100 justify-center">
<div className="mt-16 flex scale-100 flex-col items-center justify-center gap-6 text-center opacity-100">
<div className="flex flex-col gap-1">
<div className="relative border border-scale-600 border-dashed dark:border-scale-900 w-32 h-4 rounded px-2 flex items-center"></div>
<div className="relative border border-scale-600 border-dashed dark:border-scale-900 w-32 h-4 rounded px-2 flex items-center">
<div className="absolute right-1 -bottom-4">
<div className="relative flex h-4 w-32 items-center rounded border border-dashed border-scale-600 px-2 dark:border-scale-900"></div>
<div className="relative flex h-4 w-32 items-center rounded border border-dashed border-scale-600 px-2 dark:border-scale-900">
<div className="absolute right-1 -bottom-4 text-scale-1100">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
@@ -235,16 +235,16 @@ const LogTable = ({
return (
<>
<section
className={'flex flex-col w-full ' + (!queryType ? 'shadow-lg' : '')}
className={'flex w-full flex-col ' + (!queryType ? 'shadow-lg' : '')}
style={{ maxHeight }}
>
{!queryType && <LogsExplorerTableHeader />}
<div className={`flex flex-row h-full ${!queryType ? 'border-l border-r' : ''}`}>
<div className={`flex h-full flex-row ${!queryType ? 'border-l border-r' : ''}`}>
<DataGrid
style={{ height: '100%' }}
className={`
flex-grow flex-1
${!queryType ? 'data-grid--logs-explorer' : ' data-grid--simple-logs'}
flex-1 flex-grow
${!queryType ? 'data-grid--logs-explorer' : ' data-grid--simple-logs'}
`}
rowHeight={40}
headerRowHeight={queryType ? 0 : 28}
@@ -254,7 +254,7 @@ const LogTable = ({
}}
noRowsFallback={
!isLoading ? (
<div className="transition-all duration-500 delay-200 py-4 flex w-full h-full justify-center items-center mx-auto space-y-12">
<div className="mx-auto flex h-full w-full items-center justify-center space-y-12 py-4 transition-all delay-200 duration-500">
{!error && renderNoResultAlert()}
{error && renderErrorAlert()}
</div>
@@ -276,10 +276,10 @@ const LogTable = ({
<div
className={
queryType
? 'w-1/2 flex flex-col'
? 'flex w-1/2 flex-col'
: focusedLog
? 'w-1/2 flex flex-col'
: 'w-0 hidden'
? 'flex w-1/2 flex-col'
: 'hidden w-0'
}
>
<LogSelection
@@ -57,15 +57,10 @@ export const uploadAttachments = async (ref: string, files: File[]) => {
}
export const formatMessage = (message: string, attachments: string[]) => {
const formattedMessage = message.replace(/\n/g, '<br/>')
if (attachments.length > 0) {
const attachmentsImg = attachments.map(
(url) => `<img src=${url} width="200" style="margin-right:10px" />`
)
return `${formattedMessage}<br/><hr/><b>Uploaded attachments:</b><br/><div style="display:flex;align-items:center">${attachmentsImg.join(
''
)}</div>`
const attachmentsImg = attachments.map((url) => `\n${url}`)
return `${message}\n${attachmentsImg.join('')}`
} else {
return formattedMessage
return message
}
}
@@ -220,6 +220,11 @@ export const unescapeLiteral = (value: string) => {
return `${splits[0].toLowerCase()}')`
}
// Handle nextval
if (value.toLowerCase().includes('nextval')) {
return `${splits[0]}')`
}
// Handle json
if (value.toLowerCase().includes('json')) {
return splits[0].slice(1)
@@ -72,7 +72,13 @@ const InputField: FC<Props> = ({
error={errors[field.name]}
onChange={(event: any) => onUpdateField({ [field.name]: event.target.value })}
actions={
<Button type="default" htmlType="button" onClick={onViewForeignKey} icon={<IconLink />}>
<Button
disabled={field.value.length === 0}
type="default"
htmlType="button"
onClick={onViewForeignKey}
icon={<IconLink />}
>
View data
</Button>
}
@@ -75,14 +75,6 @@ export const validateFields = (fields: RowField[]) => {
}
}
if (field.isIdentity || field.defaultValue) return
if (
!field.isNullable &&
typeof field.value === 'string' &&
field.value.length === 0 &&
field.defaultValue === null
) {
errors[field.name] = `Please assign a value for this field`
}
})
return errors
}
@@ -12,9 +12,12 @@ import { getWithTimeout } from 'lib/common/fetch'
import { Project } from 'types'
import { DisplayApiSettings, DisplayConfigSettings } from 'components/ui/ProjectSettings'
type ProjectBuildingState = { project: Project }
const ProjectBuildingState: FC<ProjectBuildingState> = ({ project }) => {
const { app } = useStore()
interface Props {
project: Project
}
const BuildingState: FC<Props> = ({ project }) => {
const { ui, app } = useStore()
const checkServerInterval = useRef<number>()
async function checkServer() {
@@ -150,7 +153,7 @@ const ProjectBuildingState: FC<ProjectBuildingState> = ({ project }) => {
</div>
)
}
export default observer(ProjectBuildingState)
export default observer(BuildingState)
const ChecklistItem = ({ description }: any) => {
return (
@@ -12,6 +12,7 @@ import LayoutHeader from './LayoutHeader'
import ConnectingState from './ConnectingState'
import PausingState from './PausingState'
import BuildingState from './BuildingState'
import RestoringState from './RestoringState'
interface Props {
title?: string
@@ -112,6 +113,7 @@ const ContentWrapper: FC<ContentWrapperProps> = observer(({ isLoading, children
const requiresDbConnection: boolean = router.pathname !== '/project/[ref]/settings/general'
const requiresPostgrestConnection = !routesToIgnorePostgrestConnection.includes(router.pathname)
const isProjectRestoring = ui.selectedProject?.status === PROJECT_STATUS.RESTORING
const isProjectBuilding = [PROJECT_STATUS.COMING_UP, PROJECT_STATUS.RESTORING].includes(
ui.selectedProject?.status ?? ''
)
@@ -126,6 +128,8 @@ const ContentWrapper: FC<ContentWrapperProps> = observer(({ isLoading, children
<PausingState project={ui.selectedProject} />
) : requiresPostgrestConnection && isProjectOffline ? (
<ConnectingState project={ui.selectedProject} />
) : requiresDbConnection && isProjectRestoring ? (
<RestoringState />
) : requiresDbConnection && isProjectBuilding ? (
<BuildingState project={ui.selectedProject} />
) : (
@@ -0,0 +1,89 @@
import { FC, useEffect, useRef, useState } from 'react'
import { Button, IconCheckCircle, IconLoader } from '@supabase/ui'
import { useStore } from 'hooks'
import { API_URL, PROJECT_STATUS } from 'lib/constants'
import { getWithTimeout } from 'lib/common/fetch'
interface Props {}
const RestoringState: FC<Props> = ({}) => {
const { app, ui } = useStore()
const project = ui.selectedProject
const checkServerInterval = useRef<number>()
const [loading, setLoading] = useState(false)
const [isCompleted, setIsCompleted] = useState(false)
useEffect(() => {
checkServerInterval.current = window.setInterval(checkServer, 4000)
return () => clearInterval(checkServerInterval.current)
}, [])
async function checkServer() {
if (!project) return
const projectStatus = await getWithTimeout(`${API_URL}/projects/${project.ref}/status`, {
timeout: 2000,
})
if (projectStatus && !projectStatus.error) {
const { status } = projectStatus
if (status === PROJECT_STATUS.ACTIVE_HEALTHY) {
clearInterval(checkServerInterval.current)
setIsCompleted(true)
}
}
}
const onConfirm = async () => {
setLoading(true)
await app.projects.fetchDetail(project?.ref ?? '')
}
return (
<div className="flex items-center justify-center h-full">
<div
className={[
'grid grid-cols-12 gap-4',
'bg-scale-300 border border-scale-400 rounded-md w-3/4 lg:w-1/2 px-8 py-6',
].join(' ')}
>
{isCompleted ? (
<>
<div className="col-span-1">
<IconCheckCircle className="text-brand-900" strokeWidth={2} />
</div>
<div className="col-span-11 space-y-1">
<p>Restoration complete!</p>
<p className="text-sm text-scale-1100">
Your project has been successfully restored and is now back online.
</p>
</div>
</>
) : (
<>
<div className="col-span-1">
<IconLoader className="animate-spin" />
</div>
<div className="col-span-11 space-y-1">
<p>Restoration in progress</p>
<p className="text-sm text-scale-1100">
Restoration can take from a few minutes up to several hours depending on the size of
your database. Your project will be offline while the restoration is running.
</p>
</div>
</>
)}
{isCompleted && (
<div className="col-start-2 col-span-11">
<Button disabled={loading} loading={loading} onClick={onConfirm}>
Return to project
</Button>
</div>
)}
</div>
</div>
)
}
export default RestoringState
+21 -19
View File
@@ -35,7 +35,7 @@ const InformationBox: FC<Props> = ({
>
<div className="flex flex-col px-4">
<div className="flex items-center justify-between">
<div className="flex w-full space-x-3 lg:items-center">
<div className="flex w-full space-x-3 lg:items-start">
{icon && <span className="text-scale-900">{icon}</span>}
<div className="flex-grow">
<h5 className="text-scale-1200 text-sm">{title}</h5>
@@ -54,26 +54,28 @@ const InformationBox: FC<Props> = ({
</div>
) : null}
</div>
<div
className={`flex flex-col space-y-3 overflow-hidden transition-all ${
isExpanded ? 'mt-3' : ''
}`}
style={{ maxHeight: isExpanded ? 500 : 0 }}
>
<div className="text-scale-1100 text-sm">{description}</div>
{(description || url || button) && (
<div
className={`flex flex-col space-y-3 overflow-hidden transition-all ${
isExpanded ? 'mt-3' : ''
}`}
style={{ maxHeight: isExpanded ? 500 : 0 }}
>
<div className="text-scale-1100 text-sm">{description}</div>
{url && (
<a
href={url}
target="_blank"
className="text-scale-1100 hover:text-scale-1200 text-sm underline transition-colors"
>
{urlLabel}
</a>
)}
{url && (
<a
href={url}
target="_blank"
className="text-scale-1100 hover:text-scale-1200 text-sm underline transition-colors"
>
{urlLabel}
</a>
)}
{button && <div>{button}</div>}
</div>
{button && <div>{button}</div>}
</div>
)}
</div>
</div>
)
+1 -5
View File
@@ -38,11 +38,7 @@ const PasswordStrengthBar = ({
></div>
</div>
)}
<p
className={
passwordStrengthScore >= DEFAULT_MINIMUM_PASSWORD_STRENGTH ? 'text-green-600' : ''
}
>
<p>
{passwordStrengthMessage
? passwordStrengthMessage
: 'This is the password to your postgres database, so it must be a strong password and hard to guess.'}{' '}
-51289
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -29,7 +29,7 @@
"@supabase/react-data-grid": "^7.1.0-beta.7",
"@supabase/shared-types": "^0.1.25",
"@supabase/supabase-js": "^2.0.0-rc.2",
"@supabase/ui": "0.37.0-alpha.80",
"@supabase/ui": "0.37.0-alpha.81",
"ajv": "^8.6.3",
"analytics-node": "^3.5.0",
"awesome-debounce-promise": "^2.1.0",
@@ -59,7 +59,7 @@
"react-beautiful-dnd": "^13.1.0",
"react-contexify": "^5.0.0",
"react-csv": "^2.0.3",
"react-datepicker": "^4.6.0",
"react-datepicker": "^4.8.0",
"react-dnd": "^14.0.3",
"react-dnd-html5-backend": "^14.0.1",
"react-dom": "^17.0.2",
@@ -1,22 +1,22 @@
import Link from 'next/link'
import { useRouter } from 'next/router'
import { observer } from 'mobx-react-lite'
import { Tabs } from '@supabase/ui'
import { IconInfo, Tabs } from '@supabase/ui'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { NextPageWithLayout } from 'types'
import { useStore, checkPermissions } from 'hooks'
import { DatabaseLayout } from 'components/layouts'
import { PITRBackupSelection } from 'components/interfaces/Database'
import { NextPageWithLayout } from 'types'
import Loading from 'components/ui/Loading'
import NoPermission from 'components/ui/NoPermission'
import { PITRNotice, PITRSelection } from 'components/interfaces/Database/Backups/PITR'
import BackupsError from 'components/interfaces/Database/Backups/BackupsError'
const DatabaseScheduledBackups: NextPageWithLayout = () => {
const DatabasePhysicalBackups: NextPageWithLayout = () => {
const { ui } = useStore()
const router = useRouter()
const ref = ui.selectedProject?.ref ?? 'default'
const canReadPhysicalBackups = checkPermissions(PermissionAction.READ, 'physical_backups')
return (
<div className="mx-auto max-w-4xl space-y-6 px-5 pt-12 pb-20">
<h3 className="text-scale-1200 text-xl">Backups</h3>
@@ -28,26 +28,55 @@ const DatabaseScheduledBackups: NextPageWithLayout = () => {
if (id === 'scheduled') router.push(`/project/${ref}/database/backups/scheduled`)
}}
>
<Tabs.Panel id="scheduled" label="Scheduled" />
<Tabs.Panel id="scheduled" label="Scheduled backups" />
<Tabs.Panel id="pitr" label="Point in Time" />
</Tabs>
<div className="space-y-4">
<p className="text-scale-1100 text-sm">
Restore your project from a specific date and time.
</p>
{canReadPhysicalBackups ? (
<PITRBackupSelection />
) : (
<NoPermission resourceText="view PITR backups" />
)}
<div className="space-y-8">
<PITR />
</div>
</div>
)
}
DatabaseScheduledBackups.getLayout = (page) => (
DatabasePhysicalBackups.getLayout = (page) => (
<DatabaseLayout title="Database">{page}</DatabaseLayout>
)
export default observer(DatabaseScheduledBackups)
const PITR = () => {
const { ui, backups } = useStore()
const { configuration, error, isLoading } = backups
const ref = ui.selectedProject?.ref ?? 'default'
const isEnabled = configuration.walg_enabled
const canReadPhysicalBackups = checkPermissions(PermissionAction.READ, 'physical_backups')
if (!canReadPhysicalBackups) return <NoPermission resourceText="view PITR backups" />
if (isLoading) return <Loading />
if (error) return <BackupsError />
if (!isEnabled)
return (
<div className="block w-full rounded border border-gray-400 border-opacity-50 bg-gray-300 p-3">
<div className="flex space-x-3">
<IconInfo size={20} strokeWidth={1.5} />
<p className="text-sm">
Point in time backups is an Enterprise feature. Reach out to us{' '}
<Link href={`/support/new?ref=${ref}&category=sales`}>
<a className="text-brand-900">here</a>
</Link>{' '}
if you're interested!
</p>
</div>
</div>
)
return (
<>
<PITRNotice />
<PITRSelection />
</>
)
}
export default observer(DatabasePhysicalBackups)
@@ -27,7 +27,7 @@ const DatabaseScheduledBackups: NextPageWithLayout = () => {
if (id === 'pitr') router.push(`/project/${ref}/database/backups/pitr`)
}}
>
<Tabs.Panel id="scheduled" label="Scheduled" />
<Tabs.Panel id="scheduled" label="Scheduled backups" />
<Tabs.Panel id="pitr" label="Point in Time" />
</Tabs>
+6 -1
View File
@@ -88,7 +88,8 @@
@apply bg-scale-200;
}
.react-datepicker__day--selected {
.react-datepicker__day--selected,
.react-datepicker__day--selected.react-datepicker__day--highlighted {
@apply bg-brand-800 text-white;
}
@@ -99,3 +100,7 @@
.react-datepicker__day--range-end {
@apply bg-brand-900 text-white hover:text-scale-700 hover:bg-white;
}
.react-datepicker__day--highlighted {
@apply bg-brand-600 text-scale-1100;
}
+4
View File
@@ -126,6 +126,10 @@ input[type='submit']:hover {
@apply btn-primary-hover;
}
input::placeholder {
@apply text-scale-900;
}
.form-group input::placeholder {
@apply text-scale-900;
}
+4
View File
@@ -326,3 +326,7 @@
// fill: #eeeeee;
}
}
.pitr-listbox {
@apply md:gap-x-2;
}
@@ -1,6 +1,11 @@
import { unescapeLiteral } from 'components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils'
describe('ColumnEditor.utils: unescapeLiteral', () => {
test('should return varchar properly', () => {
const mockInput = `nextval('"Test_id_seq"'::regclass)`
const value = unescapeLiteral(mockInput)
expect(value).toStrictEqual(`nextval('"Test_id_seq"')`)
})
test('should return varchar properly', () => {
const mockInput = "'potato'::character varying"
const value = unescapeLiteral(mockInput)
@@ -1,41 +0,0 @@
import { validateFields } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils'
describe('RowEditor.utils: validateFields', () => {
test('should validate for non-nullable fields correctly', () => {
const mockFields = [
{
name: 'name',
value: '',
format: 'text',
defaultValue: null,
isNullable: false,
},
{
name: 'age',
value: 0,
format: 'int8',
defaultValue: null,
isNullable: false,
},
{
name: 'height',
value: '',
format: 'int2',
defaultValue: null,
isNullable: false,
},
{
name: 'weight',
value: '',
format: 'int2',
defaultValue: null,
isNullable: true,
},
]
const res = validateFields(mockFields)
expect(res).toHaveProperty('name')
expect(res).not.toHaveProperty('age')
expect(res).toHaveProperty('height')
expect(res).not.toHaveProperty('weight')
})
})
+5
View File
@@ -5,6 +5,11 @@ export interface Notification {
error?: any // Optional: Any other errors that needs to be logged out in the console
progress?: number // Optional: For loading messages to show a progress bar (Out of 100)
duration?: number // Optional: How long to show the message for (ms)
metadata?: NotificationMetadata
}
interface NotificationMetadata {
[key: string]: any
}
export interface ChartIntervals {