diff --git a/.github/workflows/publish_image.yml b/.github/workflows/publish_image.yml index 78b5855e158..8ed1db87295 100644 --- a/.github/workflows/publish_image.yml +++ b/.github/workflows/publish_image.yml @@ -2,17 +2,18 @@ name: Publish to Image Registry on: push: - branches: - - studio - paths: - - 'studio/**' tags: - - 'v*' + - '*' workflow_dispatch: + inputs: + version: + description: 'Image tag' + required: true + type: string jobs: publish: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - id: meta uses: docker/metadata-action@v4 @@ -21,9 +22,10 @@ jobs: supabase/studio public.ecr.aws/t3w2s2c9/studio flavor: | - latest=true + latest=false tags: | type=ref,event=tag + type=raw,value=${{ inputs.version }},enable=${{ github.event_name != 'push' }} - uses: docker/setup-qemu-action@v2 with: @@ -47,7 +49,7 @@ jobs: - uses: docker/build-push-action@v3 with: push: true - context: studio + context: '{{defaultContext}}:studio' target: production platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} diff --git a/apps/reference/docs/about.mdx b/apps/reference/docs/about.mdx index 322389988a4..aa1142a407c 100755 --- a/apps/reference/docs/about.mdx +++ b/apps/reference/docs/about.mdx @@ -110,6 +110,16 @@ You can use it completely, or just the features you need. style={{ height: '100%' }} /> + {/* Realtime */} +
+ +
{/* File Storage */}
- {/* Realtime */} -
- -
diff --git a/apps/reference/docs/guides/with-solidjs.mdx b/apps/reference/docs/guides/with-solidjs.mdx index 931de124084..93395c57083 100644 --- a/apps/reference/docs/guides/with-solidjs.mdx +++ b/apps/reference/docs/guides/with-solidjs.mdx @@ -163,14 +163,14 @@ Let's start building the SolidJS app from scratch. We can use [Degit](https://github.com/Rich-Harris/degit) to initialize an app called `supabase-solid`: ```bash -npx degit solidjs/templates/js supabase-solid +npx degit solidjs/templates/ts supabase-solid cd supabase-solid ``` Then let's install the only additional dependency: [supabase-js](https://github.com/supabase/supabase-js) ```bash -npm install @supabase/supabase-js +npm install @supabase/supabase-js@rc ``` And finally we want to save the environment variables in a `.env`. @@ -200,7 +200,7 @@ You can find the full contents of this file [here](https://raw.githubusercontent Let's set up a SolidJS component to manage logins and sign ups. We'll use Magic Links, so users can sign in with their email without using passwords. -```jsx title="/src/Auth.jsx" +```jsx title="/src/Auth.tsx" import { createSignal } from 'solid-js' import { supabase } from './supabaseClient' @@ -208,46 +208,46 @@ export default function Auth() { const [loading, setLoading] = createSignal(false) const [email, setEmail] = createSignal('') - const handleLogin = async (e) => { + const handleLogin = async (e: SubmitEvent) => { e.preventDefault() try { setLoading(true) - const { error } = await supabase.auth.signIn({ email: email() }) + const { error } = await supabase.auth.signInWithOtp({ email: email() }) if (error) throw error alert('Check your email for the login link!') } catch (error) { - alert(error.error_description || error.message) + if (error instanceof Error) { + alert(error.message) + } } finally { setLoading(false) } } return ( -
-
-

Supabase + SolidJS

-

- Sign in via magic link with your email below -

- {loading() ? ( - 'Sending magic link...' - ) : ( -
- +
+
+

Supabase + SolidJS

+

Sign in via magic link with your email below

+ +
+ setEmail(e.target.value)} + onChange={(e) => setEmail(e.currentTarget.value)} /> -
+
+ - - )} +
+
) @@ -258,27 +258,31 @@ export default function Auth() { After a user is signed in we can allow them to edit their profile details and manage their account. -Let's create a new component for that called `Account.js`. +Let's create a new component for that called `Account.tsx`. -```jsx title="src/Account.jsx" -import { createSignal, createEffect } from 'solid-js' +```jsx title="src/Account.tsx" +import { AuthSession } from '@supabase/supabase-js' +import { Component, createEffect, createSignal } from 'solid-js' import { supabase } from './supabaseClient' -const Account = (props) => { +interface Props { + session: AuthSession +} + +const Account: Component = ({ session }) => { const [loading, setLoading] = createSignal(true) - const [username, setUsername] = createSignal(null) - const [website, setWebsite] = createSignal(null) - const [avatar_url, setAvatarUrl] = createSignal(null) + const [username, setUsername] = createSignal(null) + const [website, setWebsite] = createSignal(null) + const [avatarUrl, setAvatarUrl] = createSignal(null) createEffect(() => { - props.session getProfile() }) const getProfile = async () => { try { setLoading(true) - const user = supabase.auth.user() + const { user } = session let { data, error, status } = await supabase .from('profiles') @@ -296,36 +300,38 @@ const Account = (props) => { setAvatarUrl(data.avatar_url) } } catch (error) { - alert(error.message) + if (error instanceof Error) { + alert(error.message) + } } finally { setLoading(false) } } - const updateProfile = async (e) => { + const updateProfile = async (e: Event) => { e.preventDefault() try { setLoading(true) - const user = supabase.auth.user() + const { user } = session const updates = { id: user.id, username: username(), website: website(), - avatar_url: avatar_url(), - updated_at: new Date(), + avatar_url: avatarUrl(), + updated_at: new Date().toISOString(), } - let { error } = await supabase.from('profiles').upsert(updates, { - returning: 'minimal', // Don't return the value after inserting - }) + let { error } = await supabase.from('profiles').upsert(updates) if (error) { throw error } } catch (error) { - alert(error.message) + if (error instanceof Error) { + alert(error.message) + } } finally { setLoading(false) } @@ -333,47 +339,35 @@ const Account = (props) => { return (
- {loading() ? ( - 'Saving ...' - ) : ( -
-
Email: {props.session.user.email}
-
- - setUsername(e.target.value)} - /> -
-
- - setWebsite(e.target.value)} - /> -
-
- -
-
- )} - +
+
Email: {session.user.email}
+
+ + setUsername(e.currentTarget.value)} + /> +
+
+ + setWebsite(e.currentTarget.value)} + /> +
+
+ +
+ +
) } @@ -383,20 +377,22 @@ export default Account ### Launch! -Now that we have all the components in place, let's update `App.js`: +Now that we have all the components in place, let's update `App.tsx`: -```jsx title="src/App.jsx" -import './index.css' -import { createSignal, createEffect } from 'solid-js' +```jsx title="src/App.tsx" +import { Component, createEffect, createSignal } from 'solid-js' import { supabase } from './supabaseClient' -import Auth from './Auth' +import { AuthSession } from '@supabase/supabase-js' import Account from './Account' +import Auth from './Auth' -export default () => { - const [session, setSession] = createSignal(null) +const App: Component = () => { + const [session, setSession] = createSignal(null) createEffect(() => { - setSession(supabase.auth.session()) + supabase.auth.getSession().then(({ data: { session } }) => { + setSession(session) + }) supabase.auth.onAuthStateChange((_event, session) => { setSession(session) @@ -404,15 +400,13 @@ export default () => { }) return ( -
- {!session() ? ( - - ) : ( - - )} +
+ {!session() ? : }
) } + +export default App ``` Once that's done, run this in a terminal window: @@ -433,49 +427,54 @@ Every Supabase project is configured with [Storage](/docs/guides/storage) for ma Let's create an avatar for the user so that they can upload a profile photo. We can start by creating a new component: -```jsx title="src/Avatar.jsx" -import { createEffect, createSignal } from 'solid-js' +```jsx title="src/Avatar.tsx" +import { Component, createEffect, createSignal, JSX } from 'solid-js' import { supabase } from './supabaseClient' -export default (props) => { - const [avatarUrl, setAvatarUrl] = createSignal(null) +interface Props { + size: number + url: string | null + onUpload: (event: Event, filePath: string) => void +} + +const Avatar: Component = (props) => { + const [avatarUrl, setAvatarUrl] = createSignal(null) const [uploading, setUploading] = createSignal(false) createEffect(() => { - if (props.url()) downloadImage(props.url()) + if (props.url) downloadImage(props.url) }) - const downloadImage = async (path) => { + const downloadImage = async (path: string) => { try { - const { data, error } = await supabase.storage - .from('avatars') - .download(path) + const { data, error } = await supabase.storage.from('avatars').download(path) if (error) { throw error } const url = URL.createObjectURL(data) setAvatarUrl(url) } catch (error) { - console.log('Error downloading image: ', error.message) + if (error instanceof Error) { + console.log('Error downloading image: ', error.message) + } } } - const uploadAvatar = async (event) => { + const uploadAvatar: JSX.EventHandler = async (event) => { try { setUploading(true) - if (!event.target.files || event.target.files.length === 0) { + const target = event.currentTarget + if (!target?.files || target.files.length === 0) { throw new Error('You must select an image to upload.') } - const file = event.target.files[0] + const file = target.files[0] const fileExt = file.name.split('.').pop() const fileName = `${Math.random()}.${fileExt}` const filePath = `${fileName}` - let { error: uploadError } = await supabase.storage - .from('avatars') - .upload(filePath, file) + let { error: uploadError } = await supabase.storage.from('avatars').upload(filePath, file) if (uploadError) { throw uploadError @@ -483,7 +482,9 @@ export default (props) => { props.onUpload(event, filePath) } catch (error) { - alert(error.message) + if (error instanceof Error) { + alert(error.message) + } } finally { setUploading(false) } @@ -491,56 +492,57 @@ export default (props) => { return (
- {avatarUrl() - {uploading() ? ( - 'Uploading...' + {avatarUrl() ? ( + {avatarUrl() ) : ( - <> - - - - - +
)} +
+ + + + +
) } + +export default Avatar ``` ### Add the new widget And then we can add the widget to the Account page: -```jsx title="src/Account.jsx" +```jsx title="src/Account.tsx" // Import the new component import Avatar from './Avatar' // ... return ( -
+
{/* Add to the body */} { + onUpload={(e: Event, url: string) => { setAvatarUrl(url) updateProfile(e) }} diff --git a/apps/reference/nav/_referenceSidebars.js b/apps/reference/nav/_referenceSidebars.js index daeefbcfa4f..94dd36931b5 100644 --- a/apps/reference/nav/_referenceSidebars.js +++ b/apps/reference/nav/_referenceSidebars.js @@ -156,6 +156,17 @@ const sidebars = { collapsed: true, items: ['guides/functions'], }, + { + type: 'category', + label: 'Realtime', + collapsed: true, + items: [ + 'guides/realtime', + 'guides/realtime/broadcast', + 'guides/realtime/presence', + 'guides/realtime/postgres-changes', + ], + }, { type: 'category', label: 'Storage', @@ -174,17 +185,6 @@ const sidebars = { 'guides/platform/permissions', ], }, - { - type: 'category', - label: 'Realtime', - collapsed: true, - items: [ - 'guides/realtime', - 'guides/realtime/broadcast', - 'guides/realtime/presence', - 'guides/realtime/postgres-changes', - ], - }, { type: 'category', label: 'Self Hosting', diff --git a/apps/www/_blog/2022-08-24-building-a-realtime-trello-board-with-supabase-and-angular.mdx b/apps/www/_blog/2022-08-24-building-a-realtime-trello-board-with-supabase-and-angular.mdx new file mode 100644 index 00000000000..9d4c694b4ee --- /dev/null +++ b/apps/www/_blog/2022-08-24-building-a-realtime-trello-board-with-supabase-and-angular.mdx @@ -0,0 +1,1377 @@ +--- +title: 'Building a Realtime Trello Board with Supabase and Angular' +description: Go beyond the hello world example with this real world project. +author: simon_grimm +image: simon-grimm-angular-realtime/angular-supabase.png +thumb: simon-grimm-angular-realtime/angular-supabase.png +tags: + - angular + - realtime + - community +date: '2022-08-24' +toc_depth: 2 +--- + +Everyone can code up a little hello world example quickly with a platform like [Supabase](https://supabase.com/) - but what about a _real world_ project of bigger scale? + +That's exactly what you will learn in this article: + +We are building a Trello board with Supabase, Angular and Tailwind! + +![Supabase Trello Example](/images/blog/simon-grimm-angular-realtime/board.png) + +Along our jurney we will: + +- write some **advanced SQL** to create our tables +- implement **magic link sign in** and user authentication with Angular +- make use of **the realtime capabilities**! + + Watch the video version of the tutorial. + + +Since there are quite some code snippets we need I've put together the [full source code on Github](https://github.com/saimon24/supabase-trello-angular) so you can easily run the project yourself! + +If you are not familiar with [Trello](https://trello.com/), it's a way to manage projects with different boards, lists and cards! + +Ready for a wild adventure? Then let's begin inside our Supabase account! + +## Creating the Supabase Project + +First of all we need a new Supabase project. If you don't have a Supabase account yet, you can [get started for free](https://app.supabase.com/)! + +In your dashboard, click "New Project" and leave it to the default settings, but make sure you keep a copy o your Database password! + +The only thing we will change manually for now is disabling the email confirmation step. By doing this, users will be directly able to sign in when using the magic link, so go to the **Authentication** tab of your project, select **Settings** and scroll down to your **Auth Providers** where you can disable it. + +![Disable Email confirm](/images/blog/simon-grimm-angular-realtime/disable-email-confirm.png) + +Everything else regarding authentication[https://supabase.com/docs/guides/auth] is handled by Supabase and we don't need to worry about it at the moment! + +## Defining your Tables with SQL + +Since Supabase uses Postgres under the hood, we need to write some SQL to define our tables. + +Let's start with something easy, which is the general definition of our tables: + +- `boards`: Keep track of user created boards +- `lists`: The lists within one board +- `cards`: The cards with tasks within one list +- `ssers`: A table to keep track of all registered users +- `user_boards`: A many to many table to keep track which boards a user is part of + +We're not going into SQL details, but you should be able to paste the following snippets into the **SQL Editor** of your project. + +![SQL Editor](/images/blog/simon-grimm-angular-realtime/sql-editor.png) + +Simply navigate to the menu item and click on **+ New query**, paste in the SQL and hit **RUN** which hopefully executes without issues: + +```sql +drop table if exists user_boards; +drop table if exists cards; +drop table if exists lists; +drop table if exists boards; +drop table if exists users; + +-- Create boards table +create table boards ( + id bigint generated by default as identity primary key, + creator uuid references auth.users not null default auth.uid(), + title text default 'Untitled Board', + created_at timestamp with time zone default timezone('utc'::text, now()) not null +); + +-- Create lists table +create table lists ( + id bigint generated by default as identity primary key, + board_id bigint references boards ON DELETE CASCADE not null, + title text default '', + position int not null default 0, + created_at timestamp with time zone default timezone('utc'::text, now()) not null +); + +-- Create Cards table +create table cards ( + id bigint generated by default as identity primary key, + list_id bigint references lists ON DELETE CASCADE not null, + board_id bigint references boards ON DELETE CASCADE not null, + position int not null default 0, + title text default '', + description text check (char_length(description) > 0), + assigned_to uuid references auth.users, + done boolean default false, + created_at timestamp with time zone default timezone('utc'::text, now()) not null +); + +-- Many to many table for user <-> boards relationship +create table user_boards ( + id bigint generated by default as identity primary key, + user_id uuid references auth.users ON DELETE CASCADE not null default auth.uid(), + board_id bigint references boards ON DELETE CASCADE +); + +-- User ID lookup table +create table users ( + id uuid not null primary key, + email text +); + +-- Make sure deleted records are included in realtime +alter table cards replica identity full; +alter table lists replica identity full; + +-- Function to get all user boards +create or replace function get_boards_for_authenticated_user() +returns setof bigint +language sql +security definer +set search_path = public +stable +as $$ + select board_id + from user_boards + where user_id = auth.uid() +$$; +``` + +Besides the creation of tables we also changed the replica identity, which helps to alter retrieve records when a row is deleted. + +Finally we defined a very important **function** that we will use to make the table secure using [Row Level Security](https://supabase.com/docs/guides/auth/row-level-security). + +This function will retrieve all boards of a user from the `user_boards` table and will be used in our policies now. + +We now enabled the row level security for the different tables and define some **policies** so only users with the right access can read/update/delete rows. + +Go ahead and run another SQL query in the editor now: + +```sql +-- boards row level security +alter table boards enable row level security; + +-- Policies +create policy "Users can create boards" on boards for + insert to authenticated with CHECK (true); + +create policy "Users can view their boards" on boards for + select using ( + id in ( + select get_boards_for_authenticated_user() + ) + ); + +create policy "Users can update their boards" on boards for + update using ( + id in ( + select get_boards_for_authenticated_user() + ) + ); + +create policy "Users can delete their created boards" on boards for + delete using (auth.uid() = creator); + +-- user_boards row level security +alter table user_boards enable row level security; + +create policy "Users can add their boards" on user_boards for + insert to authenticated with check (true); + +create policy "Users can view boards" on user_boards for + select using (auth.uid() = user_id); + +create policy "Users can delete their boards" on user_boards for + delete using (auth.uid() = user_id); + +-- lists row level security +alter table lists enable row level security; + +-- Policies +create policy "Users can edit lists if they are part of the board" on lists for + all using ( + board_id in ( + select get_boards_for_authenticated_user() + ) + ); + +-- cards row level security +alter table cards enable row level security; + +-- Policies +create policy "Users can edit cards if they are part of the board" on cards for + all using ( + board_id in ( + select get_boards_for_authenticated_user() + ) + ); +``` + +Finally we need a **trigger** that reacts to changes in our database. + +In our case we want to listen to the creation of new boards, which will automatically create the board <-> user connection in the `user_boards` table. + +Additionally we will also add every new authenticated user to our `users` table since you later don't have access to the internal auth table of Supabase! + +Therefore run one last query: + +```sql +-- inserts a row into user_boards +create function public.handle_board_added() +returns trigger +language plpgsql +security definer +as $$ +begin + insert into public.user_boards (board_id, user_id) + values (new.id, auth.uid()); + return new; +end; +$$; + +-- trigger the function every time a board is created +create trigger on_board_created + after insert on boards + for each row execute procedure public.handle_board_added(); + + +create or replace function public.handle_new_user() +returns trigger as $$ +begin + insert into public.users (id, email) + values (new.id, new.email); + return new; +end; +$$ language plpgsql security definer; + +create trigger on_auth_user_created + after insert on auth.users + for each row execute procedure public.handle_new_user(); +``` + +At this point our Supabase project is configured correctly and we can move into the actual application! + +## Creating the Angular Project + +We are not bound to any framework, but in this article we are using [Angular](https://supabase.com/docs/guides/with-angular) to build a robust web application. + +Get started by using the [Angular CLI](https://angular.io/cli) to generate a new project and then add some components and services that we will need. + +Finally we can install the [Supabase JS package](https://github.com/supabase/supabase-js) and two additional helper packages for some cool functionality, so go ahead and run: + +```bash +ng new trelloBoard --routing --style=scss +cd ./trelloBoard + +# Generate components and services +ng generate component components/login +ng generate component components/inside/workspace +ng generate component components/inside/board + +ng generate service services/auth +ng generate service services/data + +# Install Supabase and additional packages +npm install @supabase/supabase-js +npm install ngx-spinner ngx-gravatar +``` + +To import the installed packages we can quickly change our **src/app/app.module.ts** to: + +```ts +import { NgModule } from '@angular/core' +import { BrowserModule } from '@angular/platform-browser' + +import { AppRoutingModule } from './app-routing.module' +import { AppComponent } from './app.component' +import { LoginComponent } from './components/login/login.component' +import { BoardComponent } from './components/inside/board/board.component' +import { WorkspaceComponent } from './components/inside/workspace/workspace.component' +import { BrowserAnimationsModule } from '@angular/platform-browser/animations' +import { NgxSpinnerModule } from 'ngx-spinner' +import { FormsModule } from '@angular/forms' +import { GravatarModule } from 'ngx-gravatar' + +@NgModule({ + declarations: [AppComponent, LoginComponent, BoardComponent, WorkspaceComponent], + imports: [ + FormsModule, + BrowserModule, + AppRoutingModule, + BrowserAnimationsModule, + NgxSpinnerModule, + GravatarModule, + ], + providers: [], + bootstrap: [AppComponent], +}) +export class AppModule {} +``` + +On top of that the [ngx-spinner](https://github.com/Napster2210/ngx-spinner) needs another entry in the **angular.json** to copy over resources so we can later easily display a loading indicator, so open it and change the `styles` array to this: + +```json +"styles": [ + "src/styles.scss", + "node_modules/ngx-spinner/animations/ball-scale-multiple.css" + ], +``` + +Since we have already generated some components, we can also change our app routing to inlcude the new pages in the **src/app/app-routing.module.ts** now: + +```ts +import { BoardComponent } from './components/inside/board/board.component' +import { WorkspaceComponent } from './components/inside/workspace/workspace.component' +import { LoginComponent } from './components/login/login.component' +import { NgModule } from '@angular/core' +import { RouterModule, Routes } from '@angular/router' + +const routes: Routes = [ + { + path: '', + component: LoginComponent, + }, + { + path: 'workspace', + component: WorkspaceComponent, + }, + { + path: 'workspace/:id', + component: BoardComponent, + }, + { + path: '**', + redirectTo: '/', + }, +] + +@NgModule({ + imports: [RouterModule.forRoot(routes, {})], + exports: [RouterModule], +}) +export class AppRoutingModule {} +``` + +Our app will start with the login screen, after which we can move to the workspace with our boards and finally dive into one specific board to show all its lists and cards. + +To correclty use the Angular router we can now update the **src/app/app.component.html** so it only holds one line: + +```html + +``` + +Finally the most important configuration step: Adding our Supabase credentials to the **src/environments/environment.ts** like this: + +```ts +export const environment = { + production: false, + supabaseUrl: 'YOUR-URL', + supabaseKey: 'YOUR-ANON-KEY', +} +``` + +You can find those values in your Supabase project by clicking on the **Settings** icon and then navigating to **API** where it shows your **Project API keys**. + +![Supabase project settings](/images/blog/simon-grimm-angular-realtime/project-settings.png) + +The `anon` key is safe to use in a frontend project since we have enabled RLS on our database anyway! + +## Adding Tailwind for Styling + +We could build an ugly project or easily make it look awesome by installing [Tailwind CSS](https://tailwindcss.com/) - we opt for the second in this article! + +There are certainly other styling libraries that you can use, so this step is completely optional but required in order to make code of this tutorial work. + +Therefore we follow the [Angular guide](https://tailwindcss.com/docs/guides/angular) and install Tailwind like this: + +```bash +npm install -D tailwindcss postcss autoprefixer @tailwindcss/forms +npx tailwindcss init +``` + +Now we also need to update our **tailwind.config.js** to this: + +```js +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ['./src/**/*.{html,ts}'], + theme: { + extend: {}, + }, + plugins: [require('@tailwindcss/forms')], +} +``` + +Finally we include the styling in our **src/styles.scss**: + +```css +@tailwind base; +@tailwind components; +@tailwind utilities; +``` + +And with that the whole project configuration is done and we can focus 100% on the functionality of our Trello clone! + +## Building a Magic Link Sign In + +We could now add all sorts of authetnication using the auth providers that Supabase provides, but we will simply use a magic link sign in where users only need to pass their email. + +To kick this off we will implement a simple authentication service that keeps track of our current user with a **BehaviourSubject** so we can easily emit new values later when the user session changes. + +We are also loading the session once "by hand" using `getUser()` since the `onAuthStateChange` event is usually not broadcasted when the page loads, and we want to load a stored session in that case as well. + +In order to send an email to the user we only need to call `signIn()` and only pass an email - Supabase takes care of the rest for us! + +Therefore get started by changing the **src/app/services/auth.service.ts** to this now: + +```ts +import { Injectable } from '@angular/core' +import { Router } from '@angular/router' +import { createClient, SupabaseClient, User } from '@supabase/supabase-js' +import { BehaviorSubject } from 'rxjs' +import { environment } from 'src/environments/environment' + +@Injectable({ + providedIn: 'root', +}) +export class AuthService { + private supabase: SupabaseClient + private _currentUser: BehaviorSubject = new BehaviorSubject(null) + + constructor(private router: Router) { + this.supabase = createClient(environment.supabaseUrl, environment.supabaseKey) + + // Manually load user session once on page load + // Note: This becomes a promise with getUser() in the next version! + const user = this.supabase.auth.user() + if (user) { + this._currentUser.next(user) + } else { + this._currentUser.next(false) + } + + this.supabase.auth.onAuthStateChange((event, session) => { + if (event == 'SIGNED_IN') { + this._currentUser.next(session!.user) + } else { + this._currentUser.next(false) + this.router.navigateByUrl('/', { replaceUrl: true }) + } + }) + } + + signInWithEmail(email: string) { + // Note: This becomes signInWithOTP() in the next version! + return this.supabase.auth.signIn({ + email, + }) + } + + logout() { + this.supabase.auth.signOut() + } + + get currentUser() { + return this._currentUser.asObservable() + } +} +``` + +That's a solid starting point for our authetnication logic, and now we just need to use those functions on our login page. + +Additionally we will also listen to user changes here since this is the page a user will load when clicking on the magic link. We can use the `currentUser` from our service so we don't need any additional logic for that. + +Once we start the sign in we can also use our cool spinner package to show a little indicator and afterwards flip the value of `linkSuccess` so we can present a little text in our UI. + +We're keeping it fairly easy, so let's change the **src/app/components/login/login.component.ts** to: + +```ts +import { Router } from '@angular/router' +import { AuthService } from './../../services/auth.service' +import { Component, OnInit } from '@angular/core' +import { NgxSpinnerService } from 'ngx-spinner' + +@Component({ + selector: 'app-login', + templateUrl: './login.component.html', + styleUrls: ['./login.component.scss'], +}) +export class LoginComponent implements OnInit { + email = '' + linkSuccess = false + + constructor( + private auth: AuthService, + private spinner: NgxSpinnerService, + private router: Router + ) { + this.auth.currentUser.subscribe((user) => { + if (user) { + this.router.navigateByUrl('/workspace', { replaceUrl: true }) + } + }) + } + + ngOnInit(): void {} + + async signIn() { + this.spinner.show() + const result = await this.auth.signInWithEmail(this.email) + + this.spinner.hide() + if (!result.error) { + this.linkSuccess = true + } else { + alert(result.error.message) + } + } +} +``` + +Last piece is our UI now, and since we are using Tailwind the HTML snippets won't look very beautiful. + +Nonetheless, it's just some CSS and connecting our fields and buttons to the right functions, so go ahead and change the **src/app/components/login/login.component.html** to: + +```html + + +
+
+

Supabase Trello

+
+ +
+
+
+
+ +
+ +
+
+ +
+ +
+
+ + Please check your emails! +
+
+
+``` + +Once you are done you should have a stylish login page! + +![Login](/images/blog/simon-grimm-angular-realtime/login.png) + +When you enter your email and click the button, you should automatically receive an email with a link that will open up your app in the browser again - and this time it should actually forward you to the workspace area immediately. + +![Magic Link Email](/images/blog/simon-grimm-angular-realtime/magic-link-email.png) + +Now at this point we could also enter that _internal_ page manually by changing the URL without being authorized, so let's add a mechanism to prevent that. + +## Protecting your Pages with a Guard + +In Angular we protect pages with a guard, and because we already keep track of the user in our authentication service it's gonna be super easy to protect pages that only authorized users should see. + +Get started by generating a new guard: + +```bash +ng generate guard guards/auth --implements CanActivate +``` + +That guard will now check the Observable of our service, filter out the initial state and then see if a user is allowed to access a page or not. + +Bring up the new **src/app/guards/auth.guard.ts** and change it to this: + +```ts +import { AuthService } from './../services/auth.service' +import { Injectable } from '@angular/core' +import { CanActivate, Router, UrlTree } from '@angular/router' +import { Observable } from 'rxjs' +import { filter, map, take } from 'rxjs/operators' + +@Injectable({ + providedIn: 'root', +}) +export class AuthGuard implements CanActivate { + constructor(private auth: AuthService, private router: Router) {} + + canActivate(): Observable { + return this.auth.currentUser.pipe( + filter((val) => val !== null), // Filter out initial Behaviour subject value + take(1), // Otherwise the Observable doesn't complete! + map((isAuthenticated) => { + if (isAuthenticated) { + return true + } else { + return this.router.createUrlTree(['/']) + } + }) + ) + } +} +``` + +Now we can apply this guard to all routes that we want to protect, so open up our **src/app/app-routing.module.ts** and add it to the two internal pages we want to protect: + +```ts +import { AuthGuard } from './guards/auth.guard' +import { BoardComponent } from './components/inside/board/board.component' +import { WorkspaceComponent } from './components/inside/workspace/workspace.component' +import { LoginComponent } from './components/login/login.component' +import { NgModule } from '@angular/core' +import { RouterModule, Routes } from '@angular/router' + +const routes: Routes = [ + { + path: '', + component: LoginComponent, + }, + { + path: 'workspace', + component: WorkspaceComponent, + canActivate: [AuthGuard], + }, + { + path: 'workspace/:id', + component: BoardComponent, + canActivate: [AuthGuard], + }, + { + path: '**', + redirectTo: '/', + }, +] + +@NgModule({ + imports: [RouterModule.forRoot(routes, {})], + exports: [RouterModule], +}) +export class AppRoutingModule {} +``` + +Now only signed in users can access those pages, and we can move a step forward to the boards logic. + +## Creating the Workspace + +Once a user arrives at the workspace page, we want to list all boards of a user and implement the ability to add boards. + +To do so, we start off within a service again which takes care of all the interaction between our code and Supbase, so the view can focus on the data presentation. + +Our first function will simplye insert an empty object into the `boards` table, which we define as a const so we can't add any typos to our code. + +Because we defined a default value for new rows in our SQL in the beginning, we don't have to pass any other data here. + +To load all tables of a user could simply query the `user_boards` table, but we might want more information about the related board so we can also [query foreign tables](https://supabase.com/docs/reference/javascript/select#query-foreign-tables) to load the board information! + +Go ahead and begin the **src/app/services/data.service.ts** with this: + +```ts +import { Injectable } from '@angular/core' +import { SupabaseClient, createClient } from '@supabase/supabase-js' +import { environment } from 'src/environments/environment' + +export const BOARDS_TABLE = 'boards' +export const USER_BOARDS_TABLE = 'user_boards' +export const LISTS_TABLE = 'lists' +export const CARDS_TABLE = 'cards' +export const USERS_TABLE = 'users' + +@Injectable({ + providedIn: 'root', +}) +export class DataService { + private supabase: SupabaseClient + + constructor() { + this.supabase = createClient(environment.supabaseUrl, environment.supabaseKey) + } + + async startBoard() { + // Minimal return will be the default in the next version and can be removed here! + return await this.supabase.from(BOARDS_TABLE).insert({}, { returning: 'minimal' }) + } + + async getBoards() { + const boards = await this.supabase.from(USER_BOARDS_TABLE).select(` + boards:board_id ( title, id ) + `) + return boards.data || [] + } +} +``` + +In fact that's enough for our first interaction with our Supabase tables, so we can move on to our view again and load the user boards when the page loads. + +Additionally we want to add a board, and here we encounter one of those _real world problems_: + +Because we have a **database trigger** that adds an entry when a new table is added, the user is not immediately authroized to access the new board row! Only once the trigger has finished, the RLS that checks user boards can confirm that this user is part of the board. + +Therefore we add another line to load the boards again and pop the last added element so we can automatically navigate into its details page. + +Now open the **src/app/components/inside/workspace/workspace.component.ts** and change it to: + +```ts +import { AuthService } from './../../../services/auth.service' +import { Router } from '@angular/router' +import { DataService } from './../../../services/data.service' +import { Component, OnInit } from '@angular/core' + +@Component({ + selector: 'app-workspace', + templateUrl: './workspace.component.html', + styleUrls: ['./workspace.component.scss'], +}) +export class WorkspaceComponent implements OnInit { + boards: any[] = [] + user = this.auth.currentUser + + constructor( + private dataService: DataService, + private router: Router, + private auth: AuthService + ) {} + + async ngOnInit() { + this.boards = await this.dataService.getBoards() + } + + async startBoard() { + const data = await this.dataService.startBoard() + + // Load all boards because we only get back minimal data + // Trigger needs to run first + // Otherwise RLS would fail + this.boards = await this.dataService.getBoards() + + if (this.boards.length > 0) { + const newBoard = this.boards.pop() + + if (newBoard.boards) { + this.router.navigateByUrl(`/workspace/${newBoard.boards.id}`) + } + } + } + + signOut() { + this.auth.logout() + } +} +``` + +To display all of this we build up another view with Tailwind and also use the Gravatar package to display a little image of the current user based on the email. + +Besides that we simply iterate all boards, add the router link to a board based on the ID and add a button to create new boards, so bring up the **src/app/components/inside/workspace/workspace.component.html** and change it to: + +```html +
+ +
+ +
+
    +
  • + {{ board.boards.title }} +
  • + +
  • + + New board +
  • +
+
+``` + +At this point we have a functional board logic and actually already route to the following details page. + +![Workspace page](/images/blog/simon-grimm-angular-realtime/workspace.png) + +The logout functionality will also remove our session and guide us back to the login, so we have covered that flow at the same time already. + +Time for some more interaction with our Supabase tables! + +## Adding CRUD Functions for the Database + +On our board details page we now need to interact with all the tables and mostly perform CRUD functionality - Create, read, update or delete records of our database. + +Since there's no real value in discussing every line, let's quickly add the following bunch of functions to our **src/app/services/data.service.ts**: + +```ts +// CRUD Board + async getBoardInfo(boardId: string) { + return await this.supabase + .from(BOARDS_TABLE) + .select('*') + .match({ id: boardId }) + .single(); + } + + async updateBoard(board: any) { + return await this.supabase + .from(BOARDS_TABLE) + .update(board) + .match({ id: board.id }); + } + + async deleteBoard(board: any) { + return await this.supabase + .from(BOARDS_TABLE) + .delete() + .match({ id: board.id }); + } + + // CRUD Lists + async getBoardLists(boardId: string) { + const lists = await this.supabase + .from(LISTS_TABLE) + .select('*') + .eq('board_id', boardId) + .order('position'); + + return lists.data || []; + } + + async addBoardList(boardId: string, position = 0) { + return await this.supabase + .from(LISTS_TABLE) + .insert({ board_id: boardId, position, title: 'New List' }) + .select('*') + .single(); + } + + async updateBoardList(list: any) { + return await this.supabase + .from(LISTS_TABLE) + .update(list) + .match({ id: list.id }); + } + + async deleteBoardList(list: any) { + return await this.supabase + .from(LISTS_TABLE) + .delete() + .match({ id: list.id }); + } + + // CRUD Cards + async addListCard(listId: string, boardId: string, position = 0) { + return await this.supabase + .from(CARDS_TABLE) + .insert( + { board_id: boardId, list_id: listId, position } + ) + .select('*') + .single(); + } + + async getListCards(listId: string) { + const lists = await this.supabase + .from(CARDS_TABLE) + .select('*') + .eq('list_id', listId) + .order('position'); + + return lists.data || []; + } + + async updateCard(card: any) { + return await this.supabase + .from(CARDS_TABLE) + .update(card) + .match({ id: card.id }); + } + + async deleteCard(card: any) { + return await this.supabase + .from(CARDS_TABLE) + .delete() + .match({ id: card.id }); + } +``` + +Most if not all of this is basic SQL as described in the [Supabase docs for Database](https://supabase.com/docs/reference/javascript/select) + +One additional function is missing, and that's a simple invitation logic. However we gonna skip the "Ok I want to join this board" step and simply add invited users to a new board. Sometimes users need to be forced to do what's good for them. + +Therfore we will try to find the user ID of a user based on the entered email, and if it exists we will create a new entry in the `user_boards` table for that user: + +```ts + // Invite others + async addUserToBoard(boardId: string, email: string) { + const user = await this.supabase + .from(USERS_TABLE) + .select('id') + .match({ email }) + .single(); + + if (user.data?.id) { + const userId = user.data.id; + const userBoard = await this.supabase.from(USER_BOARDS_TABLE).insert({ + user_id: userId, + board_id: boardId, + }); + return userBoard; + } else { + return null; + } + } +``` + +With those functions in place I think we are more than ready to create a powerful board page. + +## Creating the Boards View + +This page is the most essential and most challenging part of our app, as it's the place where the actual work happens and users collaborate on boards. + +However, we will begin by setting up the basic stuff and introduce **realtime functionality and presence** in a separate step afterwards. + +Because it would be tedious to split the page into multiple code snippets we'll go with one big and I'll explain what's going on: + +- We first need to load some general board info like the title using `getBoardInfo()` and the passed ID of the board +- We then need to load all **lists** of a board using `getBoardLists()` +- We then need to load every **card** for every list using `getListCards()` + +To keep track of data and changes we hold all cards in the `listCards` object that stores all cards under the related list ID key. + +In terms of additional logic we might want to update or delete the board, which we can do simply with the previously created service functions. + +Same is true for lists and cards, whcih can be added, updated or removed. + +However, this will not (yet) update our local data, since we want to implement this with realtime updates later. + +For now go ahead and change the **src/app/components/inside/board/board.component.ts** to: + +```ts +import { DataService } from './../../../services/data.service' +import { Component, HostListener, OnInit } from '@angular/core' +import { ActivatedRoute, Router } from '@angular/router' + +@Component({ + selector: 'app-board', + templateUrl: './board.component.html', + styleUrls: ['./board.component.scss'], +}) +export class BoardComponent implements OnInit { + lists: any[] = [] + boardId: string | null = null + editTitle: any = {} + editCard: any = {} + boardInfo: any = null + titleChanged = false + + listCards: any = {} + addUserEmail = '' + + constructor( + private route: ActivatedRoute, + private dataService: DataService, + private router: Router + ) {} + + async ngOnInit() { + this.boardId = this.route.snapshot.paramMap.get('id') + if (this.boardId) { + // Load general board information + const board = await this.dataService.getBoardInfo(this.boardId) + this.boardInfo = board.data + + // Retrieve all lists + this.lists = await this.dataService.getBoardLists(this.boardId) + + // Retrieve cards for each list + for (let list of this.lists) { + this.listCards[list.id] = await this.dataService.getListCards(list.id) + } + + // For later... + this.handleRealtimeUpdates() + } + } + + // + // BOARD logic + // + async saveBoardTitle() { + await this.dataService.updateBoard(this.boardInfo) + this.titleChanged = false + } + + async deleteBoard() { + await this.dataService.deleteBoard(this.boardInfo) + this.router.navigateByUrl('/workspace') + } + + // + // LISTS logic + // + async addList() { + const newList = await this.dataService.addBoardList(this.boardId!, this.lists.length) + } + + editingTitle(list: any, edit = false) { + this.editTitle[list.id] = edit + } + + async updateListTitle(list: any) { + await this.dataService.updateBoardList(list) + this.editingTitle(list, false) + } + + async deleteBoardList(list: any) { + await this.dataService.deleteBoardList(list) + } + + // + // CARDS logic + // + async addCard(list: any) { + await this.dataService.addListCard(list.id, this.boardId!, this.listCards[list.id].length) + } + + editingCard(card: any, edit = false) { + this.editCard[card.id] = edit + } + + async updateCard(card: any) { + await this.dataService.updateCard(card) + this.editingCard(card, false) + } + + async deleteCard(card: any) { + await this.dataService.deleteCard(card) + } + + // Invites + async addUser() { + await this.dataService.addUserToBoard(this.boardId!, this.addUserEmail) + this.addUserEmail = '' + } + + handleRealtimeUpdates() { + // TODO + } +} +``` + +That was a massive file - make sure you take the time to go through it at least once or twice to better understand the differetn functions we added. + +Now we need to tackle the view of that page, and because it's Tailwind the snippets won't be shorter. + +We can begin with the easier part, which is the header area that displays a back button, the board information that can be updated on click and a delete button to well, you know what. + +Bring up the **src/app/components/inside/board/board.component.html** and add this first: + +```html +
+ +
+``` + +Since we will have more of these update input fields later, let's quickly add a col `HostListener` to our app so we can detect at least the ESC key event and then close all of those edit input fields in our **src/app/components/inside/board/board.component.ts** + +```ts + @HostListener('document:keydown', ['$event']) onKeydownHandler( + event: KeyboardEvent + ) { + if (event.keyCode === 27) { + // Close whatever needs to be closed! + this.titleChanged = false; + + Object.keys(this.editCard).map((item) => { + this.editCard[item] = false; + return item; + }); + + Object.keys(this.editTitle).map((item) => { + this.editTitle[item] = false; + return item; + }); + } + } +``` + +Finally we need to iterate all lists, and for every list display all cards. + +Actually a pretty simple task, but since we need more buttons to control the elements so we can delete, add and update them to whole code becomes a bit more bloated. + +Nonetheless we can continue below the previous code in our **src/app/components/inside/board/board.component.html** and add this: + +```html +
+
+ +
+
+

+ {{ list.title }} +

+ + +
+ + +
+
+

{{ card.title }}

+ +
+ + + +
+
+
+ + Add a card +
+ +
+
+ +
+ + New list +
+
+
+``` + +At this point we are able to add a list, add a new card in that list and finally update or delete all of that! + +![Board functionality](/images/blog/simon-grimm-angular-realtime/board.png) + +Most of this won't update the view since we will handle this with realtime updates in a minute, so you need to refresh your page after adding a card or list right now! + +But we can actually already add our invitation logic, which just needs another input field so we can invite another email to work with us on the board. + +Add the following in the `
` tag of our **src/app/components/inside/board/board.component.html** at the bottom: + +```html +
+ Invite + + + +
+``` + +The required function in our class and service already exists, so you can now already invite other users (who are already signed up!) and from their account see the same board as you have. + +## Handling Realtime Table Changes + +The cool thing is is how easy we are now able to implement real time functionality - the only thing required for this is to turn it on. + +We can do this right inside the table editor of Supabase, so go to your tables, click that little arrow next to edi so you can edit the table and then enable realtime for bot **cards** and **lists**! + +![Supabase Realtime](/images/blog/simon-grimm-angular-realtime/supabase-realtime.png) + +Now we are able to retrieve those updates if we listen for them, and while the API for this might slightly change with the next Supabase JS update, the general idea can still be applied: + +We create a new `Subject` and return it as an `Observable`, and then listen to changes of our tables by using `on()`. + +Whenever we get an update, we emit that change to the Subject so we have one stream of updates that we can return to our view. + +To continue, bring up the **src/app/services/data.service.ts** and add this additional function: + +```ts + getTableChanges() { + const changes = new Subject(); + + this.supabase + .from(CARDS_TABLE) + .on('*', (payload: any) => { + changes.next(payload); + }) + .subscribe(); + + this.supabase + .from(LISTS_TABLE) + .on('*', (payload: any) => { + changes.next(payload); + }) + .subscribe(); + + return changes.asObservable(); + } +``` + +Now that we can easily get all the updates to our relevant tables in realtime, we just need to handle them accordingly. + +This is just a matter of finding out which **event** occured (INSERT, UPDATE, DELETE) and then applying the changes to our local data to add, change or remove data. + +Go ahead by finally implementing our function in the **src/app/components/inside/board/board.component.ts** that we left open until now: + +```ts + handleRealtimeUpdates() { + this.dataService.getTableChanges().subscribe((update: any) => { + const record = update.new?.id ? update.new : update.old; + const event = update.eventType; + + if (!record) return; + + if (update.table == 'cards') { + if (event === 'INSERT') { + this.listCards[record.list_id].push(record); + } else if (event === 'UPDATE') { + const newArr = []; + + for (let card of this.listCards[record.list_id]) { + if (card.id == record.id) { + card = record; + } + newArr.push(card); + } + this.listCards[record.list_id] = newArr; + } else if (event === 'DELETE') { + this.listCards[record.list_id] = this.listCards[ + record.list_id + ].filter((card: any) => card.id !== record.id); + } + } else if (update.table == 'lists') { + // TODO + } + }); + } +``` + +This handles the events if the table of our event is **cards**, buzt the second part is somewhat similar. + +I simply put the code for the `else` case in a second block, to not make the first handling look that big - but it's pretty much the same logic of handling the different cases and now updating everything related to **lists**: + +```ts +else if (update.table == 'lists') { + if (event === 'INSERT') { + this.lists.push(record); + this.listCards[record.id] = []; + } else if (event === 'UPDATE') { + this.lists.filter((list: any) => list.id === record.id)[0] = record; + + const newArr = []; + + for (let list of this.lists) { + if (list.id == record.id) { + list = record; + } + newArr.push(list); + } + this.lists = newArr; + } else if (event === 'DELETE') { + this.lists = this.lists.filter((list: any) => list.id !== record.id); + } +} +``` + +With that final piece of code we are completely done with our Supabase Angular Trello clone, and you can enjoy the fruits of your hard work! + +## Conclusion + +Building projects with Supabase is awesome, and hopefully this real world clone example gave you insight into different areas that you need to think about. + +You can [find the full code of this tutorial on Github](https://github.com/saimon24/supabase-trello-angular) where you just need to insert your own Supabase instance and then create the tables with the included SQL file. + +If you enjoyed the tutorial, you can [find many more tutorials on my YouTube channel](https://www.youtube.com/simongrimmdev_) where I help web developers build awesome mobile apps. + +Until next time and happy coding with Supabase! diff --git a/apps/www/lib/authors.json b/apps/www/lib/authors.json index 390cdd31d9e..17fe79ba71e 100644 --- a/apps/www/lib/authors.json +++ b/apps/www/lib/authors.json @@ -205,5 +205,13 @@ "position": "Engineering", "author_url": "https://github.com/j0", "author_image_url": "https://github.com/j0.png" + }, + { + "author_id": "simon_grimm", + "author": "Simon Grimm", + "username": "schlimmson", + "position": "Guest Author", + "author_url": "https://twitter.com/schlimmson", + "author_image_url": "https://github.com/saimon24.png" } ] diff --git a/apps/www/public/images/blog/simon-grimm-angular-realtime/angular-supabase.png b/apps/www/public/images/blog/simon-grimm-angular-realtime/angular-supabase.png new file mode 100644 index 00000000000..4578b76690e Binary files /dev/null and b/apps/www/public/images/blog/simon-grimm-angular-realtime/angular-supabase.png differ diff --git a/apps/www/public/images/blog/simon-grimm-angular-realtime/board.png b/apps/www/public/images/blog/simon-grimm-angular-realtime/board.png new file mode 100644 index 00000000000..c3f1ee675ba Binary files /dev/null and b/apps/www/public/images/blog/simon-grimm-angular-realtime/board.png differ diff --git a/apps/www/public/images/blog/simon-grimm-angular-realtime/disable-email-confirm.png b/apps/www/public/images/blog/simon-grimm-angular-realtime/disable-email-confirm.png new file mode 100644 index 00000000000..2a0c0623871 Binary files /dev/null and b/apps/www/public/images/blog/simon-grimm-angular-realtime/disable-email-confirm.png differ diff --git a/apps/www/public/images/blog/simon-grimm-angular-realtime/login.png b/apps/www/public/images/blog/simon-grimm-angular-realtime/login.png new file mode 100644 index 00000000000..091c8332ad8 Binary files /dev/null and b/apps/www/public/images/blog/simon-grimm-angular-realtime/login.png differ diff --git a/apps/www/public/images/blog/simon-grimm-angular-realtime/magic-link-email.png b/apps/www/public/images/blog/simon-grimm-angular-realtime/magic-link-email.png new file mode 100644 index 00000000000..a27b43c5bb2 Binary files /dev/null and b/apps/www/public/images/blog/simon-grimm-angular-realtime/magic-link-email.png differ diff --git a/apps/www/public/images/blog/simon-grimm-angular-realtime/project-settings.png b/apps/www/public/images/blog/simon-grimm-angular-realtime/project-settings.png new file mode 100644 index 00000000000..7738c6cb92e Binary files /dev/null and b/apps/www/public/images/blog/simon-grimm-angular-realtime/project-settings.png differ diff --git a/apps/www/public/images/blog/simon-grimm-angular-realtime/sql-editor.png b/apps/www/public/images/blog/simon-grimm-angular-realtime/sql-editor.png new file mode 100644 index 00000000000..e5fb3fbb47a Binary files /dev/null and b/apps/www/public/images/blog/simon-grimm-angular-realtime/sql-editor.png differ diff --git a/apps/www/public/images/blog/simon-grimm-angular-realtime/supabase-realtime.png b/apps/www/public/images/blog/simon-grimm-angular-realtime/supabase-realtime.png new file mode 100644 index 00000000000..d5e0da41486 Binary files /dev/null and b/apps/www/public/images/blog/simon-grimm-angular-realtime/supabase-realtime.png differ diff --git a/apps/www/public/images/blog/simon-grimm-angular-realtime/workspace.png b/apps/www/public/images/blog/simon-grimm-angular-realtime/workspace.png new file mode 100644 index 00000000000..4022a029e40 Binary files /dev/null and b/apps/www/public/images/blog/simon-grimm-angular-realtime/workspace.png differ diff --git a/apps/www/public/rss.xml b/apps/www/public/rss.xml index 4911984fd81..e039d570523 100644 --- a/apps/www/public/rss.xml +++ b/apps/www/public/rss.xml @@ -5,833 +5,840 @@ https://supabase.com Latest news from Supabase en - Fri, 19 Aug 2022 05:05:00 +0000 + Tue, 23 Aug 2022 23:11:00 +0000 + https://supabase.com/blog/building-a-realtime-trello-board-with-supabase-and-angular + Building a Realtime Trello Board with Supabase and Angular + https://supabase.com/blog/building-a-realtime-trello-board-with-supabase-and-angular + Go beyond the hello world example with this real world project. + Tue, 23 Aug 2022 23:11:00 +0000 + + https://supabase.com/blog/supabase-vault Supabase Vault https://supabase.com/blog/supabase-vault Today we're announcing Vault, a Postgres extension for managing secrets and encryption inside your database. - Fri, 19 Aug 2022 05:05:00 +0000 + Thu, 18 Aug 2022 23:11:00 +0000 https://supabase.com/blog/postgrest-v10 PostgREST v10: EXPLAIN and Improved Relationship Detection https://supabase.com/blog/postgrest-v10 Today, PostgREST 10 was released. Let's take a look at some of the new features that go hand in hand with supabase-js v2. - Fri, 19 Aug 2022 05:05:00 +0000 + Thu, 18 Aug 2022 23:11:00 +0000 https://supabase.com/blog/pg-jsonschema-a-postgres-extension-for-json-validation pg_jsonschema: JSON Schema support for Postgres https://supabase.com/blog/pg-jsonschema-a-postgres-extension-for-json-validation Today we're releasing pg_jsonschema, a Postgres extension for JSON validation. - Fri, 19 Aug 2022 05:05:00 +0000 + Thu, 18 Aug 2022 23:11:00 +0000 https://supabase.com/blog/launch-week-5-one-more-thing One more thing https://supabase.com/blog/launch-week-5-one-more-thing Let's be honest, it's never just one more thing. - Fri, 19 Aug 2022 05:05:00 +0000 + Thu, 18 Aug 2022 23:11:00 +0000 https://supabase.com/blog/launch-week-5-community-day Community Day https://supabase.com/blog/launch-week-5-community-day Wrapping up Launch Week 5 with contributors, partners, and friends. - Fri, 19 Aug 2022 05:05:00 +0000 + Thu, 18 Aug 2022 23:11:00 +0000 - https://supabase.com/blog/supabase-realtime-multiplayer-general-availability copy + https://supabase.com/blog/supabase-realtime-multiplayer-general-availability Realtime: Multiplayer Edition - https://supabase.com/blog/supabase-realtime-multiplayer-general-availability copy + https://supabase.com/blog/supabase-realtime-multiplayer-general-availability Announcing the general availability of Realtime's Broadcast and Presence. - Wed, 17 Aug 2022 05:05:00 +0000 + Wed, 17 Aug 2022 23:11:00 +0000 https://supabase.com/blog/supabase-soc2 Supabase is SOC2 compliant https://supabase.com/blog/supabase-soc2 Supabase is now SOC2 compliant. Learn how we got here and what it means for our customers. - Tue, 16 Aug 2022 05:05:00 +0000 + Tue, 16 Aug 2022 23:11:00 +0000 https://supabase.com/blog/supabase-js-v2 supabase-js v2 Release Candidate https://supabase.com/blog/supabase-js-v2 First peek of supabase-js v2, which focuses on quality-of-life improvements for developers. - Mon, 15 Aug 2022 05:05:00 +0000 + Mon, 15 Aug 2022 23:11:00 +0000 https://supabase.com/blog/supabase-cli-v1-and-admin-api-beta Supabase CLI v1 and Admin API Beta https://supabase.com/blog/supabase-cli-v1-and-admin-api-beta We are moving Supabase CLI v1 out of beta, and releasing Admin API beta. - Sun, 14 Aug 2022 05:05:00 +0000 + Sun, 14 Aug 2022 23:11:00 +0000 https://supabase.com/blog/supabase-series-b Supabase Series B https://supabase.com/blog/supabase-series-b Supabase raised $80M in May, bringing our total funding to $116M. - Thu, 11 Aug 2022 05:05:00 +0000 + Thu, 11 Aug 2022 23:11:00 +0000 https://supabase.com/blog/launch-week-5-hackathon Launch Week 5 Hackathon https://supabase.com/blog/launch-week-5-hackathon Build to win $1500 - Friday 12th to Monday 21st August 2022 - Tue, 09 Aug 2022 05:05:00 +0000 + Tue, 09 Aug 2022 23:11:00 +0000 https://supabase.com/blog/slack-consolidate-slackbot-to-consolidate-messages Slack Consolidate: a slackbot built with Python and Supabase https://supabase.com/blog/slack-consolidate-slackbot-to-consolidate-messages A slackbot to consolidate messages from different channels using Supabase, Slack SDK and Python - Mon, 08 Aug 2022 05:05:00 +0000 + Mon, 08 Aug 2022 23:11:00 +0000 https://supabase.com/blog/supabase-beta-update-july-2022 Supabase Beta July 2022 https://supabase.com/blog/supabase-beta-update-july-2022 Launch Week Golden Tickets, Flutter SDK 1.0 Developer Preview and more... - Tue, 02 Aug 2022 05:05:00 +0000 + Tue, 02 Aug 2022 23:11:00 +0000 https://supabase.com/blog/supabase-flutter-sdk-1-developer-preview Supabase Flutter SDK 1.0 Developer Preview https://supabase.com/blog/supabase-flutter-sdk-1-developer-preview Supabase Flutter SDK is getting a major update and we need your help making it better. - Mon, 01 Aug 2022 05:05:00 +0000 + Mon, 01 Aug 2022 23:11:00 +0000 https://supabase.com/blog/seen-by-in-postgresql Implementing "seen by" functionality with Postgres https://supabase.com/blog/seen-by-in-postgresql Different approaches for tracking visitor counts with PostgreSQL. - Sun, 17 Jul 2022 05:05:00 +0000 + Sun, 17 Jul 2022 23:11:00 +0000 https://supabase.com/blog/supabase-auth-helpers-with-sveltekit-support Revamped Auth Helpers for Supabase (with SvelteKit support) https://supabase.com/blog/supabase-auth-helpers-with-sveltekit-support Supabase Auth Helpers now have improved developer experience, Sveltekit support, and more. - Tue, 12 Jul 2022 05:05:00 +0000 + Tue, 12 Jul 2022 23:11:00 +0000 https://supabase.com/blog/beta-update-june-2022 Supabase Beta June 2022 https://supabase.com/blog/beta-update-june-2022 Auth Helpers, Unlimited Free Projects, CLI and more... - Tue, 05 Jul 2022 05:05:00 +0000 + Tue, 05 Jul 2022 23:11:00 +0000 https://supabase.com/blog/flutter-tutorial-building-a-chat-app Flutter Tutorial: building a Flutter chat app https://supabase.com/blog/flutter-tutorial-building-a-chat-app How to create a real time chat app using Flutter. - Wed, 29 Jun 2022 05:05:00 +0000 + Wed, 29 Jun 2022 23:11:00 +0000 https://supabase.com/blog/visualizing-supabase-data-using-metabase Visualizing Supabase Data using Metabase https://supabase.com/blog/visualizing-supabase-data-using-metabase How to create different kinds of charts out of data stored in Supabase using Metabase. - Tue, 28 Jun 2022 05:05:00 +0000 + Tue, 28 Jun 2022 23:11:00 +0000 https://supabase.com/blog/partial-postgresql-data-dumps-with-rls Partial data dumps using Postgres Row Level Security https://supabase.com/blog/partial-postgresql-data-dumps-with-rls Using RLS to create seed files for local PostgreSQL testing. - Mon, 27 Jun 2022 05:05:00 +0000 + Mon, 27 Jun 2022 23:11:00 +0000 https://supabase.com/blog/loading-data-supabase-python Python data loading with Supabase https://supabase.com/blog/loading-data-supabase-python An example of how to load data into Supabase using supabase-py - Thu, 16 Jun 2022 05:05:00 +0000 + Thu, 16 Jun 2022 23:11:00 +0000 https://supabase.com/blog/beta-update-may-2022 Supabase Beta May 2022 https://supabase.com/blog/beta-update-may-2022 Product and community updates including wildcard auth redirects, edge functions with webhooks, and Prometheus endpoints for everybody. - Tue, 31 May 2022 05:05:00 +0000 + Tue, 31 May 2022 23:11:00 +0000 https://supabase.com/blog/how-supabase-accelerates-development-of-all-pull-together How Mike Lyndon is using Supabase to accelerate development of AllPullTogether https://supabase.com/blog/how-supabase-accelerates-development-of-all-pull-together Mike Lyndon is learning web development as he builds AllPullTogether, and Supabase is helping him accelerate what he can accomplish. - Wed, 25 May 2022 05:05:00 +0000 + Wed, 25 May 2022 23:11:00 +0000 https://supabase.com/blog/partner-gallery-works-with-supabase Works With Supabase - announcing our Partner Gallery https://supabase.com/blog/partner-gallery-works-with-supabase Introducing our Partner Gallery - open source and made with Supabase. - Tue, 19 Apr 2022 05:05:00 +0000 + Tue, 19 Apr 2022 23:11:00 +0000 https://supabase.com/blog/bring-the-func-hackathon-winners Bring the Func Hackathon Winners 2022 https://supabase.com/blog/bring-the-func-hackathon-winners Celebrating many amazing OSS Hackathon projects using GraphQL and Edge Functions. - Sun, 17 Apr 2022 05:05:00 +0000 + Sun, 17 Apr 2022 23:11:00 +0000 https://supabase.com/blog/beta-update-march-2022 Supabase Beta March 2022 https://supabase.com/blog/beta-update-march-2022 Functions, GraphQL, and much more. - Thu, 14 Apr 2022 05:05:00 +0000 + Thu, 14 Apr 2022 23:11:00 +0000 https://supabase.com/blog/supabrew Supabrew - Never Code Thirsty https://supabase.com/blog/supabrew A light and refreshing non-alcoholic beer for devs. - Fri, 01 Apr 2022 06:06:00 +0000 + Thu, 31 Mar 2022 23:11:00 +0000 https://supabase.com/blog/supabase-realtime-with-multiplayer-features Supabase Realtime, with Multiplayer Features https://supabase.com/blog/supabase-realtime-with-multiplayer-features Today we're announced Realtime, with multiplayer features. Realtime enables broadcast, presence, and listening to database changes delivered over WebSockets. - Fri, 01 Apr 2022 06:06:00 +0000 + Thu, 31 Mar 2022 23:11:00 +0000 https://supabase.com/blog/hackathon-bring-the-func Hackathon: Bring the Func(🕺) https://supabase.com/blog/hackathon-bring-the-func Build open-source projects with our latest features, win limited edition swag and plant a tree! - Fri, 01 Apr 2022 06:06:00 +0000 + Thu, 31 Mar 2022 23:11:00 +0000 https://supabase.com/blog/supabase-edge-functions Edge Functions are now available in Supabase https://supabase.com/blog/supabase-edge-functions Today we're launching Edge Functions. Edge Functions let you execute Typescript code close to your users, no matter where they're located. - Wed, 30 Mar 2022 06:06:00 +0000 + Wed, 30 Mar 2022 23:11:00 +0000 https://supabase.com/blog/supabase-enterprise Introducing Supabase Enterprise https://supabase.com/blog/supabase-enterprise Today we are releasing Supabase Enterprise, a suite of features to scale your project. - Tue, 29 Mar 2022 06:06:00 +0000 + Tue, 29 Mar 2022 23:11:00 +0000 https://supabase.com/blog/graphql-now-available GraphQL is now available in Supabase https://supabase.com/blog/graphql-now-available GraphQL support is now in general availability on the Supabase platform via our open source PostgreSQL extension, pg_graphql (beta). - Mon, 28 Mar 2022 06:06:00 +0000 + Mon, 28 Mar 2022 23:11:00 +0000 https://supabase.com/blog/community-day-lw4 Community Day https://supabase.com/blog/community-day-lw4 Kicking off Launch Week 4 with contributors, partners, and friends. - Sun, 27 Mar 2022 06:06:00 +0000 + Sun, 27 Mar 2022 23:11:00 +0000 https://supabase.com/blog/supabase-launch-week-four Supabase Launch Week 4 https://supabase.com/blog/supabase-launch-week-four Launch Week 4: One new feature every day for an entire week. Starting Monday 28th March. - Thu, 24 Mar 2022 06:06:00 +0000 + Fri, 25 Mar 2022 00:12:00 +0000 https://supabase.com/blog/should-i-open-source-my-company Should I Open Source my Company? https://supabase.com/blog/should-i-open-source-my-company The unexpected upsides of building in public - Thu, 24 Mar 2022 06:06:00 +0000 + Fri, 25 Mar 2022 00:12:00 +0000 https://supabase.com/blog/audit Postgres Auditing in 150 lines of SQL https://supabase.com/blog/audit PostgreSQL has a robust set of features which we can leverage to create a generic auditing solution in 150 lines of SQL. - Mon, 07 Mar 2022 06:06:00 +0000 + Tue, 08 Mar 2022 00:12:00 +0000 https://supabase.com/blog/supabase-beta-january-2022 Supabase Beta January 2022 https://supabase.com/blog/supabase-beta-january-2022 New auth providers, SMS providers, and new videos. - Mon, 21 Feb 2022 06:06:00 +0000 + Tue, 22 Feb 2022 00:12:00 +0000 https://supabase.com/blog/supabase-beta-december-2021 Supabase Beta December 2021 https://supabase.com/blog/supabase-beta-december-2021 New crypto extension, Postgres videos, and a bunch of cool integrations. - Wed, 19 Jan 2022 06:06:00 +0000 + Thu, 20 Jan 2022 00:12:00 +0000 https://supabase.com/blog/product-hunt-golden-kitty-awards-2021 Golden Kitty Awards Ceremony Watch Party with Supabase https://supabase.com/blog/product-hunt-golden-kitty-awards-2021 Hang out with us while watching the Product Hunt Golden Kitty Awards Ceremony - Wed, 19 Jan 2022 06:06:00 +0000 + Thu, 20 Jan 2022 00:12:00 +0000 https://supabase.com/blog/holiday-hackdays-winners-2021 Holiday Hackdays Winners 2021 https://supabase.com/blog/holiday-hackdays-winners-2021 Celebrating many amazing projects submitted to our Holiday Hackdays Hackathon. - Thu, 16 Dec 2021 06:06:00 +0000 + Fri, 17 Dec 2021 00:12:00 +0000 https://supabase.com/blog/beta-november-2021-launch-week-recap Supabase Beta November 2021: Launch Week Recap https://supabase.com/blog/beta-november-2021-launch-week-recap We wrapped up November with Supabase's third Launch Week. Here's all the awesome stuff that got shipped ... - Tue, 14 Dec 2021 06:06:00 +0000 + Wed, 15 Dec 2021 00:12:00 +0000 https://supabase.com/blog/supabase-holiday-hackdays-hackathon Kicking off the Holiday Hackdays https://supabase.com/blog/supabase-holiday-hackdays-hackathon Build cool stuff and celebrate open-source software with us during the Holiday Hackdays! - Thu, 02 Dec 2021 06:06:00 +0000 + Fri, 03 Dec 2021 00:12:00 +0000 https://supabase.com/blog/pg-graphql pg_graphql: A GraphQL extension for PostgreSQL https://supabase.com/blog/pg-graphql GraphQL support is in development for PostgreSQL + Supabase. - Thu, 02 Dec 2021 06:06:00 +0000 + Fri, 03 Dec 2021 00:12:00 +0000 https://supabase.com/blog/launch-week-three-friday-five-more-things Five more things https://supabase.com/blog/launch-week-three-friday-five-more-things It's never just one more thing! - Thu, 02 Dec 2021 06:06:00 +0000 + Fri, 03 Dec 2021 00:12:00 +0000 https://supabase.com/blog/supabase-acquires-logflare Supabase acquires Logflare https://supabase.com/blog/supabase-acquires-logflare Today, we're ecstatic to announce that Logflare is joining Supabase. - Wed, 01 Dec 2021 06:06:00 +0000 + Thu, 02 Dec 2021 00:12:00 +0000 https://supabase.com/blog/realtime-row-level-security-in-postgresql Realtime Postgres RLS now available on Supabase https://supabase.com/blog/realtime-row-level-security-in-postgresql Realtime database changes are now broadcast to authenticated users, respecting the same PostgreSQL policies that you use for Row Level Security. - Tue, 30 Nov 2021 06:06:00 +0000 + Wed, 01 Dec 2021 00:12:00 +0000 https://supabase.com/blog/supabase-studio Supabase Studio https://supabase.com/blog/supabase-studio The same Dashboard that you're using on our Platform is now available for local development and Self-Hosting. - Mon, 29 Nov 2021 06:06:00 +0000 + Tue, 30 Nov 2021 00:12:00 +0000 https://supabase.com/blog/community-day-lw3 Community Day https://supabase.com/blog/community-day-lw3 Kicking off launch week by highlighting the communities around Supabase. - Sun, 28 Nov 2021 06:06:00 +0000 + Mon, 29 Nov 2021 00:12:00 +0000 https://supabase.com/blog/whats-new-in-postgres-14 New in PostgreSQL 14: What every developer should know https://supabase.com/blog/whats-new-in-postgres-14 A quick look at some new features and functionality in PostgreSQL 14. - Sat, 27 Nov 2021 06:06:00 +0000 + Sun, 28 Nov 2021 00:12:00 +0000 https://supabase.com/blog/postgrest-9 PostgREST 9 https://supabase.com/blog/postgrest-9 New features and updates in PostgREST version 9. - Fri, 26 Nov 2021 06:06:00 +0000 + Sat, 27 Nov 2021 00:12:00 +0000 https://supabase.com/blog/supabase-launch-week-the-trilogy Supabase Launch Week III: Holiday Special https://supabase.com/blog/supabase-launch-week-the-trilogy Tis the season to be shipping. - Thu, 25 Nov 2021 06:06:00 +0000 + Fri, 26 Nov 2021 00:12:00 +0000 https://supabase.com/blog/supabase-how-we-launch How we launch at Supabase https://supabase.com/blog/supabase-how-we-launch The history and methodology of Supabase Launch Week. - Thu, 25 Nov 2021 06:06:00 +0000 + Fri, 26 Nov 2021 00:12:00 +0000 https://supabase.com/blog/supabase-beta-october-2021 Supabase Beta October 2021 https://supabase.com/blog/supabase-beta-october-2021 Three new Auth providers, multi-schema support, and we're gearing up for another Launch Week. - Sat, 06 Nov 2021 06:06:00 +0000 + Sun, 07 Nov 2021 00:12:00 +0000 https://supabase.com/blog/supabase-series-a Supabase $30m Series A https://supabase.com/blog/supabase-series-a Supabase just raised $30M, bringing our total funding to $36M. - Wed, 27 Oct 2021 05:05:00 +0000 + Wed, 27 Oct 2021 23:11:00 +0000 https://supabase.com/blog/replenysh-time-to-value-in-less-than-24-hours Replenysh uses Supabase to implement OTP in less than 24-hours https://supabase.com/blog/replenysh-time-to-value-in-less-than-24-hours Learn how Replenysh uses Supabase to power the circular economy, redefining how brands interact with their customers and products. - Mon, 18 Oct 2021 05:05:00 +0000 + Mon, 18 Oct 2021 23:11:00 +0000 https://supabase.com/blog/hacktoberfest-hackathon-winners-2021 Hacktoberfest Hackathon Winners 2021 https://supabase.com/blog/hacktoberfest-hackathon-winners-2021 Celebrating many amazing projects submitted to our Hacktoberfest Hackathon. - Wed, 13 Oct 2021 05:05:00 +0000 + Wed, 13 Oct 2021 23:11:00 +0000 https://supabase.com/blog/supabase-beta-sept-2021 Supabase Beta Sept 2021 https://supabase.com/blog/supabase-beta-sept-2021 Hackathon, Aborting request, UI updates, and now Hiring. - Sun, 03 Oct 2021 05:05:00 +0000 + Sun, 03 Oct 2021 23:11:00 +0000 https://supabase.com/blog/supabase-hacktoberfest-hackathon-2021 Supabase Hacktoberfest Hackathon 2021 https://supabase.com/blog/supabase-hacktoberfest-hackathon-2021 We're running another Supabase Hackathon during Hacktoberfest! - Mon, 27 Sep 2021 05:05:00 +0000 + Mon, 27 Sep 2021 23:11:00 +0000 https://supabase.com/blog/supabase-beta-august-2021 Supabase Beta August 2021 https://supabase.com/blog/supabase-beta-august-2021 Fundraising, Realtime Security, custom SMS templates, and deployments in South Korea. - Thu, 09 Sep 2021 05:05:00 +0000 + Thu, 09 Sep 2021 23:11:00 +0000 https://supabase.com/blog/supabase-beta-july-2021 Supabase Beta July 2021 https://supabase.com/blog/supabase-beta-july-2021 Discord Logins, Vercel Integration, Full text search, and OAuth guides. - Wed, 11 Aug 2021 05:05:00 +0000 + Wed, 11 Aug 2021 23:11:00 +0000 https://supabase.com/blog/hackathon-winners Open Source Hackathon Winners https://supabase.com/blog/hackathon-winners Let the medal ceremony begin for the best projects submitted during the Supabase Hackathon. - Sun, 08 Aug 2021 05:05:00 +0000 + Sun, 08 Aug 2021 23:11:00 +0000 https://supabase.com/blog/supabase-swag-store Supabase Swag Store https://supabase.com/blog/supabase-swag-store Today we are officially launching the Supabase Swag Store. - Thu, 29 Jul 2021 05:05:00 +0000 + Thu, 29 Jul 2021 23:11:00 +0000 https://supabase.com/blog/supabase-functions-updates Updates for Supabase Functions https://supabase.com/blog/supabase-functions-updates The question on everyone's mind - are we launching Supabase Functions? Well, it's complicated. - Thu, 29 Jul 2021 05:05:00 +0000 + Thu, 29 Jul 2021 23:11:00 +0000 https://supabase.com/blog/1-the-supabase-hackathon The Supabase Hackathon https://supabase.com/blog/1-the-supabase-hackathon A whole week of Hacking for Fun and Prizes. - Thu, 29 Jul 2021 05:05:00 +0000 + Thu, 29 Jul 2021 23:11:00 +0000 https://supabase.com/blog/supabase-reports-and-metrics Supabase Reports and Metrics https://supabase.com/blog/supabase-reports-and-metrics We're exposing a full set of metrics in your projects, so that you can build better (and faster) products for your users. - Wed, 28 Jul 2021 05:05:00 +0000 + Wed, 28 Jul 2021 23:11:00 +0000 https://supabase.com/blog/supabase-auth-passwordless-sms-login Supabase Auth v2: Phone Auth now available https://supabase.com/blog/supabase-auth-passwordless-sms-login Phone Auth is available today on all new and existing Supabase projects. - Tue, 27 Jul 2021 05:05:00 +0000 + Tue, 27 Jul 2021 23:11:00 +0000 https://supabase.com/blog/mobbin-supabase-200000-users Mobbin uses Supabase to authenticate 200,000 users https://supabase.com/blog/mobbin-supabase-200000-users Learn how Mobbin migrated 200,000 users from Firebase for a better authentication experience. - Tue, 27 Jul 2021 05:05:00 +0000 + Tue, 27 Jul 2021 23:11:00 +0000 https://supabase.com/blog/storage-beta Supabase Storage now in Beta https://supabase.com/blog/storage-beta Supabase Storage moves into Beta. - Mon, 26 Jul 2021 05:05:00 +0000 + Mon, 26 Jul 2021 23:11:00 +0000 https://supabase.com/blog/spot-flutter-with-postgres Spot: a video sharing app built with Flutter https://supabase.com/blog/spot-flutter-with-postgres Spot is a geolocation-based video-sharing app with some social networking features. - Mon, 26 Jul 2021 05:05:00 +0000 + Mon, 26 Jul 2021 23:11:00 +0000 https://supabase.com/blog/supabase-postgres-13 Supabase is now on Postgres 13.3 https://supabase.com/blog/supabase-postgres-13 From today, new Supabase projects will be on a version of Supabase Postgres that runs on Postgres 13.3. - Sun, 25 Jul 2021 05:05:00 +0000 + Sun, 25 Jul 2021 23:11:00 +0000 https://supabase.com/blog/supabase-community-day Supabase Community Day https://supabase.com/blog/supabase-community-day Community Day - Sun, 25 Jul 2021 05:05:00 +0000 + Sun, 25 Jul 2021 23:11:00 +0000 https://supabase.com/blog/epsilon3-self-hosting Epsilon3 Self-Host Supabase To Revolutionize Space Operations https://supabase.com/blog/epsilon3-self-hosting Learn how the team at Epsilon3 use Supabase to help teams execute secure and reliable operations in an industry that project spend runs into the billions. - Sun, 25 Jul 2021 05:05:00 +0000 + Sun, 25 Jul 2021 23:11:00 +0000 https://supabase.com/blog/supabase-launch-week-sql Supabase Launch Week II: The SQL https://supabase.com/blog/supabase-launch-week-sql Five days of Supabase. Again. - Wed, 21 Jul 2021 05:05:00 +0000 + Wed, 21 Jul 2021 23:11:00 +0000 https://supabase.com/blog/roles-postgres-hooks Protecting reserved roles with PostgreSQL Hooks https://supabase.com/blog/roles-postgres-hooks Using Postgres Hooks to protect functionality in your Postgres database. - Thu, 01 Jul 2021 05:05:00 +0000 + Thu, 01 Jul 2021 23:11:00 +0000 https://supabase.com/blog/supabase-beta-june-2021 Supabase Beta June 2021 https://supabase.com/blog/supabase-beta-june-2021 Discord Logins, Vercel Integration, Full text search, and OAuth guides. - Tue, 01 Jun 2021 05:05:00 +0000 + Tue, 01 Jun 2021 23:11:00 +0000 https://supabase.com/blog/supabase-beta-may-2021 Supabase Beta May 2021 https://supabase.com/blog/supabase-beta-may-2021 Apple & Twitter Logins, Supabase Grid, Go & Swift Libraries. - Tue, 01 Jun 2021 05:05:00 +0000 + Tue, 01 Jun 2021 23:11:00 +0000 https://supabase.com/blog/supabase-beta-april-2021 Supabase Beta April 2021 https://supabase.com/blog/supabase-beta-april-2021 Supabase "gardening" - stability, security, and community support. - Tue, 04 May 2021 05:05:00 +0000 + Tue, 04 May 2021 23:11:00 +0000 https://supabase.com/blog/supabase-beta-march-2021 Supabase Beta March 2021 https://supabase.com/blog/supabase-beta-march-2021 Launch week, Storage, Supabase CLI, Connection Pooling, Supabase UI, and Pricing. - Mon, 05 Apr 2021 05:05:00 +0000 + Mon, 05 Apr 2021 23:11:00 +0000 https://supabase.com/blog/supabase-workflows Workflows are coming to Supabase https://supabase.com/blog/supabase-workflows Functions are great, but you know what's better? - Thu, 01 Apr 2021 06:06:00 +0000 + Thu, 01 Apr 2021 23:11:00 +0000 https://supabase.com/blog/supabase-pgbouncer PgBouncer is now available in Supabase https://supabase.com/blog/supabase-pgbouncer Better support for Serverless and Postgres. - Thu, 01 Apr 2021 06:06:00 +0000 + Thu, 01 Apr 2021 23:11:00 +0000 https://supabase.com/blog/supabase-dot-com Supabase Dot Com https://supabase.com/blog/supabase-dot-com The Supabase Domain name is changing. - Thu, 01 Apr 2021 06:06:00 +0000 + Thu, 01 Apr 2021 23:11:00 +0000 https://supabase.com/blog/supabase-nft-marketplace Supabase Launches NFT Marketplace https://supabase.com/blog/supabase-nft-marketplace A fully encrypted NFT platform to protect and transact your digital assets - Thu, 01 Apr 2021 06:06:00 +0000 + Wed, 31 Mar 2021 23:11:00 +0000 https://supabase.com/blog/supabase-cli Supabase CLI https://supabase.com/blog/supabase-cli Local development, database migrations, and self-hosting. - Wed, 31 Mar 2021 06:06:00 +0000 + Tue, 30 Mar 2021 23:11:00 +0000 https://supabase.com/blog/supabase-storage Storage is now available in Supabase https://supabase.com/blog/supabase-storage Launching Supabase Storage and how you can use it in your apps - Tue, 30 Mar 2021 06:06:00 +0000 + Mon, 29 Mar 2021 23:11:00 +0000 https://supabase.com/blog/pricing Supabase Beta Pricing https://supabase.com/blog/pricing Supabase launches Beta pricing structure - Mon, 29 Mar 2021 06:06:00 +0000 + Sun, 28 Mar 2021 23:11:00 +0000 https://supabase.com/blog/launch-week Launch week https://supabase.com/blog/launch-week Five days of Supabase. - Wed, 24 Mar 2021 06:06:00 +0000 + Thu, 25 Mar 2021 00:12:00 +0000 https://supabase.com/blog/angels-of-supabase Angels of Supabase https://supabase.com/blog/angels-of-supabase Meet the investors of Supabase. - Wed, 24 Mar 2021 06:06:00 +0000 + Thu, 25 Mar 2021 00:12:00 +0000 https://supabase.com/blog/In-The-Loop Developers stay up to date with intheloop.dev https://supabase.com/blog/In-The-Loop Learn why Kevin is building intheloop.dev with Supabase - Mon, 22 Mar 2021 06:06:00 +0000 + Mon, 22 Mar 2021 00:12:00 +0000 https://supabase.com/blog/using-supabase-replit Using Supabase in Replit https://supabase.com/blog/using-supabase-replit Free hosted relational database from within your node.js repl - Thu, 11 Mar 2021 06:06:00 +0000 + Thu, 11 Mar 2021 00:12:00 +0000 https://supabase.com/blog/toad-a-link-shortener-with-simple-apis-for-low-coders Toad, a link shortener with simple APIs for low-coders https://supabase.com/blog/toad-a-link-shortener-with-simple-apis-for-low-coders An easy-to-use link shortening tool with simple APIs - Mon, 08 Mar 2021 06:06:00 +0000 + Mon, 08 Mar 2021 00:12:00 +0000 https://supabase.com/blog/postgres-as-a-cron-server Postgres as a CRON Server https://supabase.com/blog/postgres-as-a-cron-server Running repetitive tasks with your Postgres database. - Fri, 05 Mar 2021 06:06:00 +0000 + Fri, 05 Mar 2021 00:12:00 +0000 https://supabase.com/blog/supabase-beta-february-2021 Supabase Beta February 2021 https://supabase.com/blog/supabase-beta-february-2021 One year of building. - Tue, 02 Mar 2021 06:06:00 +0000 + Tue, 02 Mar 2021 00:12:00 +0000 https://supabase.com/blog/cracking-postgres-interview Cracking PostgreSQL Interview Questions https://supabase.com/blog/cracking-postgres-interview Understand the top PostgreSQL Interview Questions - Sat, 27 Feb 2021 06:06:00 +0000 + Sat, 27 Feb 2021 00:12:00 +0000 https://supabase.com/blog/case-study-roboflow Roboflow.com choose Supabase to power Paint.wtf leaderboard https://supabase.com/blog/case-study-roboflow Learn how Roboflow.com used Supabase to build their Paint.wtf leaderboard - Tue, 09 Feb 2021 06:06:00 +0000 + Tue, 09 Feb 2021 00:12:00 +0000 https://supabase.com/blog/supabase-beta-january-2021 Supabase Beta January 2021 https://supabase.com/blog/supabase-beta-january-2021 Eleven months of building. - Tue, 02 Feb 2021 06:06:00 +0000 + Tue, 02 Feb 2021 00:12:00 +0000 https://supabase.com/blog/supabase-beta-december-2020 Supabase Beta December 2020 https://supabase.com/blog/supabase-beta-december-2020 Ten months of building. - Sat, 02 Jan 2021 06:06:00 +0000 + Sat, 02 Jan 2021 00:12:00 +0000 https://supabase.com/blog/supabase-dashboard-performance Making the Supabase Dashboard Supa-fast https://supabase.com/blog/supabase-dashboard-performance Improving the performance of the Supabase dashboard - Sun, 13 Dec 2020 06:06:00 +0000 + Sun, 13 Dec 2020 00:12:00 +0000 https://supabase.com/blog/supabase-striveschool Supabase Partners With Strive School To Help Teach Open Source https://supabase.com/blog/supabase-striveschool Supabase Partners With Strive School To Help Teach Open Source To The Next Generation Of Developers - Wed, 02 Dec 2020 06:06:00 +0000 + Wed, 02 Dec 2020 00:12:00 +0000 https://supabase.com/blog/case-study-xendit Xendit Built a Counter-Fraud Watchlist for the Fintech Industry https://supabase.com/blog/case-study-xendit See how Xendit use Supabase to build a full-text search engine. - Wed, 02 Dec 2020 06:06:00 +0000 + Wed, 02 Dec 2020 00:12:00 +0000 https://supabase.com/blog/case-study-tayfa TAYFA Built a No-Code Website Builder in Seven Days https://supabase.com/blog/case-study-tayfa See how Tayfa went from idea to paying customer in less than 30 days. - Wed, 02 Dec 2020 06:06:00 +0000 + Wed, 02 Dec 2020 00:12:00 +0000 https://supabase.com/blog/case-study-monitoro Monitoro Built a Web Crawler Handling Millions of API Requests https://supabase.com/blog/case-study-monitoro See how Monitoro built an automated scraping platform using Supabase. - Wed, 02 Dec 2020 06:06:00 +0000 + Wed, 02 Dec 2020 00:12:00 +0000 https://supabase.com/blog/supabase-alpha-november-2020 Supabase Alpha November 2020 https://supabase.com/blog/supabase-alpha-november-2020 Nine months of building. - Tue, 01 Dec 2020 06:06:00 +0000 + Tue, 01 Dec 2020 00:12:00 +0000 https://supabase.com/blog/postgresql-views Postgres Views https://supabase.com/blog/postgresql-views Creating and using a view in PostgreSQL. - Wed, 18 Nov 2020 06:06:00 +0000 + Wed, 18 Nov 2020 00:12:00 +0000 https://supabase.com/blog/supabase-alpha-october-2020 Supabase Alpha October 2020 https://supabase.com/blog/supabase-alpha-october-2020 Eight months of building. - Mon, 02 Nov 2020 06:06:00 +0000 + Mon, 02 Nov 2020 00:12:00 +0000 https://supabase.com/blog/improved-dx Supabase.js 1.0 https://supabase.com/blog/improved-dx We're releasing a new version of our Supabase client with some awesome new improvements. - Fri, 30 Oct 2020 06:06:00 +0000 + Fri, 30 Oct 2020 00:12:00 +0000 https://supabase.com/blog/supabase-alpha-september-2020 Supabase Alpha September 2020 https://supabase.com/blog/supabase-alpha-september-2020 Seven months of building. - Sat, 03 Oct 2020 05:05:00 +0000 + Fri, 02 Oct 2020 23:11:00 +0000 https://supabase.com/blog/supabase-hacktoberfest-2020 Supabase Hacktoberfest 2020 https://supabase.com/blog/supabase-hacktoberfest-2020 Join us for a celebration of open source software and learn how to contribute to Supabase. - Fri, 11 Sep 2020 05:05:00 +0000 + Thu, 10 Sep 2020 23:11:00 +0000 https://supabase.com/blog/supabase-alpha-august-2020 Supabase Alpha August 2020 https://supabase.com/blog/supabase-alpha-august-2020 Six months of building - Thu, 03 Sep 2020 05:05:00 +0000 + Wed, 02 Sep 2020 23:11:00 +0000 https://supabase.com/blog/supabase-auth Supabase Auth https://supabase.com/blog/supabase-auth Authenticate and authorize your users with Supabase Auth - Wed, 05 Aug 2020 05:05:00 +0000 + Tue, 04 Aug 2020 23:11:00 +0000 https://supabase.com/blog/supabase-alpha-july-2020 Supabase Alpha July 2020 https://supabase.com/blog/supabase-alpha-july-2020 Five months of building - Sun, 02 Aug 2020 05:05:00 +0000 + Sat, 01 Aug 2020 23:11:00 +0000 https://supabase.com/blog/continuous-postgresql-backup-walg Continuous PostgreSQL Backups using WAL-G https://supabase.com/blog/continuous-postgresql-backup-walg Have you ever wanted to restore your database's state to a particular moment in time? This post explains how, using WAL-G. - Sun, 02 Aug 2020 05:05:00 +0000 + Sat, 01 Aug 2020 23:11:00 +0000 https://supabase.com/blog/alpha-launch-postmortem Alpha Launch Postmortem https://supabase.com/blog/alpha-launch-postmortem Everything that went wrong with Supabase's launch - Fri, 10 Jul 2020 05:05:00 +0000 + Thu, 09 Jul 2020 23:11:00 +0000 https://supabase.com/blog/postgresql-templates What are PostgreSQL Templates? https://supabase.com/blog/postgresql-templates What are PostgreSQL templates and what are they used for? - Thu, 09 Jul 2020 05:05:00 +0000 + Wed, 08 Jul 2020 23:11:00 +0000 https://supabase.com/blog/postgresql-physical-logical-backups Physical vs Logical Backups in PostgreSQL https://supabase.com/blog/postgresql-physical-logical-backups What are physical and logical backups in Postgres? - Tue, 07 Jul 2020 05:05:00 +0000 + Mon, 06 Jul 2020 23:11:00 +0000 https://supabase.com/blog/supabase-alpha-june-2020 Supabase Alpha June 2020 https://supabase.com/blog/supabase-alpha-june-2020 Four months of building - Wed, 01 Jul 2020 05:05:00 +0000 + Tue, 30 Jun 2020 23:11:00 +0000 https://supabase.com/blog/supabase-steve-chavez Steve Chavez has joined Supabase https://supabase.com/blog/supabase-steve-chavez Steve joins Supabase to help build Auth. - Mon, 15 Jun 2020 05:05:00 +0000 + Sun, 14 Jun 2020 23:11:00 +0000 https://supabase.com/blog/supabase-alpha-may-2020 Supabase Alpha May 2020 https://supabase.com/blog/supabase-alpha-may-2020 Three months of building - Mon, 01 Jun 2020 05:05:00 +0000 + Sun, 31 May 2020 23:11:00 +0000 https://supabase.com/blog/supabase-alpha-april-2020 Supabase Alpha April 2020 https://supabase.com/blog/supabase-alpha-april-2020 Two months of building - Mon, 01 Jun 2020 05:05:00 +0000 + Sun, 31 May 2020 23:11:00 +0000 diff --git a/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx b/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx index d5abb12219d..b4f394979cc 100644 --- a/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx +++ b/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx @@ -7,9 +7,10 @@ interface Props { name: string properties: any formValues: any + disabled?: boolean } -const FormField: FC = ({ name, properties, formValues }) => { +const FormField: FC = ({ name, properties, formValues, disabled = false }) => { if (properties.show && formValues[properties.show.key] !== properties.show.matches) return <> const [hidden, setHidden] = useState(!!properties.isSecret) @@ -22,6 +23,7 @@ const FormField: FC = ({ name, properties, formValues }) => { layout="vertical" id={name} name={name} + disabled={disabled} type={hidden ? 'password' : 'text'} label={properties.title} labelOptional={ @@ -64,6 +66,7 @@ const FormField: FC = ({ name, properties, formValues }) => { layout="vertical" id={name} name={name} + disabled={disabled} label={properties.title} labelOptional={ properties.descriptionOptional ? ( @@ -97,6 +100,7 @@ const FormField: FC = ({ name, properties, formValues }) => { size="small" id={name} name={name} + disabled={disabled} label={properties.title} descriptionText={properties.description} /> @@ -107,6 +111,7 @@ const FormField: FC = ({ name, properties, formValues }) => { = ({ provider }) => { const { authConfig, ui } = useStore() const doubleNegativeKeys = ['MAILER_AUTOCONFIRM', 'SMS_AUTOCONFIRM'] + const canUpdateConfig = checkPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') const generateInitialValues = () => { const initialValues: { [x: string]: string } = {} @@ -103,8 +106,8 @@ const ProviderForm: FC = ({ provider }) => { validationSchema={provider.validationSchema} onSubmit={onSubmit} > - {({ isSubmitting, handleReset, values }: any) => { - const noChanges = JSON.stringify(INITIAL_VALUES) === JSON.stringify(values) + {({ isSubmitting, handleReset, initialValues, values }: any) => { + const noChanges = JSON.stringify(initialValues) === JSON.stringify(values) return (
= ({ provider }) => { border-scale-500 bg-scale-100 py-6 px-6 text-scale-1200 dark:bg-scale-300 " > -
+
{Object.keys(provider.properties).map((x: string) => ( ))} {provider?.misc?.alert && ( @@ -149,12 +153,36 @@ const ProviderForm: FC = ({ provider }) => { handleReset() setOpen(false) }} + disabled={isSubmitting} > Cancel - + + + + + {!canUpdateConfig && ( + + +
+ + You need additional permissions to update provider settings + +
+
+ )} +
diff --git a/studio/components/interfaces/Auth/AutoSchemaForm.tsx b/studio/components/interfaces/Auth/AutoSchemaForm.tsx index 319938ffd76..42d931c61a9 100644 --- a/studio/components/interfaces/Auth/AutoSchemaForm.tsx +++ b/studio/components/interfaces/Auth/AutoSchemaForm.tsx @@ -1,9 +1,10 @@ import { useEffect, useState } from 'react' import { observer } from 'mobx-react-lite' import { boolean, number, object, string } from 'yup' +import { PermissionAction } from '@supabase/shared-types/out/constants' import { Button, Form, Input, IconEye, IconEyeOff, InputNumber, Toggle } from '@supabase/ui' -import { useStore } from 'hooks' +import { useStore, checkPermissions } from 'hooks' import { FormActions, FormHeader, @@ -20,6 +21,8 @@ const AutoSchemaForm = observer(() => { const formId = 'auth-config-general-form' const [hidden, setHidden] = useState(true) + const canUpdateConfig = checkPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') + const INITIAL_VALUES = { DISABLE_SIGNUP: !authConfig.config.DISABLE_SIGNUP, JWT_EXP: authConfig.config.JWT_EXP, @@ -90,12 +93,18 @@ const AutoSchemaForm = observer(() => { +
} @@ -108,6 +117,7 @@ const AutoSchemaForm = observer(() => { label="Allow new users to sign up" layout="flex" descriptionText="If this is disabled, new users will not be able to sign up to your application." + disabled={!canUpdateConfig} /> @@ -121,6 +131,7 @@ const AutoSchemaForm = observer(() => { size="small" label="Site URL" descriptionText="The base URL of your website. Used as an allow-list for redirects and for constructing URLs used in emails." + disabled={!canUpdateConfig} /> { label="JWT expiry limit" descriptionText="How long tokens are valid for. Defaults to 3600 (1 hour), maximum 604,800 seconds (one week)." actions={seconds} + disabled={!canUpdateConfig} /> @@ -139,6 +151,7 @@ const AutoSchemaForm = observer(() => { label="Enable hCaptcha protection" layout="flex" descriptionText="Protect authentication endpoints from abuse." + disabled={!canUpdateConfig} /> {values.SECURITY_CAPTCHA_ENABLED && ( { type={hidden ? 'password' : 'text'} size="small" label="hCaptcha secret" + disabled={!canUpdateConfig} actions={
) @@ -138,6 +141,7 @@ const TemplateEditor: FC = ({ template }) => { id="code-id" loading={!isLoaded} language="html" + isReadOnly={!canUpdateConfig} className="!mb-0 h-96 overflow-hidden rounded border" onInputChange={(e: string | undefined) => setBodyValue(e ?? '')} options={{ wordWrap: 'off', contextmenu: false }} @@ -146,7 +150,7 @@ const TemplateEditor: FC = ({ template }) => {
)} -
+
{ resetForm({ @@ -158,6 +162,12 @@ const TemplateEditor: FC = ({ template }) => { form={formId} isSubmitting={isSubmitting} hasChanges={hasChanges} + disabled={!canUpdateConfig} + helper={ + !canUpdateConfig + ? 'You need additional permissions to update authentication settings' + : undefined + } />
diff --git a/studio/components/interfaces/Auth/RedirectDomains/DomainList.tsx b/studio/components/interfaces/Auth/RedirectDomains/DomainList.tsx index cc985ee818c..08929879c79 100644 --- a/studio/components/interfaces/Auth/RedirectDomains/DomainList.tsx +++ b/studio/components/interfaces/Auth/RedirectDomains/DomainList.tsx @@ -8,10 +8,11 @@ import { EmptyListState } from 'components/ui/States' import { HorizontalShimmerWithIcon } from 'components/ui/Shimmers' interface Props { + canUpdate: boolean onSelectDomainToDelete: (domain: string) => void } -const DomainList: FC = ({ onSelectDomainToDelete }) => { +const DomainList: FC = ({ canUpdate, onSelectDomainToDelete }) => { const { authConfig } = useStore() const URI_ALLOW_LIST_ARRAY = authConfig.config.URI_ALLOW_LIST @@ -33,19 +34,21 @@ const DomainList: FC = ({ onSelectDomainToDelete }) => { URI_ALLOW_LIST_ARRAY.map((domain: string) => { return ( -
+
- {domain} + {domain}
- + {canUpdate && ( + + )} ) }) diff --git a/studio/components/interfaces/Auth/RedirectDomains/RedirectDomains.tsx b/studio/components/interfaces/Auth/RedirectDomains/RedirectDomains.tsx index b6b6cf97cc0..5eede56fce7 100644 --- a/studio/components/interfaces/Auth/RedirectDomains/RedirectDomains.tsx +++ b/studio/components/interfaces/Auth/RedirectDomains/RedirectDomains.tsx @@ -1,9 +1,11 @@ import { useState } from 'react' import { object, string } from 'yup' import { observer } from 'mobx-react-lite' +import * as Tooltip from '@radix-ui/react-tooltip' import { Button, Form, Input, Modal } from '@supabase/ui' +import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useStore } from 'hooks' +import { checkPermissions, useStore } from 'hooks' import { FormHeader } from 'components/ui/Forms' import { domainRegex } from '../Auth.constants' import DomainList from './DomainList' @@ -19,6 +21,8 @@ const RedirectDomains = () => { const [isDeleting, setIsDeleting] = useState(false) const [selectedDomainToDelete, setSelectedDomainToDelete] = useState() + const canUpdateConfig = checkPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') + const newDomainSchema = object({ domain: string().matches(domainRegex, 'URL is not valid').required(), }) @@ -86,9 +90,30 @@ const RedirectDomains = () => { title="Redirect URLs" description="URLs that auth providers are permitted to redirect to post authentication" /> - + + + + + {!canUpdateConfig && ( + + +
+ + You need additional permissions to update redirect URLs + +
+
+ )} +
- + { const formId = 'auth-config-smtp-form' const initialValues = generateFormValues(authConfig.config) + const canUpdateConfig = checkPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') useEffect(() => { if (isLoaded && isSmtpEnabled(config)) { @@ -143,12 +145,18 @@ const SmtpForm = () => { /> +
} @@ -161,6 +169,7 @@ const SmtpForm = () => { label="Enable Custom SMTP" layout="flex" checked={enableSmtp} + disabled={!canUpdateConfig} // @ts-ignore onChange={(value: boolean) => setEnableSmtp(value)} descriptionText="Emails will be sent using your custom SMTP provider" @@ -188,6 +197,7 @@ const SmtpForm = () => { label="Sender email" descriptionText="This is the email address the emails are sent from" placeholder="noreply@yourdomain.com" + disabled={!canUpdateConfig} /> { label="Sender name" descriptionText="Name displayed in the recipient's inbox" placeholder="The name shown on the email" + disabled={!canUpdateConfig} /> @@ -218,6 +229,7 @@ const SmtpForm = () => { id="SMTP_HOST" label="Host" descriptionText="Hostname or IP address of your SMTP server." + disabled={!canUpdateConfig} /> { } + disabled={!canUpdateConfig} /> { label="Minimum interval between emails being sent" descriptionText="How long between each email can a new email be sent via your SMTP server." actions={seconds} + disabled={!canUpdateConfig} /> { label="Rate limit for sending emails" descriptionText="How many emails can be sent per hour." actions={emails per hour} + disabled={!canUpdateConfig} /> { onClick={() => setHidden(!hidden)} /> } + disabled={!canUpdateConfig} /> diff --git a/studio/components/interfaces/Authentication/Users/InviteUserModal.tsx b/studio/components/interfaces/Authentication/Users/InviteUserModal.tsx index 401e60663a0..d27900cc1d8 100644 --- a/studio/components/interfaces/Authentication/Users/InviteUserModal.tsx +++ b/studio/components/interfaces/Authentication/Users/InviteUserModal.tsx @@ -1,8 +1,10 @@ import { useContext, useState } from 'react' import { observer } from 'mobx-react-lite' +import * as Tooltip from '@radix-ui/react-tooltip' import { Button, Modal, Input, IconPlus, IconMail, Form } from '@supabase/ui' +import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useStore } from 'hooks' +import { useStore, checkPermissions } from 'hooks' import { post } from 'lib/common/fetch' import { API_URL } from 'lib/constants' import { PageContext } from 'pages/project/[ref]/auth/users' @@ -13,6 +15,7 @@ const InviteUserModal = () => { const [visible, setVisible] = useState(false) const handleToggle = () => setVisible(!visible) + const canInviteUsers = checkPermissions(PermissionAction.AUTH_EXECUTE, 'invite_user') const validate = (values: any) => { const errors: any = {} @@ -53,9 +56,28 @@ const InviteUserModal = () => { return (
- + + + + + {!canInviteUsers && ( + + +
+ + You need additional permissions to invite users + +
+
+ )} +
{ size="small" htmlType="submit" loading={isSubmitting} - disabled={isSubmitting} + disabled={!canInviteUsers || isSubmitting} > Invite user diff --git a/studio/components/interfaces/Authentication/Users/UserDropdown.tsx b/studio/components/interfaces/Authentication/Users/UserDropdown.tsx index 48e333d73d6..13b7601ee43 100644 --- a/studio/components/interfaces/Authentication/Users/UserDropdown.tsx +++ b/studio/components/interfaces/Authentication/Users/UserDropdown.tsx @@ -1,5 +1,6 @@ import { FC, useContext, useState } from 'react' import { observer } from 'mobx-react-lite' +import * as Tooltip from '@radix-ui/react-tooltip' import { Button, Dropdown, Divider, IconTrash, IconMail, IconMoreHorizontal } from '@supabase/ui' import { useStore } from 'hooks' @@ -8,8 +9,14 @@ import { post, delete_ } from 'lib/common/fetch' import { API_URL } from 'lib/constants' import { PageContext } from 'pages/project/[ref]/auth/users' import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmModal' +import { User } from './Users.types' -const UserDropdown: FC<{ user: any }> = ({ user }) => { +interface Props { + user: User + canRemoveUser: boolean +} + +const UserDropdown: FC = ({ user, canRemoveUser }) => { const PageState: any = useContext(PageContext) const { ui } = useStore() const [loading, setLoading] = useState(false) @@ -134,9 +141,32 @@ const UserDropdown: FC<{ user: any }> = ({ user }) => { ) : null} - }> - Delete user - + + + } + disabled={!canRemoveUser} + > + Delete user + + + {!canRemoveUser && ( + + +
+ + You need additional permissions to delete users + +
+
+ )} +
} > diff --git a/studio/components/interfaces/Authentication/Users/Users.types.ts b/studio/components/interfaces/Authentication/Users/Users.types.ts new file mode 100644 index 00000000000..bc47af875d8 --- /dev/null +++ b/studio/components/interfaces/Authentication/Users/Users.types.ts @@ -0,0 +1,15 @@ +export interface User { + id: string + email: string + phone: string + email_confirmed_at: string + phone_confirmed_at: string + created_at: string + last_sign_in_at: string + raw_app_meta_data?: { + provider: string + } + app_metadata?: { + provider: string + } +} diff --git a/studio/components/interfaces/Authentication/Users/UsersList.tsx b/studio/components/interfaces/Authentication/Users/UsersList.tsx index dd3ffc12b1e..d3a1144c184 100644 --- a/studio/components/interfaces/Authentication/Users/UsersList.tsx +++ b/studio/components/interfaces/Authentication/Users/UsersList.tsx @@ -6,9 +6,12 @@ import { PageContext } from 'pages/project/[ref]/auth/users' import Table from 'components/to-be-cleaned/Table' import UserListItem from './UsersListItem' import UsersPagination from './UsersPagination' +import { checkPermissions } from 'hooks' +import { PermissionAction } from '@supabase/shared-types/out/constants' const UsersList = ({}) => { const PageState: any = useContext(PageContext) + const canRemoveUser = checkPermissions(PermissionAction.TENANT_SQL_DELETE, 'auth.users') return ( @@ -56,7 +59,9 @@ const UsersList = ({}) => { )} {PageState.users.length > 0 && - PageState.users.map((x: any) => )} + PageState.users.map((x: any) => ( + + ))} diff --git a/studio/components/interfaces/Authentication/Users/UsersListItem.tsx b/studio/components/interfaces/Authentication/Users/UsersListItem.tsx index a128f957f69..0210f8926a9 100644 --- a/studio/components/interfaces/Authentication/Users/UsersListItem.tsx +++ b/studio/components/interfaces/Authentication/Users/UsersListItem.tsx @@ -6,26 +6,14 @@ import Table from 'components/to-be-cleaned/Table' import SimpleCodeBlock from 'components/to-be-cleaned/SimpleCodeBlock' import UserDropdown from './UserDropdown' import { getDateFromIsoString } from './Users.utils' +import { User } from './Users.types' interface Props { - user: { - id: string - email: string - phone: string - email_confirmed_at: string - phone_confirmed_at: string - created_at: string - last_sign_in_at: string - raw_app_meta_data?: { - provider: string - } - app_metadata?: { - provider: string - } - } + user: User + canRemoveUser: boolean } -const UserListItem: FC = ({ user }) => { +const UserListItem: FC = ({ user, canRemoveUser }) => { const isUserConfirmed = user.email_confirmed_at || user.phone_confirmed_at const createdAt = getDateFromIsoString(user.created_at) const lastSignedIn = getDateFromIsoString(user.last_sign_in_at) @@ -63,7 +51,7 @@ const UserListItem: FC = ({ user }) => { - + ) diff --git a/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx b/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx index 424235909af..e3e0390e19e 100644 --- a/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx +++ b/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx @@ -88,21 +88,17 @@ const GeneralSettings = observer(() => { return ( - {!canUpdateOrganization && ( -

- You need additional permissions to manage this organization's settings -

- )} +
} diff --git a/studio/components/interfaces/Settings/General/General.tsx b/studio/components/interfaces/Settings/General/General.tsx index b14935f30b2..b0ac84bfebd 100644 --- a/studio/components/interfaces/Settings/General/General.tsx +++ b/studio/components/interfaces/Settings/General/General.tsx @@ -59,21 +59,17 @@ const General: FC = ({}) => { - {!canUpdateProject && ( -

- You need additional permissions to manage this project's settings -

- )} +
} diff --git a/studio/components/interfaces/Settings/Logs/LogColumnRenderers/FunctionsLogsColumnRender.tsx b/studio/components/interfaces/Settings/Logs/LogColumnRenderers/FunctionsLogsColumnRender.tsx index a8b2aead5fe..264067087d9 100644 --- a/studio/components/interfaces/Settings/Logs/LogColumnRenderers/FunctionsLogsColumnRender.tsx +++ b/studio/components/interfaces/Settings/Logs/LogColumnRenderers/FunctionsLogsColumnRender.tsx @@ -19,7 +19,12 @@ export default [ key: 'level', headerRenderer: () => , name: 'level', - formatter: (data: any) => , + formatter: (data: any) => { + if (data.row.event_type === 'uncaughtException') { + return + } + return + }, width: 24, resizable: true, }, diff --git a/studio/components/interfaces/Settings/Logs/LogEventChart.tsx b/studio/components/interfaces/Settings/Logs/LogEventChart.tsx index 79a680f3040..ac4e51cca10 100644 --- a/studio/components/interfaces/Settings/Logs/LogEventChart.tsx +++ b/studio/components/interfaces/Settings/Logs/LogEventChart.tsx @@ -1,99 +1,116 @@ import { BarChart } from 'components/to-be-cleaned/Charts/ChartRenderer' import dayjs from 'dayjs' -import { LogData } from '.' +import { EventChartData, isUnixMicro, LogData, unixMicroToIsoTimestamp } from '.' import { useMemo } from 'react' interface Props { - data?: LogData[] - onBarClick: (timestamp: number) => void + data?: EventChartData[] + onBarClick: (isoTimestamp: string) => void } -type TimestampMap = { [timestamp: number]: number } const LogEventChart: React.FC = ({ data, onBarClick }) => { if (!data) return null - const aggregated = useAggregated(data) + // TODO: remove once endpoint returns iso timestamp directly + const transformedData = useMemo(() => { + return data.map((d) => { + const iso = isUnixMicro(d.timestamp) + ? unixMicroToIsoTimestamp(d.timestamp) + : dayjs(d.timestamp).toISOString() + + return { + ...d, + timestamp: iso, + // needed for bar chart + // TODO: remove once bar chart is refactored + period_start: iso, + } + }) + }, [JSON.stringify(data)]) return ( { if (!v || !v?.activePayload?.[0]?.payload) return - const timestamp = v.activePayload[0].payload.timestamp + const unixOrIsoTimestamp = v.activePayload[0].payload.timestamp + const isoTimestamp = isUnixMicro(unixOrIsoTimestamp) + ? unixMicroToIsoTimestamp(unixOrIsoTimestamp) + : unixOrIsoTimestamp // 60s before - onBarClick((Number(timestamp) + 60) * 1000 * 1000) + onBarClick(isoTimestamp) }} - customDateFormat="MMM D, HH:mm" + customDateFormat="MMM D, HH:mm:s" noDataMessage={''} /> ) } -const useAggregated = (data: LogData[]) => { - const truncateToMinute = (micro: number) => Math.floor(micro / 1000 / 1000 / 60) * 60 - const truncateToHour = (micro: number) => Math.floor(micro / 1000 / 1000 / 60 / 60) * 60 * 60 - const getDiffMinute = (currentTimestamp: number, olderTimestamp: number) => - Math.round((currentTimestamp - olderTimestamp) / 1000 / 60) - const getDiffHour = (currentTimestamp: number, olderTimestamp: number) => - Math.round((currentTimestamp - olderTimestamp) / 1000 / 60 / 60) - const diffMultiplierMinute = (v: number) => v * 60 - const diffMultiplierHour = (v: number) => v * 60 * 60 - return useMemo(() => { - const oldest = data[data.length - 1] - if (!oldest) return - const latest = data[0] - const oldestDayjs = dayjs(oldest.timestamp / 1000) - const latestDayjs = dayjs(latest.timestamp / 1000) - let truncFunc = truncateToMinute - let getDiff = getDiffMinute - let diffMultiplier = diffMultiplierMinute - if (Math.abs(oldestDayjs.diff(latestDayjs, 'day', true)) > 0.25) { - truncFunc = truncateToHour - getDiff = getDiffHour - diffMultiplier = diffMultiplierHour - } +// const useAggregated = (data: LogData[]) => { +// const truncateToMinute = (micro: number) => Math.floor(micro / 1000 / 1000 / 60) * 60 +// const truncateToHour = (micro: number) => Math.floor(micro / 1000 / 1000 / 60 / 60) * 60 * 60 +// const getDiffMinute = (currentTimestamp: number, olderTimestamp: number) => +// Math.round((currentTimestamp - olderTimestamp) / 1000 / 60) +// const getDiffHour = (currentTimestamp: number, olderTimestamp: number) => +// Math.round((currentTimestamp - olderTimestamp) / 1000 / 60 / 60) +// const diffMultiplierMinute = (v: number) => v * 60 +// const diffMultiplierHour = (v: number) => v * 60 * 60 +// return useMemo(() => { +// const oldest = data[data.length - 1] +// if (!oldest) return +// const latest = data[0] +// const oldestDayjs = dayjs(oldest.timestamp / 1000) +// const latestDayjs = dayjs(latest.timestamp / 1000) +// let truncFunc = truncateToMinute +// let getDiff = getDiffMinute +// let diffMultiplier = diffMultiplierMinute +// if (Math.abs(oldestDayjs.diff(latestDayjs, 'day', true)) > 0.25) { +// truncFunc = truncateToHour +// getDiff = getDiffHour +// diffMultiplier = diffMultiplierHour +// } - const countMap = data - .map((d) => { - // truncate to per-minute - return { ...d, timestamp: truncateToMinute(d.timestamp) } - }) - .reduce((acc, d) => { - if (acc[d.timestamp]) { - acc[d.timestamp] = acc[d.timestamp] + 1 - } else { - acc[d.timestamp] = 1 - } - return acc - }, {} as TimestampMap) +// const countMap = data +// .map((d) => { +// // truncate to per-minute +// return { ...d, timestamp: truncateToMinute(d.timestamp) } +// }) +// .reduce((acc, d) => { +// if (acc[d.timestamp]) { +// acc[d.timestamp] = acc[d.timestamp] + 1 +// } else { +// acc[d.timestamp] = 1 +// } +// return acc +// }, {} as TimestampMap) - // Add in additional data points for empty minutes - const oldestEvent = data[data.length - 1] - if (!oldestEvent) return [] - const currentTimestamp = new Date().getTime() - const oldestTimestampMicro = oldestEvent.timestamp - const latestTimestamp = truncFunc(data[0]['timestamp']) - const diff = getDiff(currentTimestamp, oldestTimestampMicro / 1000) - for (const toAdd of Array.from(Array(diff).keys())) { - const tsToCheck = truncFunc(oldestTimestampMicro) + diffMultiplier(toAdd) - if (!(tsToCheck in countMap) && tsToCheck <= latestTimestamp) { - countMap[tsToCheck] = 0 - } - } +// // Add in additional data points for empty minutes +// const oldestEvent = data[data.length - 1] +// if (!oldestEvent) return [] +// const currentTimestamp = new Date().getTime() +// const oldestTimestampMicro = oldestEvent.timestamp +// const latestTimestamp = truncFunc(data[0]['timestamp']) +// const diff = getDiff(currentTimestamp, oldestTimestampMicro / 1000) +// for (const toAdd of Array.from(Array(diff).keys())) { +// const tsToCheck = truncFunc(oldestTimestampMicro) + diffMultiplier(toAdd) +// if (!(tsToCheck in countMap) && tsToCheck <= latestTimestamp) { +// countMap[tsToCheck] = 0 +// } +// } - let aggregated = [] - for (const [key, value] of Object.entries(countMap)) { - const v: number = Number(key) - aggregated.push({ - timestamp: key, - timestampMicro: v * 1000 * 1000, - count: value, - }) - } - return aggregated.sort((a, b) => a.timestampMicro - b.timestampMicro) - }, [JSON.stringify(data)]) -} +// let aggregated = [] +// for (const [key, value] of Object.entries(countMap)) { +// const v: number = Number(key) +// aggregated.push({ +// timestamp: key, +// timestampMicro: v * 1000 * 1000, +// count: value, +// }) +// } +// return aggregated.sort((a, b) => a.timestampMicro - b.timestampMicro) +// }, [JSON.stringify(data)]) +// } export default LogEventChart diff --git a/studio/components/interfaces/Settings/Logs/LogTable.tsx b/studio/components/interfaces/Settings/Logs/LogTable.tsx index c3a6b14a94a..3408e07ecca 100644 --- a/studio/components/interfaces/Settings/Logs/LogTable.tsx +++ b/studio/components/interfaces/Settings/Logs/LogTable.tsx @@ -1,20 +1,19 @@ -import dayjs from 'dayjs' import { useEffect, useState, useMemo } from 'react' import { Alert, Button, IconEye, IconEyeOff, Input } from '@supabase/ui' import DataGrid from '@supabase/react-data-grid' import LogSelection, { LogSelectionProps } from './LogSelection' import { LogData, QueryType } from './Logs.types' -import { SeverityFormatter, ResponseCodeFormatter, HeaderFormmater } from './LogsFormatters' import { isDefaultLogPreviewFormat } from './Logs.utils' -// column renders +import CSVButton from 'components/ui/CSVButton' import DatabaseApiColumnRender from './LogColumnRenderers/DatabaseApiColumnRender' import DatabasePostgresColumnRender from './LogColumnRenderers/DatabasePostgresColumnRender' -import CSVButton from 'components/ui/CSVButton' import DefaultPreviewColumnRenderer from './LogColumnRenderers/DefaultPreviewColumnRenderer' import { LogQueryError } from '.' import ResourcesExceededErrorRenderer from './LogsErrorRenderers/ResourcesExceededErrorRenderer' import DefaultErrorRenderer from './LogsErrorRenderers/DefaultErrorRenderer' +import FunctionsLogsColumnRender from './LogColumnRenderers/FunctionsLogsColumnRender' +import FunctionsEdgeColumnRender from './LogColumnRenderers/FunctionsEdgeColumnRender' interface Props { data?: Array @@ -100,88 +99,10 @@ const LogTable = ({ break case 'fn_edge': - columns = [ - { - key: 'timestamp', - headerRenderer: () => ( -
- -
- ), - name: 'timestamp', - formatter: (data: any) => ( - - - {dayjs(data?.row?.timestamp / 1000).format('DD MMM')} - - - {dayjs(data?.row?.timestamp / 1000).format('HH:mm:ss')} - - - ), - width: 128, - }, - { - key: 'status_code', - headerRenderer: () => , - name: 'status_code', - formatter: (data: any) => ( - - ), - width: 0, - resizable: true, - }, - { - key: 'method', - headerRenderer: () => , - width: 0, - resizable: true, - }, - { - key: 'id', - headerRenderer: () => , - name: 'id', - resizable: true, - }, - ] + columns = FunctionsEdgeColumnRender break case 'functions': - columns = [ - { - key: 'timestamp', - headerRenderer: () => ( -
- -
- ), - name: 'timestamp', - formatter: (data: any) => ( - - - {dayjs(data?.row?.timestamp / 1000).format('DD MMM')} - - - {dayjs(data?.row?.timestamp / 1000).format('HH:mm:ss')} - - - ), - width: 128, - resizable: true, - }, - { - key: 'level', - headerRenderer: () => , - name: 'level', - formatter: (data: any) => , - width: 24, - resizable: true, - }, - { - key: 'event_message', - headerRenderer: () => , - resizable: true, - }, - ] + columns = FunctionsLogsColumnRender break default: diff --git a/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx b/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx index ed4fb75d0d8..bda56d1bfad 100644 --- a/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx +++ b/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx @@ -24,8 +24,6 @@ const DatePickers: React.FC = ({ to, from, onChange, helpers }) => { const selectedHelper = helpers.find((helper) => { if (to === helper.calcTo() && from === helper.calcFrom()) { return true - } else if (helper.text === helperValue) { - return true } else { return false } @@ -33,8 +31,10 @@ const DatePickers: React.FC = ({ to, from, onChange, helpers }) => { useEffect(() => { if (selectedHelper && helperValue !== selectedHelper.text) { setHelperValue(selectedHelper.text) + } else if (!selectedHelper && (to || from)) { + setHelperValue('') } - }, [selectedHelper]) + }, [selectedHelper, to, from]) return (
@@ -66,6 +66,7 @@ const DatePickers: React.FC = ({ to, from, onChange, helpers }) => { { setHelperValue('') if (onChange) onChange(value) diff --git a/studio/components/interfaces/Settings/Logs/Logs.constants.ts b/studio/components/interfaces/Settings/Logs/Logs.constants.ts index aa915d032d2..9f1f791f8fa 100644 --- a/studio/components/interfaces/Settings/Logs/Logs.constants.ts +++ b/studio/components/interfaces/Settings/Logs/Logs.constants.ts @@ -254,7 +254,7 @@ export const LOGS_TABLES = { functions: LogsTableName.FUNCTIONS, fn_edge: LogsTableName.FN_EDGE, auth: LogsTableName.AUTH, - realtime: LogsTableName.REALTIME + realtime: LogsTableName.REALTIME, } export const LOGS_SOURCE_DESCRIPTION = { @@ -266,8 +266,6 @@ export const LOGS_SOURCE_DESCRIPTION = { [LogsTableName.REALTIME]: 'Realtime server for Postgres logical replication broadcasting', } -export const genCountQuery = (table: string): string => `SELECT count(*) as count FROM ${table}` - export const genQueryParams = (params: { [k: string]: string }) => { // remove keys which are empty strings, null, or undefined for (const k in params) { diff --git a/studio/components/interfaces/Settings/Logs/Logs.types.ts b/studio/components/interfaces/Settings/Logs/Logs.types.ts index 7e5280a45a8..d99a3d214b1 100644 --- a/studio/components/interfaces/Settings/Logs/Logs.types.ts +++ b/studio/components/interfaces/Settings/Logs/Logs.types.ts @@ -7,9 +7,13 @@ interface Metadata { export type DatePickerToFrom = { to: string | null; from: string | null } export type LogSearchCallback = ( + event: + | 'search-input-change' + | 'event-chart-bar-click' + | 'datepicker-change', filters: { - query: string - } & DatePickerToFrom + query?: string + } & Partial ) => void export interface LogsWarning { @@ -48,6 +52,11 @@ export interface CountData { count: number } +export interface EventChartData { + count: number + timestamp: string | number +} + type LFResponse = { result: T[] error?: { @@ -63,9 +72,10 @@ type LFResponse = { } type ApiError = string -export type LogQueryError = Omit, "result"> | ApiError +export type LogQueryError = Omit, 'result'> | ApiError export type Count = LFResponse +export type EventChart = LFResponse export type Logs = LFResponse diff --git a/studio/components/interfaces/Settings/Logs/Logs.utils.ts b/studio/components/interfaces/Settings/Logs/Logs.utils.ts index 95a373b8f8a..2c2f2f58200 100644 --- a/studio/components/interfaces/Settings/Logs/Logs.utils.ts +++ b/studio/components/interfaces/Settings/Logs/Logs.utils.ts @@ -1,5 +1,5 @@ -import { Filters, LogData, LogsTableName, SQL_FILTER_TEMPLATES } from '.' -import dayjs from 'dayjs' +import { Filters, LogData, LogsEndpointParams, LogsTableName, SQL_FILTER_TEMPLATES } from '.' +import dayjs, { Dayjs } from 'dayjs' import { get } from 'lodash' /** @@ -164,4 +164,74 @@ export const genDefaultQuery = (table: LogsTableName, filters: Filters) => { /** * SQL query to retrieve only one log */ -export const genSingleLogQuery = (table: LogsTableName, id: string) => `select id, timestamp, event_message, metadata from ${table} where id = '${id}' limit 1` \ No newline at end of file +export const genSingleLogQuery = (table: LogsTableName, id: string) => + `select id, timestamp, event_message, metadata from ${table} where id = '${id}' limit 1` + +export const genCountQuery = (table: string): string => `SELECT count(*) as count FROM ${table}` + +/** calculates how much the chart start datetime should be offset given the current datetime filter params */ +export const calcChartStart = (params: Partial): [Dayjs, string] => { + const ite = params.iso_timestamp_end ? dayjs(params.iso_timestamp_end) : dayjs() + const its = params.iso_timestamp_start ? dayjs(params.iso_timestamp_start) : dayjs() + let trunc = 'minute' + let extendValue = 60 * 6 + const minuteDiff = ite.diff(its, 'minute') + const hourDiff = ite.diff(its, 'hour') + if (minuteDiff > (60 * 12)) { + trunc = 'hour' + extendValue = 24 * 3 + } else if (hourDiff > 24 * 5) { + trunc = 'day' + extendValue = 7 + } + return [its.add(-extendValue, trunc), trunc] +} + +/** + * + * generates log event chart query + */ +export const genChartQuery = ( + table: LogsTableName, + params: LogsEndpointParams, + filters: Filters +) => { + const [startOffset, trunc] = calcChartStart(params) + const where = _genWhereStatement(table, filters) + return ` +SELECT + timestamp_trunc(t.timestamp, ${trunc}) as timestamp, + count(timestamp) as count +FROM + ${table} t +${where ? where + ` and t.timestamp > '${startOffset.toISOString()}'` : ""} +GROUP BY +timestamp +ORDER BY + timestamp ASC + ` +} + + +type TsPair = [string | '', string | ''] +export const ensureNoTimestampConflict = ([initialStart, initialEnd]: TsPair, [nextStart, nextEnd]: TsPair): TsPair => { + if (initialStart && initialEnd && nextEnd && !nextStart) { + const resolvedDiff = dayjs(nextEnd).diff(dayjs(initialStart)) + let start = dayjs(initialStart) + + if (resolvedDiff <= 0) { + // start ts is definitely before end ts + const currDiff = Math.abs( + dayjs(initialEnd).diff(start, 'minute') + ) + // shift start ts backwards by the current ts difference + start = dayjs(nextEnd).subtract(currDiff, 'minute') + } + return [start.toISOString(), nextEnd] + } else if (!nextEnd && nextStart) { + return [nextStart, initialEnd] + } else { + return [nextStart, nextEnd] + } + +} \ No newline at end of file diff --git a/studio/components/interfaces/Settings/Logs/LogsFormatters.tsx b/studio/components/interfaces/Settings/Logs/LogsFormatters.tsx index 35a7cf9d8c5..1ac89c1f5ae 100644 --- a/studio/components/interfaces/Settings/Logs/LogsFormatters.tsx +++ b/studio/components/interfaces/Settings/Logs/LogsFormatters.tsx @@ -92,7 +92,13 @@ export const ResponseCodeFormatter = ({ value }: any) => { * for http response codes */ -export const SeverityFormatter = ({ value }: { value: string }) => { +export const SeverityFormatter = ({ + value, + uppercase = true, +}: { + value: string + uppercase?: boolean +}) => { if (!value) { return (
@@ -101,9 +107,11 @@ export const SeverityFormatter = ({ value }: { value: string }) => { ) } - value = value.toUpperCase() + const uppercasedValue = value.toUpperCase() + const text = uppercase ? uppercasedValue : value - switch (value) { + switch (uppercasedValue) { + case 'UNCAUGHTEXCEPTION': case 'PANIC': case 'FATAL': case 'ERROR': @@ -112,7 +120,7 @@ export const SeverityFormatter = ({ value }: { value: string }) => {
- {value} + {text}
) break @@ -125,7 +133,7 @@ export const SeverityFormatter = ({ value }: { value: string }) => {
- {value} + {text}
) break @@ -136,7 +144,7 @@ export const SeverityFormatter = ({ value }: { value: string }) => {
- {value} + {text}
) break @@ -147,7 +155,7 @@ export const SeverityFormatter = ({ value }: { value: string }) => {
- {value} + {text}
) break @@ -157,7 +165,7 @@ export const SeverityFormatter = ({ value }: { value: string }) => { return (
- +
) diff --git a/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx b/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx index a972f28d9c1..14b8880ff19 100644 --- a/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx +++ b/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx @@ -11,7 +11,7 @@ import { QueryType, LogEventChart, Filters, - unixMicroToIsoTimestamp, + ensureNoTimestampConflict, } from 'components/interfaces/Settings/Logs' import useLogsPreview from 'hooks/analytics/useLogsPreview' import PreviewFilterPanel from 'components/interfaces/Settings/Logs/PreviewFilterPanel' @@ -52,7 +52,7 @@ export const LogsPreviewer: React.FC = ({ const table = !tableName ? LOGS_TABLES[queryType] : tableName const [ - { error, logData, params, newCount, filters, isLoading }, + { error, logData, params, newCount, filters, isLoading, eventChartData }, { loadOlder, setFilters, refresh, setParams }, ] = useLogsPreview(projectRef as string, table, filterOverride) @@ -83,24 +83,48 @@ export const LogsPreviewer: React.FC = ({ }, }) } - - const handleSearch: LogSearchCallback = async ({ query = '', to, from }) => { - setParams((prev) => ({ - ...prev, - iso_timestamp_start: from || prev.iso_timestamp_start || '', - iso_timestamp_end: to || prev.iso_timestamp_end || '', - })) - setFilters((prev) => ({ ...prev, search_query: query })) - router.push({ - pathname: router.pathname, - query: { - ...router.query, - s: query || '', - its: from || its || '', - ite: to || ite || '', - }, - }) + const handleSearch: LogSearchCallback = async (event, { query, to, from }) => { + if (event === 'search-input-change') { + setFilters((prev) => ({ ...prev, search_query: query })) + router.push({ + pathname: router.pathname, + query: { ...router.query, s: query }, + }) + } else if (event === 'event-chart-bar-click') { + const [nextStart, nextEnd] = ensureNoTimestampConflict( + [params.iso_timestamp_start || '', params.iso_timestamp_end || ''], + [from || '', to || ''] + ) + setParams((prev) => ({ + ...prev, + iso_timestamp_start: nextStart, + iso_timestamp_end: nextEnd, + })) + router.push({ + pathname: router.pathname, + query: { + ...router.query, + its: nextStart, + ite: nextEnd, + }, + }) + } else if (event === 'datepicker-change') { + setParams((prev) => ({ + ...prev, + iso_timestamp_start: from || '', + iso_timestamp_end: to || '', + })) + router.push({ + pathname: router.pathname, + query: { + ...router.query, + its: from || '', + ite: to || '', + }, + }) + } } + return (
= ({
{showChart && ( { - const to = unixMicroToIsoTimestamp(timestampMicro) - handleSearch({ query: filters.search_query as string, to, from: null }) + data={!isLoading && eventChartData ? eventChartData : undefined} + onBarClick={(isoTimestamp) => { + handleSearch('event-chart-bar-click', { + query: filters.search_query as string, + to: isoTimestamp as string, + from: null, + }) }} /> )} diff --git a/studio/components/interfaces/Settings/Logs/PreviewFilterPanel.tsx b/studio/components/interfaces/Settings/Logs/PreviewFilterPanel.tsx index 68867800ec9..52e670b762d 100644 --- a/studio/components/interfaces/Settings/Logs/PreviewFilterPanel.tsx +++ b/studio/components/interfaces/Settings/Logs/PreviewFilterPanel.tsx @@ -97,8 +97,10 @@ const PreviewFilterPanel: FC = ({ Refresh ) - const handleSearch = (partial: Partial[0]>) => - onSearch({ query: search, to: partial?.to || null, from: partial?.from || null }) + const handleDatepickerChange = ({ to, from }: Partial[1]>) => { + onSearch('datepicker-change', { to, from }) + } + const handleInputSearch = (query: string) => onSearch('search-input-change', { query }) return (
= ({ onSubmit={(e) => { // prevent redirection e.preventDefault() - handleSearch({}) + handleInputSearch(search) }} > = ({ onChange={(e) => setSearch(e.target.value)} onBlur={(e: React.FocusEvent) => { setSearch(e.target.value) - handleSearch({ query: e.target.value }) + handleInputSearch(e.target.value) }} icon={
@@ -131,7 +133,7 @@ const PreviewFilterPanel: FC = ({ actions={ hasEdits && ( diff --git a/studio/components/ui/NoPermission.tsx b/studio/components/ui/NoPermission.tsx index 89853ee8d2a..436112b4307 100644 --- a/studio/components/ui/NoPermission.tsx +++ b/studio/components/ui/NoPermission.tsx @@ -3,32 +3,47 @@ import { FC } from 'react' interface Props { resourceText: string + isFullPage?: boolean } -const NoPermission: FC = ({ resourceText }) => ( -
-
-
- -
-
-
-

You need additional permissions to {resourceText}

-
-

- Contact your organization owner or adminstrator for assistance. -

+const NoPermission: FC = ({ resourceText, isFullPage = false }) => { + const NoPermissionMessage = ({}) => ( +
+
+
+ +
+
+
+

You need additional permissions to {resourceText}

+
+

+ Contact your organization owner or adminstrator for assistance. +

+
-
-) + ) + + if (isFullPage) { + return ( +
+
+ +
+
+ ) + } else { + return + } +} export default NoPermission diff --git a/studio/hooks/analytics/useLogsPreview.tsx b/studio/hooks/analytics/useLogsPreview.tsx index d22fa934c6c..b31575e956b 100644 --- a/studio/hooks/analytics/useLogsPreview.tsx +++ b/studio/hooks/analytics/useLogsPreview.tsx @@ -1,6 +1,9 @@ import { Count, + EventChart, + EventChartData, Filters, + genChartQuery, genCountQuery, genDefaultQuery, genQueryParams, @@ -27,6 +30,7 @@ interface Data { filters: Filters params: LogsEndpointParams oldestTimestamp?: string + eventChartData: EventChartData[] | null } interface Handlers { loadOlder: () => void @@ -116,11 +120,40 @@ function useLogsPreview( }) const newCount = countData?.result?.[0]?.count ?? 0 + // chart data + + const chartQuery = genChartQuery(table, params, filters) + const chartUrl = () => { + // cancel request if no sql provided + if (!params.sql) { + // return null to restrict unnecessary requests to api + // https://swr.vercel.app/docs/conditional-fetching#conditional + return null + } + + return `${API_URL}/projects/${projectRef}/analytics/endpoints/logs.all?${genQueryParams({ + iso_timestamp_end: params.iso_timestamp_end, + project: params.project, + sql: chartQuery, + } as any)}` + } + + const { data: eventChartResponse, mutate: refreshEventChart } = useSWR( + chartUrl, + get, + { + revalidateOnFocus: false, + dedupingInterval: 10000, + refreshInterval: 0, + } + ) + const refresh = async () => { const generatedSql = genDefaultQuery(table, filters) setParams((prev) => ({ ...prev, sql: generatedSql })) setLatestRefresh(new Date().toISOString()) setSize(1) + refreshEventChart() } let error: null | string | object = swrError ? swrError.message : null @@ -155,6 +188,7 @@ function useLogsPreview( filters, params, oldestTimestamp: oldestTimestamp ? String(oldestTimestamp) : undefined, + eventChartData: eventChartResponse?.result || null, }, { setFilters: handleSetFilters, diff --git a/studio/pages/org/[slug]/settings.tsx b/studio/pages/org/[slug]/settings.tsx index 7ca63b1cd18..c7d79c06efb 100644 --- a/studio/pages/org/[slug]/settings.tsx +++ b/studio/pages/org/[slug]/settings.tsx @@ -4,7 +4,7 @@ import { useRouter } from 'next/router' import { createContext, useContext, useEffect, useState } from 'react' import { Member, NextPageWithLayout, Organization, Project, Role, User } from 'types' -import { useOrganizationDetail, useOrganizationRoles, useStore, withAuth } from 'hooks' +import { useFlag, useOrganizationDetail, useOrganizationRoles, useStore, withAuth } from 'hooks' import { AccountLayoutWithoutAuth } from 'components/layouts' import { GeneralSettings, @@ -21,7 +21,14 @@ const OrgSettingsLayout = withAuth( const router = useRouter() const slug = ui.selectedOrganization?.slug || '' - const { roles } = useOrganizationRoles(slug) + + const { roles: allRoles } = useOrganizationRoles(slug) + const enableBillingOnlyReadOnly = useFlag('enableBillingOnlyReadOnlyRoles') + const roles = enableBillingOnlyReadOnly + ? allRoles + : (allRoles ?? []).filter((role) => + ['Owner', 'Administrator', 'Developer'].includes(role.name) + ) // [Refactor] Eventually move away from useLocalObservable const PageState: any = useLocalObservable(() => ({ diff --git a/studio/pages/project/[ref]/auth/auth-logs.tsx b/studio/pages/project/[ref]/auth/logs.tsx similarity index 58% rename from studio/pages/project/[ref]/auth/auth-logs.tsx rename to studio/pages/project/[ref]/auth/logs.tsx index fc343aa0542..bf2fbffeee6 100644 --- a/studio/pages/project/[ref]/auth/auth-logs.tsx +++ b/studio/pages/project/[ref]/auth/logs.tsx @@ -1,14 +1,21 @@ import { observer } from 'mobx-react-lite' -import { useStore } from 'hooks' +import { PermissionAction } from '@supabase/shared-types/out/constants' + +import { NextPageWithLayout } from 'types' +import { checkPermissions, useStore } from 'hooks' import { AuthLayout } from 'components/layouts' import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' -import { NextPageWithLayout } from 'types' +import NoPermission from 'components/ui/NoPermission' const LogsPage: NextPageWithLayout = () => { const { ui } = useStore() const project = ui.selectedProject - return ( + const canReadAuthLogs = checkPermissions(PermissionAction.ANALYTICS_READ, 'logflare') + + return !canReadAuthLogs ? ( + + ) : ( <>{project && } ) } diff --git a/studio/pages/project/[ref]/auth/settings.tsx b/studio/pages/project/[ref]/auth/settings.tsx index 2762edc57c9..ea14e36bc0d 100644 --- a/studio/pages/project/[ref]/auth/settings.tsx +++ b/studio/pages/project/[ref]/auth/settings.tsx @@ -9,6 +9,7 @@ import AutoSchemaForm from 'components/interfaces/Auth/AutoSchemaForm' import RedirectDomains from 'components/interfaces/Auth/RedirectDomains/RedirectDomains' import { AuthLayout } from 'components/layouts' import { FormsContainer } from 'components/ui/Forms' +import NoPermission from 'components/ui/NoPermission' const PageLayout: NextPageWithLayout = () => { const { ui, authConfig } = useStore() @@ -17,9 +18,11 @@ const PageLayout: NextPageWithLayout = () => { authConfig.load() }, [ui.selectedProjectRef]) - // const canUpdate = checkPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') + const canReadAuthSettings = checkPermissions(PermissionAction.READ, 'custom_config_gotrue') - if (authConfig) { + if (!canReadAuthSettings) { + return + } else if (authConfig) { return ( diff --git a/studio/pages/project/[ref]/auth/templates.tsx b/studio/pages/project/[ref]/auth/templates.tsx index 74e27bf1e6f..56a21f3dee6 100644 --- a/studio/pages/project/[ref]/auth/templates.tsx +++ b/studio/pages/project/[ref]/auth/templates.tsx @@ -1,20 +1,26 @@ -import { EmailTemplates, SmtpForm } from 'components/interfaces' -import { AuthLayout } from 'components/layouts' -import { FormsContainer } from 'components/ui/Forms' -import { useStore } from 'hooks' -import { observer } from 'mobx-react-lite' import { useEffect } from 'react' +import { observer } from 'mobx-react-lite' +import { PermissionAction } from '@supabase/shared-types/out/constants' + import { NextPageWithLayout } from 'types' +import { useStore, checkPermissions } from 'hooks' +import { AuthLayout } from 'components/layouts' +import { EmailTemplates, SmtpForm } from 'components/interfaces' +import { FormsContainer } from 'components/ui/Forms' +import NoPermission from 'components/ui/NoPermission' const PageLayout: NextPageWithLayout = () => { const { ui, authConfig } = useStore() useEffect(() => { - // temporary store loader authConfig.load() }, [ui.selectedProjectRef]) - if (authConfig) { + const canReadAuthSettings = checkPermissions(PermissionAction.READ, 'custom_config_gotrue') + + if (!canReadAuthSettings) { + return + } else if (authConfig) { return ( diff --git a/studio/pages/project/[ref]/auth/users.tsx b/studio/pages/project/[ref]/auth/users.tsx index d88d8b88a14..f6151f06474 100644 --- a/studio/pages/project/[ref]/auth/users.tsx +++ b/studio/pages/project/[ref]/auth/users.tsx @@ -4,10 +4,12 @@ import { useRouter } from 'next/router' import { get } from 'lib/common/fetch' import { API_URL } from 'lib/constants' -import { useStore } from 'hooks' +import { checkPermissions, useStore } from 'hooks' import { AuthLayout } from 'components/layouts' import { Users } from 'components/interfaces/Authentication' import { NextPageWithLayout } from 'types' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import NoPermission from 'components/ui/NoPermission' export const PageContext = createContext(null) @@ -84,7 +86,13 @@ const UsersPage: NextPageWithLayout = () => { PageState!.projectKpsVersion = project?.kpsVersion }, [project]) - return + const canReadUsers = checkPermissions(PermissionAction.TENANT_SQL_SELECT, 'auth.users') + + return !canReadUsers ? ( + + ) : ( + + ) } UsersPage.getLayout = (page) => ( diff --git a/studio/pages/project/[ref]/reports/index.tsx b/studio/pages/project/[ref]/reports/index.tsx index d49fb9663fa..0a32a8ab1f4 100644 --- a/studio/pages/project/[ref]/reports/index.tsx +++ b/studio/pages/project/[ref]/reports/index.tsx @@ -22,9 +22,7 @@ const PageLayout: NextPageWithLayout = () => { const { ui } = useStore() const project = ui.selectedProject - // const canCreateReport = checkPermissions(PermissionAction.CREATE, 'user_content', { - // resource: { type: 'report' }, - // }) + // const canCreateReport = checkPermissions(PermissionAction.CR const contentStore = useProjectContentStore(ref) useEffect(() => { diff --git a/studio/tests/pages/projects/LogTable.test.js b/studio/tests/pages/projects/LogTable.test.js index 12446159483..6ed545ea736 100644 --- a/studio/tests/pages/projects/LogTable.test.js +++ b/studio/tests/pages/projects/LogTable.test.js @@ -62,6 +62,47 @@ test('can display standard preview table columns', async () => { await expect(screen.findByText(/12345/)).rejects.toThrow() await expect(screen.findByText(fakeMicroTimestamp)).rejects.toThrow() }) + +test.each([ + { + queryType: 'functions', + data: [ + { + event_message: 'This is a error log\n', + event_type: 'log', + function_id: '001b0b08-331c-403e-810c-a2004b03a019', + level: 'error', + timestamp: 1659545029083869, + id: '3475cf6f-2929-4296-ab44-ce2c17069937', + }, + ], + includes: [/ERROR/], + excludes: ['undefined', 'null'], + }, + { + queryType: 'functions', + data: [ + { + event_message: 'This is a uncaughtExceptop\n', + event_type: 'uncaughtException', + function_id: '001b0b08-331c-403e-810c-a2004b03a019', + timestamp: 1659545029083869, + id: '4475cf6f-2929-4296-ab44-ce2c17069937', + level: null, + }, + ], + includes: [/uncaughtException/], + excludes: [/ERROR/], + }, +])('table col renderer for $queryType', async ({ queryType, data, includes, excludes }) => { + render() + + await Promise.all([ + ...includes.map((text) => screen.findByText(text)), + ...excludes.map((text) => expect(screen.findByText(text)).rejects.toThrow()), + ]) +}) + test('can display custom columns and headers based on data input', async () => { render() await waitFor(() => screen.getByText(/some_header/)) diff --git a/studio/tests/pages/projects/Logs.utils.test.js b/studio/tests/pages/projects/Logs.utils.test.js index eb3d0a56fe1..1b4f66eee48 100644 --- a/studio/tests/pages/projects/Logs.utils.test.js +++ b/studio/tests/pages/projects/Logs.utils.test.js @@ -1,8 +1,11 @@ import { + ensureNoTimestampConflict, + genChartQuery, genDefaultQuery, LogsTableName, SQL_FILTER_TEMPLATES, } from 'components/interfaces/Settings/Logs' +import dayjs from 'dayjs' describe.each(Object.values(LogsTableName))('%s', (table) => { const templates = SQL_FILTER_TEMPLATES[table] @@ -60,5 +63,38 @@ describe.each(Object.values(LogsTableName))('%s', (table) => { contains.forEach((str) => expect(generated).toContain(str)) excludes.forEach((str) => expect(generated).not.toContain(str)) }) + test('generated chart query should include filters', () => { + const generated = genChartQuery(table, {}, { 'override.nested': 'something' }) + expect(generated).toContain('override.nested') + expect(generated).toContain('something') + expect(generated).toContain('timestamp_trunc') + }) }) }) + +const base = dayjs().subtract(2, 'day') +const baseIso = base.toISOString() +test.each([ + { + case: 'next start is after initial start', + initial: [base.subtract(1, 'day').toISOString(), baseIso], + next: [base.subtract(2, 'day').toISOString(), null], + expected: [base.subtract(2, 'day').toISOString(), baseIso], + }, + { + case: 'next end is before initial start', + initial: [base.subtract(1, 'day').toISOString(), baseIso], + next: [null, base.subtract(2, 'day').toISOString()], + expected: [base.subtract(3, 'day').toISOString(), base.subtract(2, 'day').toISOString()], + }, + { + case: 'next end is not before initial start', + initial: [base.subtract(2, 'day').toISOString(), baseIso], + next: [null, base.subtract(1, 'day').toISOString()], + expected: [base.subtract(2, 'day').toISOString(), base.subtract(1, 'day').toISOString()], + }, +])('ensure no timestamp conflict: $case', ({ initial, next, expected }) => { + const result = ensureNoTimestampConflict(initial, next) + expect(result[0]).toEqual(expected[0]) + expect(result[1]).toEqual(expected[1]) +}) diff --git a/studio/tests/pages/projects/LogsPreviewer.test.js b/studio/tests/pages/projects/LogsPreviewer.test.js index 0eac35647b3..a9e5abdfe25 100644 --- a/studio/tests/pages/projects/LogsPreviewer.test.js +++ b/studio/tests/pages/projects/LogsPreviewer.test.js @@ -193,6 +193,21 @@ test('poll count for new messages', async () => { await waitFor(() => screen.queryByText(/125/) === null) await screen.findByText(/some-uuid123/) }) +test('log event chart', async () => { + get.mockImplementation((url) => { + // truncate + if (url.includes('trunc')) { + return { result: [{ timestamp: new Date().toISOString(), count: 125 }] } + } + return { + result: [logDataFixture({ id: 'some-uuid123' })], + } + }) + render() + + await waitFor(() => screen.queryByText(/some-uuid123/) === null) + expect(get).toBeCalledWith(expect.stringContaining('trunc')) +}) test('s= query param will populate the search bar', async () => { const router = defaultRouterMock() @@ -219,7 +234,7 @@ test('te= query param will populate the timestamp to input', async () => { expect.stringContaining(`iso_timestamp_end=${encodeURIComponent(iso)}`) ) }) - userEvent.click(await screen.findByText('Custom')) + userEvent.click(await screen.findByTitle('Custom')) }) test('ts= query param will populate the timestamp from input', async () => { // get time 20 mins before @@ -236,7 +251,7 @@ test('ts= query param will populate the timestamp from input', async () => { expect.stringContaining(`iso_timestamp_start=${encodeURIComponent(iso)}`) ) }) - userEvent.click(await screen.findByText('Custom')) + userEvent.click(await screen.findByTitle('Custom')) await screen.findByText(new RegExp(newDate.getFullYear())) }) @@ -289,6 +304,9 @@ test('bug: load older btn does not error out when previous page is empty', async }) test('log event chart hide', async () => { + get.mockImplementation((url) => { + return { result: [] } + }) render() await screen.findByText('Events') const toggle = await screen.findByText(/Chart/) diff --git a/studio/tests/pages/projects/PreviewFilterPanel.test.js b/studio/tests/pages/projects/PreviewFilterPanel.test.js index 85ef27cdf1b..e8a55735627 100644 --- a/studio/tests/pages/projects/PreviewFilterPanel.test.js +++ b/studio/tests/pages/projects/PreviewFilterPanel.test.js @@ -49,11 +49,11 @@ test('Manual refresh', async () => { expect(mockFn).toBeCalled() }) test('Datepicker dropdown', async () => { - render() + const fn = jest.fn() + render() clickDropdown(await screen.findByText(/Last hour/)) userEvent.click(await screen.findByText(/Last 3 hours/)) - await screen.findByText(/Last 3 hours/) - await expect(screen.findByText(/Last hour/)).rejects.toThrow() + expect(fn).toBeCalled() }) test.todo('timestamp to/from filter default value')