mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
chore(docs): add dotenvx example (#31213)
* wip * wip * wip * remove seeds * wip * add branching memento * remove triple dots * proofread * chore: mention direct secret usage * chore: update README.md * chore: update docs * fix: .env.local to .env.developemnt * chore: fix typo * chore: fix commands * chore: update README.md * chore: apply PR comments * chore: rename .env.local to .env * chore: simplify dotenvx setup (#32824) * chore: bump cli * chore: disable unused seed --------- Co-authored-by: Han Qiao <sweatybridge@gmail.com> Co-authored-by: Qiao Han <qiao@supabase.io>
This commit is contained in:
27 files changed
+6155
No files matched your search
@@ -0,0 +1,32 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
# dotenv
|
||||
!.env
|
||||
!.env.*
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Supabase
|
||||
.supabase
|
||||
.vercel
|
||||
@@ -0,0 +1,186 @@
|
||||
# Integrating Dotenvx with a Supabase Project
|
||||
|
||||
This project is a full-stack Slack clone built using:
|
||||
|
||||
### Frontend:
|
||||
|
||||
- **[Next.js](https://github.com/vercel/next.js):** A React framework optimized for production.
|
||||
- **[Supabase.js](https://supabase.com/docs/library/getting-started):** For user management and real-time data syncing.
|
||||
|
||||
### Backend:
|
||||
|
||||
- **[Supabase](https://supabase.com/dashboard):** A hosted Postgres database with a RESTful API, used alongside Supabase.js.
|
||||
- **GitHub Authentication:** For user login.
|
||||
|
||||
---
|
||||
|
||||
## Introduction
|
||||
|
||||
This example demonstrates how to use [dotenvx](https://dotenvx.com/) and `config.toml` to manage multiple environments seamlessly. You'll learn how to set up local and production environments with shared, secure configurations.
|
||||
|
||||
---
|
||||
|
||||
## Core Concept
|
||||
|
||||
`config.toml` supports environment variables through the `env()` syntax. Using `dotenvx`, you can securely manage sensitive values like GitHub credentials for third-party authentication.
|
||||
|
||||
### Key Features of Dotenvx:
|
||||
|
||||
- Secrets are encrypted and stored securely, while private decryption keys are saved in `.env.keys` (excluded from version control).
|
||||
- Teams can share public keys to encrypt environment values securely.
|
||||
- Learn more: [Dotenvx secrets and encryption](https://dotenvx.com/encryption).
|
||||
|
||||
This example guides you through deploying and managing app environments with dotenvx.
|
||||
|
||||
---
|
||||
|
||||
## Structuring Environment Files
|
||||
|
||||
Following the conventions used in this project, environments are configured using dotenv files in `supabase` directory:
|
||||
|
||||
| File | Environment | `.gitignore` it? | Encrypted |
|
||||
| --------------- | ----------- | ---------------- | --------- |
|
||||
| .env.keys | All | Yes | No |
|
||||
| .env.local | Local | Yes | No |
|
||||
| .env.production | Production | No | Yes |
|
||||
| .env.preview | Branches | No | Yes |
|
||||
| .env | Any | Maybe | Yes |
|
||||
|
||||
Since `.env` file is always loaded by default, you can use it for any environment, including preview branches.
|
||||
|
||||
However, if you choose to commit `.env` to git, remember to encrypt secret values as explained in [remote development](#How-to-Use-with-Preview-Branches) section.
|
||||
|
||||
### Example: Environment-Driven Configuration
|
||||
|
||||
> **Important:** The `encrypted:` syntax only works for designated "secret" fields in the configuration (like `secret` in auth providers). Using encrypted values in other fields will not be automatically decrypted and may cause issues. If you need to protect sensitive information in non-secret fields, use environment variables with the `env()` syntax instead.
|
||||
>
|
||||
> Example of correct usage in secret fields:
|
||||
>
|
||||
> ```toml
|
||||
> [auth.external.github]
|
||||
> enabled = true
|
||||
> client_id = "encrypted:<value>" # Won't decrypt the value since client_id isn't a secret value
|
||||
> secret = "encrypted:<encrypted-value>" # Works: 'secret' is a designated secret field
|
||||
> ```
|
||||
|
||||
Using `env()` in `config.toml` simplifies environment-specific values:
|
||||
|
||||
```toml
|
||||
site_url = "env(SUPABASE_AUTH_SITE_URL)"
|
||||
additional_redirect_urls = [
|
||||
"env(SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS)"
|
||||
]
|
||||
|
||||
[auth.external.github]
|
||||
enabled = true
|
||||
client_id = "env(SUPABASE_AUTH_EXTERNAL_GITHUB_CLIENT_ID)"
|
||||
secret = "env(SUPABASE_AUTH_EXTERNAL_GITHUB_SECRET)"
|
||||
```
|
||||
|
||||
> **Note:** Alternatively, you can directly store encrypted values in your `config.toml` file:
|
||||
>
|
||||
> ```toml
|
||||
> [auth.external.github]
|
||||
> enabled = true
|
||||
> secret = "encrypted:<encrypted-value>"
|
||||
> ```
|
||||
>
|
||||
> This approach eliminates the need for environment variables but still maintains security through encryption.
|
||||
|
||||
---
|
||||
|
||||
## Local Development
|
||||
|
||||
Create `supabase/.env.local` with your own [GitHub OAuth App credentials](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app). This file should NOT be committed to git as it can contain plaintext values for secret fields.
|
||||
|
||||
```dotenv
|
||||
SUPABASE_AUTH_EXTERNAL_GITHUB_CLIENT_ID=<client-id>
|
||||
SUPABASE_AUTH_EXTERNAL_GITHUB_SECRET=<client-secret>
|
||||
```
|
||||
|
||||
Run the local stack:
|
||||
|
||||
```bash
|
||||
npx supabase start
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Visit `localhost:3000` to test the app with GitHub OAuth integration.
|
||||
|
||||
---
|
||||
|
||||
## Remote Deployment
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Vercel Account**
|
||||
- **Supabase Account**
|
||||
|
||||
1. **Create a Supabase Project:**
|
||||
|
||||
Sign up at [Supabase Dashboard](https://supabase.com/dashboard) and create a new project. After the database initializes, create `supabase/.env.production` file with your project specific values.
|
||||
|
||||
```dotenv
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://<your-project>.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=<your-project-apikey>
|
||||
```
|
||||
|
||||
2. **Configure Production Variables:**
|
||||
|
||||
Set the site URL for authentication services:
|
||||
|
||||
```dotenv
|
||||
SUPABASE_AUTH_SITE_URL=https://<your-app-url>.vercel.app/
|
||||
SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS=https://<your-app-url>.vercel.app/**
|
||||
```
|
||||
|
||||
Encrypt GitHub credentials in dotenv file:
|
||||
|
||||
```bash
|
||||
npx dotenvx set SUPABASE_AUTH_EXTERNAL_GITHUB_SECRET "<your-secret>" -f supabase/.env.production
|
||||
```
|
||||
|
||||
This also creates the encryption key in `supabase/.env.production` and the decryption key in `supabase/.env.keys`.
|
||||
|
||||
3. **Deploy to Supabase Remote:**
|
||||
|
||||
```bash
|
||||
npx dotenvx run -f supabase/.env.production -- npx supabase link
|
||||
npx dotenvx run -f supabase/.env.production -- npx supabase db push
|
||||
npx dotenvx run -f supabase/.env.production -- npx supabase config push
|
||||
```
|
||||
|
||||
### How to Use with Preview Branches
|
||||
|
||||
Dotenvx now supports encrypted secrets with Supabase's branching system. This allows you to securely manage environment-specific configurations across different branches.
|
||||
|
||||
Here's how to set up encrypted secrets for your preview branches:
|
||||
|
||||
1. **Generate Key Pair and Encrypt Your Secrets:**
|
||||
|
||||
```bash
|
||||
npx dotenvx set SUPABASE_AUTH_EXTERNAL_GITHUB_SECRET "<your-secret>" -f supabase/.env.preview
|
||||
```
|
||||
|
||||
This creates a new encryption key in `supabase/.env.preview` and a new decryption key in `supabase/.env.keys`, specifically for your preview branches.
|
||||
|
||||
2. **Update Project Secrets:**
|
||||
|
||||
We store both the production and preview decryption keys in the project's secret handler, allowing the branching executor to access and decrypt your values when configuring services:
|
||||
|
||||
```bash
|
||||
npx supabase secrets set --env-file supabase/.env.keys
|
||||
```
|
||||
|
||||
4. **Choose Your Configuration Approach:**
|
||||
- Option A: Copy the encrypted value directly into `config.toml`:
|
||||
```toml
|
||||
secret_value = "encrypted:<encrypted-value>"
|
||||
```
|
||||
- Option B: Reference the environment variable that contain the secret in `config.toml`:
|
||||
```toml
|
||||
secret_value = "env(SOME_KEY)"
|
||||
```
|
||||
Then commit your `.env.preview` file with the encrypted values. The branching executor will automatically retrieve and use these values from `.env.preview` when deploying your branch.
|
||||
|
||||
Now your preview branches will have access to the encrypted secrets while maintaining security. The branching executor will handle both database migrations and configuration updates automatically.
|
||||
@@ -0,0 +1,88 @@
|
||||
import Link from 'next/link'
|
||||
import { useContext } from 'react'
|
||||
import UserContext from '~/lib/UserContext'
|
||||
import { addChannel, deleteChannel } from '~/lib/Store'
|
||||
import TrashIcon from '~/components/TrashIcon'
|
||||
|
||||
export default function Layout(props) {
|
||||
const { signOut, user } = useContext(UserContext)
|
||||
|
||||
const slugify = (text) => {
|
||||
return text
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-') // Replace spaces with -
|
||||
.replace(/[^\w-]+/g, '') // Remove all non-word chars
|
||||
.replace(/--+/g, '-') // Replace multiple - with single -
|
||||
.replace(/^-+/, '') // Trim - from start of text
|
||||
.replace(/-+$/, '') // Trim - from end of text
|
||||
}
|
||||
|
||||
const newChannel = async () => {
|
||||
const slug = prompt('Please enter your name')
|
||||
if (slug) {
|
||||
addChannel(slugify(slug), user.id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="main flex h-screen w-screen overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<nav
|
||||
className="w-64 bg-gray-900 text-gray-100 overflow-scroll "
|
||||
style={{ maxWidth: '20%', minWidth: 150, maxHeight: '100vh' }}
|
||||
>
|
||||
<div className="p-2 ">
|
||||
<div className="p-2">
|
||||
<button
|
||||
className="bg-blue-900 hover:bg-blue-800 text-white py-2 px-4 rounded w-full transition duration-150"
|
||||
onClick={() => newChannel()}
|
||||
>
|
||||
New Channel
|
||||
</button>
|
||||
</div>
|
||||
<hr className="m-2" />
|
||||
<div className="p-2 flex flex-col space-y-2">
|
||||
<h6 className="text-xs">{user?.email}</h6>
|
||||
<button
|
||||
className="bg-blue-900 hover:bg-blue-800 text-white py-2 px-4 rounded w-full transition duration-150"
|
||||
onClick={() => signOut()}
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
<hr className="m-2" />
|
||||
<h4 className="font-bold">Channels</h4>
|
||||
<ul className="channel-list">
|
||||
{props.channels.map((x) => (
|
||||
<SidebarItem
|
||||
channel={x}
|
||||
key={x.id}
|
||||
isActiveChannel={x.id === props.activeChannelId}
|
||||
user={user}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 bg-gray-800 h-screen">{props.children}</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarItem = ({ channel, isActiveChannel, user }) => (
|
||||
<>
|
||||
<li className="flex items-center justify-between">
|
||||
<Link href="/channels/[id]" as={`/channels/${channel.id}`}>
|
||||
<a className={isActiveChannel ? 'font-bold' : ''}>{channel.slug}</a>
|
||||
</Link>
|
||||
{channel.id !== 1 && (channel.created_by === user?.id || user?.appRole === 'admin') && (
|
||||
<button onClick={() => deleteChannel(channel.id)}>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
</>
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useContext } from 'react'
|
||||
import UserContext from '~/lib/UserContext'
|
||||
import { deleteMessage } from '~/lib/Store'
|
||||
import TrashIcon from '~/components/TrashIcon'
|
||||
|
||||
const Message = ({ message }) => {
|
||||
const { user } = useContext(UserContext)
|
||||
|
||||
return (
|
||||
<div className="py-1 flex items-center space-x-2">
|
||||
<div className="text-gray-100 w-4">
|
||||
{(user?.id === message.user_id || ['admin', 'moderator'].includes(user?.appRole)) && (
|
||||
<button onClick={() => deleteMessage(message.id)}>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-blue-700 font-bold">{message?.author?.username}</p>
|
||||
<p className="text-white">{message.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Message
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
const MessageInput = ({ onSubmit }) => {
|
||||
const [messageText, setMessageText] = useState('')
|
||||
|
||||
const submitOnEnter = (event) => {
|
||||
// Watch for enter key
|
||||
if (event.keyCode === 13) {
|
||||
onSubmit(messageText)
|
||||
setMessageText('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
type="text"
|
||||
placeholder="Send a message"
|
||||
value={messageText}
|
||||
onChange={(e) => setMessageText(e.target.value)}
|
||||
onKeyDown={(e) => submitOnEnter(e)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default MessageInput
|
||||
@@ -0,0 +1,25 @@
|
||||
const TrashIcon = (props) => {
|
||||
const { size = 16 } = props
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="feather feather-trash-2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
<line x1="10" y1="11" x2="10" y2="17"></line>
|
||||
<line x1="14" y1="11" x2="14" y2="17"></line>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default TrashIcon
|
||||
@@ -0,0 +1,224 @@
|
||||
--
|
||||
-- For use with https://github.com/supabase/supabase/tree/master/examples/slack-clone/nextjs-slack-clone
|
||||
--
|
||||
|
||||
-- Custom types
|
||||
create type public.app_permission as enum ('channels.delete', 'messages.delete');
|
||||
create type public.app_role as enum ('admin', 'moderator');
|
||||
create type public.user_status as enum ('ONLINE', 'OFFLINE');
|
||||
|
||||
-- USERS
|
||||
create table public.users (
|
||||
id uuid references auth.users not null primary key, -- UUID from auth.users
|
||||
username text,
|
||||
status user_status default 'OFFLINE'::public.user_status
|
||||
);
|
||||
comment on table public.users is 'Profile data for each user.';
|
||||
comment on column public.users.id is 'References the internal Supabase Auth user.';
|
||||
|
||||
-- CHANNELS
|
||||
create table public.channels (
|
||||
id bigint generated by default as identity primary key,
|
||||
inserted_at timestamp with time zone default timezone('utc'::text, now()) not null,
|
||||
slug text not null unique,
|
||||
created_by uuid references public.users not null
|
||||
);
|
||||
comment on table public.channels is 'Topics and groups.';
|
||||
|
||||
-- MESSAGES
|
||||
create table public.messages (
|
||||
id bigint generated by default as identity primary key,
|
||||
inserted_at timestamp with time zone default timezone('utc'::text, now()) not null,
|
||||
message text,
|
||||
user_id uuid references public.users not null,
|
||||
channel_id bigint references public.channels on delete cascade not null
|
||||
);
|
||||
comment on table public.messages is 'Individual messages sent by each user.';
|
||||
|
||||
-- USER ROLES
|
||||
create table public.user_roles (
|
||||
id bigint generated by default as identity primary key,
|
||||
user_id uuid references public.users on delete cascade not null,
|
||||
role app_role not null,
|
||||
unique (user_id, role)
|
||||
);
|
||||
comment on table public.user_roles is 'Application roles for each user.';
|
||||
|
||||
-- ROLE PERMISSIONS
|
||||
create table public.role_permissions (
|
||||
id bigint generated by default as identity primary key,
|
||||
role app_role not null,
|
||||
permission app_permission not null,
|
||||
unique (role, permission)
|
||||
);
|
||||
comment on table public.role_permissions is 'Application permissions for each role.';
|
||||
|
||||
-- authorize with role-based access control (RBAC)
|
||||
create function public.authorize(
|
||||
requested_permission app_permission
|
||||
)
|
||||
returns boolean as $$
|
||||
declare
|
||||
bind_permissions int;
|
||||
begin
|
||||
select count(*)
|
||||
from public.role_permissions
|
||||
where role_permissions.permission = authorize.requested_permission
|
||||
and role_permissions.role = (auth.jwt() ->> 'user_role')::public.app_role
|
||||
into bind_permissions;
|
||||
|
||||
return bind_permissions > 0;
|
||||
end;
|
||||
$$ language plpgsql security definer set search_path = public;
|
||||
|
||||
-- Secure the tables
|
||||
alter table public.users enable row level security;
|
||||
alter table public.channels enable row level security;
|
||||
alter table public.messages enable row level security;
|
||||
alter table public.user_roles enable row level security;
|
||||
alter table public.role_permissions enable row level security;
|
||||
create policy "Allow logged-in read access" on public.users for select using ( auth.role() = 'authenticated' );
|
||||
create policy "Allow individual insert access" on public.users for insert with check ( auth.uid() = id );
|
||||
create policy "Allow individual update access" on public.users for update using ( auth.uid() = id );
|
||||
create policy "Allow logged-in read access" on public.channels for select using ( auth.role() = 'authenticated' );
|
||||
create policy "Allow individual insert access" on public.channels for insert with check ( auth.uid() = created_by );
|
||||
create policy "Allow individual delete access" on public.channels for delete using ( auth.uid() = created_by );
|
||||
create policy "Allow authorized delete access" on public.channels for delete using ( authorize('channels.delete') );
|
||||
create policy "Allow logged-in read access" on public.messages for select using ( auth.role() = 'authenticated' );
|
||||
create policy "Allow individual insert access" on public.messages for insert with check ( auth.uid() = user_id );
|
||||
create policy "Allow individual update access" on public.messages for update using ( auth.uid() = user_id );
|
||||
create policy "Allow individual delete access" on public.messages for delete using ( auth.uid() = user_id );
|
||||
create policy "Allow authorized delete access" on public.messages for delete using ( authorize('messages.delete') );
|
||||
create policy "Allow individual read access" on public.user_roles for select using ( auth.uid() = user_id );
|
||||
|
||||
-- Send "previous data" on change
|
||||
alter table public.users replica identity full;
|
||||
alter table public.channels replica identity full;
|
||||
alter table public.messages replica identity full;
|
||||
|
||||
-- inserts a row into public.users and assigns roles
|
||||
create function public.handle_new_user()
|
||||
returns trigger as $$
|
||||
declare is_admin boolean;
|
||||
begin
|
||||
insert into public.users (id, username)
|
||||
values (new.id, new.email);
|
||||
|
||||
select count(*) = 1 from auth.users into is_admin;
|
||||
|
||||
if position('+supaadmin@' in new.email) > 0 then
|
||||
insert into public.user_roles (user_id, role) values (new.id, 'admin');
|
||||
elsif position('+supamod@' in new.email) > 0 then
|
||||
insert into public.user_roles (user_id, role) values (new.id, 'moderator');
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql security definer set search_path = auth, public;
|
||||
|
||||
-- trigger the function every time a user is created
|
||||
create trigger on_auth_user_created
|
||||
after insert on auth.users
|
||||
for each row execute procedure public.handle_new_user();
|
||||
|
||||
/**
|
||||
* REALTIME SUBSCRIPTIONS
|
||||
* Only allow realtime listening on public tables.
|
||||
*/
|
||||
|
||||
begin;
|
||||
-- remove the realtime publication
|
||||
drop publication if exists supabase_realtime;
|
||||
|
||||
-- re-create the publication but don't enable it for any tables
|
||||
create publication supabase_realtime;
|
||||
commit;
|
||||
|
||||
-- add tables to the publication
|
||||
alter publication supabase_realtime add table public.channels;
|
||||
alter publication supabase_realtime add table public.messages;
|
||||
alter publication supabase_realtime add table public.users;
|
||||
|
||||
/**
|
||||
* AUTH HOOKS
|
||||
* Create an auth hook to add a custom claim to the access token jwt.
|
||||
*/
|
||||
|
||||
-- Create the auth hook function
|
||||
-- https://supabase.com/docs/guides/auth/auth-hooks#hook-custom-access-token
|
||||
create or replace function public.custom_access_token_hook(event jsonb)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
stable
|
||||
as $$
|
||||
declare
|
||||
claims jsonb;
|
||||
user_role public.app_role;
|
||||
begin
|
||||
-- Check if the user is marked as admin in the profiles table
|
||||
select role into user_role from public.user_roles where user_id = (event->>'user_id')::uuid;
|
||||
|
||||
claims := event->'claims';
|
||||
|
||||
if user_role is not null then
|
||||
-- Set the claim
|
||||
claims := jsonb_set(claims, '{user_role}', to_jsonb(user_role));
|
||||
else
|
||||
claims := jsonb_set(claims, '{user_role}', 'null');
|
||||
end if;
|
||||
|
||||
-- Update the 'claims' object in the original event
|
||||
event := jsonb_set(event, '{claims}', claims);
|
||||
|
||||
-- Return the modified or original event
|
||||
return event;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant usage on schema public to supabase_auth_admin;
|
||||
|
||||
grant execute
|
||||
on function public.custom_access_token_hook
|
||||
to supabase_auth_admin;
|
||||
|
||||
revoke execute
|
||||
on function public.custom_access_token_hook
|
||||
from authenticated, anon, public;
|
||||
|
||||
grant all
|
||||
on table public.user_roles
|
||||
to supabase_auth_admin;
|
||||
|
||||
revoke all
|
||||
on table public.user_roles
|
||||
from authenticated, anon, public;
|
||||
|
||||
create policy "Allow auth admin to read user roles" ON public.user_roles
|
||||
as permissive for select
|
||||
to supabase_auth_admin
|
||||
using (true)
|
||||
|
||||
|
||||
/**
|
||||
* HELPER FUNCTIONS
|
||||
* Create test user helper method.
|
||||
*/
|
||||
create or replace function public.create_user(
|
||||
email text
|
||||
) returns uuid
|
||||
security definer
|
||||
set search_path = auth
|
||||
as $$
|
||||
declare
|
||||
user_id uuid;
|
||||
begin
|
||||
user_id := extensions.uuid_generate_v4();
|
||||
|
||||
insert into auth.users (id, email)
|
||||
values (user_id, email)
|
||||
returning id into user_id;
|
||||
|
||||
return user_id;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
export const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
)
|
||||
|
||||
/**
|
||||
* @param {number} channelId the currently selected Channel
|
||||
*/
|
||||
export const useStore = (props) => {
|
||||
const [channels, setChannels] = useState([])
|
||||
const [messages, setMessages] = useState([])
|
||||
const [users] = useState(new Map())
|
||||
const [newMessage, handleNewMessage] = useState(null)
|
||||
const [newChannel, handleNewChannel] = useState(null)
|
||||
const [newOrUpdatedUser, handleNewOrUpdatedUser] = useState(null)
|
||||
const [deletedChannel, handleDeletedChannel] = useState(null)
|
||||
const [deletedMessage, handleDeletedMessage] = useState(null)
|
||||
|
||||
// Load initial data and set up listeners
|
||||
useEffect(() => {
|
||||
// Get Channels
|
||||
fetchChannels(setChannels)
|
||||
// Listen for new and deleted messages
|
||||
const messageListener = supabase
|
||||
.channel('public:messages')
|
||||
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages' }, (payload) =>
|
||||
handleNewMessage(payload.new)
|
||||
)
|
||||
.on('postgres_changes', { event: 'DELETE', schema: 'public', table: 'messages' }, (payload) =>
|
||||
handleDeletedMessage(payload.old)
|
||||
)
|
||||
.subscribe()
|
||||
// Listen for changes to our users
|
||||
const userListener = supabase
|
||||
.channel('public:users')
|
||||
.on('postgres_changes', { event: '*', schema: 'public', table: 'users' }, (payload) =>
|
||||
handleNewOrUpdatedUser(payload.new)
|
||||
)
|
||||
.subscribe()
|
||||
// Listen for new and deleted channels
|
||||
const channelListener = supabase
|
||||
.channel('public:channels')
|
||||
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'channels' }, (payload) =>
|
||||
handleNewChannel(payload.new)
|
||||
)
|
||||
.on('postgres_changes', { event: 'DELETE', schema: 'public', table: 'channels' }, (payload) =>
|
||||
handleDeletedChannel(payload.old)
|
||||
)
|
||||
.subscribe()
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
supabase.removeChannel(supabase.channel(messageListener))
|
||||
supabase.removeChannel(supabase.channel(userListener))
|
||||
supabase.removeChannel(supabase.channel(channelListener))
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Update when the route changes
|
||||
useEffect(() => {
|
||||
if (props?.channelId > 0) {
|
||||
fetchMessages(props.channelId, (messages) => {
|
||||
messages.forEach((x) => users.set(x.user_id, x.author))
|
||||
setMessages(messages)
|
||||
})
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.channelId])
|
||||
|
||||
// New message received from Postgres
|
||||
useEffect(() => {
|
||||
if (newMessage && newMessage.channel_id === Number(props.channelId)) {
|
||||
const handleAsync = async () => {
|
||||
let authorId = newMessage.user_id
|
||||
if (!users.get(authorId)) await fetchUser(authorId, (user) => handleNewOrUpdatedUser(user))
|
||||
setMessages(messages.concat(newMessage))
|
||||
}
|
||||
handleAsync()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [newMessage])
|
||||
|
||||
// Deleted message received from postgres
|
||||
useEffect(() => {
|
||||
if (deletedMessage) setMessages(messages.filter((message) => message.id !== deletedMessage.id))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [deletedMessage])
|
||||
|
||||
// New channel received from Postgres
|
||||
useEffect(() => {
|
||||
if (newChannel) setChannels(channels.concat(newChannel))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [newChannel])
|
||||
|
||||
// Deleted channel received from postgres
|
||||
useEffect(() => {
|
||||
if (deletedChannel) setChannels(channels.filter((channel) => channel.id !== deletedChannel.id))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [deletedChannel])
|
||||
|
||||
// New or updated user received from Postgres
|
||||
useEffect(() => {
|
||||
if (newOrUpdatedUser) users.set(newOrUpdatedUser.id, newOrUpdatedUser)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [newOrUpdatedUser])
|
||||
|
||||
return {
|
||||
// We can export computed values here to map the authors to each message
|
||||
messages: messages.map((x) => ({ ...x, author: users.get(x.user_id) })),
|
||||
channels: channels !== null ? channels.sort((a, b) => a.slug.localeCompare(b.slug)) : [],
|
||||
users,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all channels
|
||||
* @param {function} setState Optionally pass in a hook or callback to set the state
|
||||
*/
|
||||
export const fetchChannels = async (setState) => {
|
||||
try {
|
||||
let { data } = await supabase.from('channels').select('*')
|
||||
if (setState) setState(data)
|
||||
return data
|
||||
} catch (error) {
|
||||
console.log('error', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single user
|
||||
* @param {number} userId
|
||||
* @param {function} setState Optionally pass in a hook or callback to set the state
|
||||
*/
|
||||
export const fetchUser = async (userId, setState) => {
|
||||
try {
|
||||
let { data } = await supabase.from('users').select(`*`).eq('id', userId)
|
||||
let user = data[0]
|
||||
if (setState) setState(user)
|
||||
return user
|
||||
} catch (error) {
|
||||
console.log('error', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all messages and their authors
|
||||
* @param {number} channelId
|
||||
* @param {function} setState Optionally pass in a hook or callback to set the state
|
||||
*/
|
||||
export const fetchMessages = async (channelId, setState) => {
|
||||
try {
|
||||
let { data } = await supabase
|
||||
.from('messages')
|
||||
.select(`*, author:user_id(*)`)
|
||||
.eq('channel_id', channelId)
|
||||
.order('inserted_at', { ascending: true })
|
||||
if (setState) setState(data)
|
||||
return data
|
||||
} catch (error) {
|
||||
console.log('error', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a new channel into the DB
|
||||
* @param {string} slug The channel name
|
||||
* @param {number} user_id The channel creator
|
||||
*/
|
||||
export const addChannel = async (slug, user_id) => {
|
||||
try {
|
||||
let { data } = await supabase
|
||||
.from('channels')
|
||||
.insert([{ slug, created_by: user_id }])
|
||||
.select()
|
||||
return data
|
||||
} catch (error) {
|
||||
console.log('error', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a new message into the DB
|
||||
* @param {string} message The message text
|
||||
* @param {number} channel_id
|
||||
* @param {number} user_id The author
|
||||
*/
|
||||
export const addMessage = async (message, channel_id, user_id) => {
|
||||
try {
|
||||
let { data } = await supabase
|
||||
.from('messages')
|
||||
.insert([{ message, channel_id, user_id }])
|
||||
.select()
|
||||
return data
|
||||
} catch (error) {
|
||||
console.log('error', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a channel from the DB
|
||||
* @param {number} channel_id
|
||||
*/
|
||||
export const deleteChannel = async (channel_id) => {
|
||||
try {
|
||||
let { data } = await supabase.from('channels').delete().match({ id: channel_id })
|
||||
return data
|
||||
} catch (error) {
|
||||
console.log('error', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a message from the DB
|
||||
* @param {number} message_id
|
||||
*/
|
||||
export const deleteMessage = async (message_id) => {
|
||||
try {
|
||||
let { data } = await supabase.from('messages').delete().match({ id: message_id })
|
||||
return data
|
||||
} catch (error) {
|
||||
console.log('error', error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
const UserContext = createContext()
|
||||
|
||||
export default UserContext
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "supabase-slack-clone-basic",
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"export": "next export",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/auth-ui-react": "^0.4.7",
|
||||
"@supabase/auth-ui-shared": "^0.1.8",
|
||||
"@supabase/supabase-js": "^2.47.3",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"next": "latest",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dotenvx/dotenvx": "^1.28.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"postcss": "^8.4.24",
|
||||
"supabase": "^2.7.2",
|
||||
"tailwindcss": "^3.3.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import '~/styles/style.scss'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import UserContext from 'lib/UserContext'
|
||||
import { supabase } from 'lib/Store'
|
||||
import { jwtDecode } from 'jwt-decode'
|
||||
|
||||
export default function SupabaseSlackClone({ Component, pageProps }) {
|
||||
const [userLoaded, setUserLoaded] = useState(false)
|
||||
const [user, setUser] = useState(null)
|
||||
const [, setSession] = useState(null)
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
function saveSession(
|
||||
/** @type {Awaited<ReturnType<typeof supabase.auth.getSession>>['data']['session']} */
|
||||
session
|
||||
) {
|
||||
setSession(session)
|
||||
const currentUser = session?.user
|
||||
if (session) {
|
||||
const jwt = jwtDecode(session.access_token)
|
||||
currentUser.appRole = jwt.user_role
|
||||
}
|
||||
setUser(currentUser ?? null)
|
||||
setUserLoaded(!!currentUser)
|
||||
if (currentUser) {
|
||||
router.push('/channels/[id]', '/channels/1')
|
||||
}
|
||||
}
|
||||
|
||||
supabase.auth.getSession().then(({ data: { session } }) => saveSession(session))
|
||||
|
||||
const {
|
||||
data: { subscription: authListener },
|
||||
} = supabase.auth.onAuthStateChange(async (_event, session) => {
|
||||
saveSession(session)
|
||||
})
|
||||
|
||||
return () => {
|
||||
authListener.unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const signOut = async () => {
|
||||
const { error } = await supabase.auth.signOut()
|
||||
if (!error) {
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<UserContext.Provider
|
||||
value={{
|
||||
userLoaded,
|
||||
user,
|
||||
signOut,
|
||||
}}
|
||||
>
|
||||
<Component {...pageProps} />
|
||||
</UserContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Layout from '~/components/Layout'
|
||||
import Message from '~/components/Message'
|
||||
import MessageInput from '~/components/MessageInput'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useStore, addMessage } from '~/lib/Store'
|
||||
import { useContext, useEffect, useRef } from 'react'
|
||||
import UserContext from '~/lib/UserContext'
|
||||
|
||||
const ChannelsPage = (props) => {
|
||||
const router = useRouter()
|
||||
const { user, authLoaded, signOut } = useContext(UserContext)
|
||||
const messagesEndRef = useRef(null)
|
||||
|
||||
// Else load up the page
|
||||
const { id: channelId } = router.query
|
||||
const { messages, channels } = useStore({ channelId })
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current.scrollIntoView({
|
||||
block: 'start',
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}, [messages])
|
||||
|
||||
// redirect to public channel when current channel is deleted
|
||||
useEffect(() => {
|
||||
if (!channels.some((channel) => channel.id === Number(channelId))) {
|
||||
router.push('/channels/1')
|
||||
}
|
||||
}, [channels, channelId])
|
||||
|
||||
// Render the channels and messages
|
||||
return (
|
||||
<Layout channels={channels} activeChannelId={channelId}>
|
||||
<div className="relative h-screen">
|
||||
<div className="Messages h-full pb-16">
|
||||
<div className="p-2 overflow-y-auto">
|
||||
{messages.map((x) => (
|
||||
<Message key={x.id} message={x} />
|
||||
))}
|
||||
<div ref={messagesEndRef} style={{ height: 0 }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 absolute bottom-0 left-0 w-full">
|
||||
<MessageInput onSubmit={async (text) => addMessage(text, channelId, user.id)} />
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChannelsPage
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { supabase } from 'lib/Store'
|
||||
import { Auth } from '@supabase/auth-ui-react'
|
||||
import { ThemeSupa } from '@supabase/auth-ui-shared'
|
||||
|
||||
const Home = () => {
|
||||
const [session, setSession] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
setSession(session)
|
||||
})
|
||||
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
setSession(session)
|
||||
})
|
||||
|
||||
return () => subscription.unsubscribe()
|
||||
}, [])
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="w-full h-full flex justify-center items-center p-4 bg-gray-300">
|
||||
<div className="w-full sm:w-1/2 xl:w-1/3">
|
||||
<div className="border-teal p-8 border-t-12 bg-white mb-6 rounded-lg shadow-lg bg-white">
|
||||
<Auth
|
||||
supabaseClient={supabase}
|
||||
appearance={{ theme: ThemeSupa }}
|
||||
providers={['github']}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return <div>Logged in!</div>
|
||||
}
|
||||
}
|
||||
|
||||
export default Home
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,28 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html,
|
||||
body,
|
||||
#__next,
|
||||
.main {
|
||||
max-height: 100vh;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.channel-list {
|
||||
li a:before {
|
||||
content: '# ';
|
||||
opacity: 0.5;
|
||||
}
|
||||
li a:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
.Messages {
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Example .env used as default values for all environments
|
||||
# Get these from your API settings: https://supabase.com/dashboard/project/_/settings/api
|
||||
# URL of supabase api will be passed to next frontend build for app deployment
|
||||
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
|
||||
# The frontend site url, will be used by Supabase Auth services to properly configure auth redirects
|
||||
SUPABASE_AUTH_SITE_URL=http://localhost:3000
|
||||
SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS=http://localhost:3000/**,http://localhost:3000/
|
||||
# Credentials for github connection
|
||||
SUPABASE_AUTH_EXTERNAL_GITHUB_CLIENT_ID=Ov23liopb2cDTY8Nohec
|
||||
SUPABASE_AUTH_EXTERNAL_GITHUB_SECRET=encrypted:BJ9Mw7XepHbQ+KtS4bQDyIgGqnLRy3tnMyHHZ2/tO3PgQOJPpt8u3MfW3EDDRCwQgts37F4B59Fvhq15E+rLV0bxOQ8t7eVohLFj82DUjQEmb23oO3RaBaaWKEoDisYhggfo06BqAP/y+nyuTEs=
|
||||
@@ -0,0 +1,11 @@
|
||||
# Example .env for preview environments
|
||||
# Get these from your API settings: https://supabase.com/dashboard/project/_/settings/api
|
||||
# URL of supabase api will be passed to next frontend build for app deployment
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://<preview-branch-ref>.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=<preview-branch-apikey>
|
||||
# The frontend site url, will be used by Supabase Auth services to properly configure auth redirects
|
||||
SUPABASE_AUTH_SITE_URL=https://<preview-app-url>.vercel.app/
|
||||
SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS=https://<preview-app-url>.vercel.app/**
|
||||
# Credentials for github connection
|
||||
SUPABASE_AUTH_EXTERNAL_GITHUB_CLIENT_ID=Ov23liopb2cDTY8Nohec
|
||||
SUPABASE_AUTH_EXTERNAL_GITHUB_SECRET="encrypted:BCsRXT2v9U/5qsCIfJJeZ14FgdoyTIgwy+RNCezWrnjj4C/RecQJFZIP8Ec4yw8bLosHh8bShLXlXfl0iNxWkqqZp+FVeMvt4WMiUfsFJge0TWmAds35dSg/VLCUrEZOYUGCpT8PR6tb7ScA2r8="
|
||||
@@ -0,0 +1,11 @@
|
||||
# Example .env for production environment
|
||||
# Get these from your API settings: https://supabase.com/dashboard/project/_/settings/api
|
||||
# URL of supabase api will be passed to next frontend build for app deployment
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://<your-project-ref>.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=<your-project-apikey>
|
||||
# The frontend site url, will be used by Supabase Auth services to properly configure auth redirects
|
||||
SUPABASE_AUTH_SITE_URL=https://<your-app-url>.vercel.app/
|
||||
SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS=https://<your-app-url>.vercel.app/**
|
||||
# Credentials for github connection
|
||||
SUPABASE_AUTH_EXTERNAL_GITHUB_CLIENT_ID=Ov23liopb2cDTY8Nohec
|
||||
SUPABASE_AUTH_EXTERNAL_GITHUB_SECRET=encrypted:BKPNXvhfgruoT3DWEfDE5Ec3E3G3bpGkcwmQmUw/yQEK/gZQOaFbaYfvIm67RWCeHukSwJC2ylm3HuOOp1FG+B35pHMQFC9rhItizJaEt5ph0IJxARUgysoXAEzk53v1S1mSXY/lyvQAh7gSQBU=
|
||||
@@ -0,0 +1,8 @@
|
||||
# Supabase
|
||||
.branches
|
||||
.temp
|
||||
|
||||
# dotenvx
|
||||
.env.keys
|
||||
.env.local
|
||||
.env.*.local
|
||||
@@ -0,0 +1,93 @@
|
||||
# A string used to distinguish different Supabase projects on the same host. Defaults to the
|
||||
# working directory name when running `supabase init`.
|
||||
project_id = "slack-clone"
|
||||
|
||||
[api]
|
||||
enabled = true
|
||||
# Port to use for the API URL.
|
||||
port = 54321
|
||||
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
|
||||
# endpoints. public and storage are always included.
|
||||
schemas = ["public"]
|
||||
# Extra schemas to add to the search_path of every request. public is always included.
|
||||
extra_search_path = ["public", "extensions"]
|
||||
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
|
||||
# for accidental or malicious requests.
|
||||
max_rows = 1000
|
||||
|
||||
[db]
|
||||
# Port to use for the local database URL.
|
||||
port = 54322
|
||||
# Port used by db diff command to initialize the shadow database.
|
||||
shadow_port = 54320
|
||||
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
|
||||
# server_version;` on the remote database to check.
|
||||
major_version = 15
|
||||
|
||||
[db.seed]
|
||||
# If enabled, seeds the database after migrations during a db reset.
|
||||
enabled = false
|
||||
# Specifies an ordered list of seed files to load during db reset.
|
||||
# Supports glob patterns relative to supabase directory. For example:
|
||||
# sql_paths = ['./seeds/*.sql', '../project-src/seeds/*-load-testing.sql']
|
||||
|
||||
[realtime]
|
||||
enabled = true
|
||||
|
||||
[auth]
|
||||
enabled = true
|
||||
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
|
||||
# in emails.
|
||||
site_url = "env(SUPABASE_AUTH_SITE_URL)"
|
||||
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
|
||||
additional_redirect_urls = [
|
||||
# Will be localhost:3000 in development or the URL of your deployed app in production.
|
||||
"env(SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS)",
|
||||
]
|
||||
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
|
||||
jwt_expiry = 3600
|
||||
# If disabled, the refresh token will never expire.
|
||||
enable_refresh_token_rotation = true
|
||||
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
|
||||
# Requires enable_refresh_token_rotation = true.
|
||||
refresh_token_reuse_interval = 10
|
||||
# Allow/disallow new user signups to your project.
|
||||
enable_signup = true
|
||||
|
||||
[auth.email]
|
||||
# Allow/disallow new user signups via email to your project.
|
||||
enable_signup = true
|
||||
# If enabled, a user will be required to confirm any email change on both the old, and new email
|
||||
# addresses. If disabled, only the new email is required to confirm.
|
||||
double_confirm_changes = true
|
||||
# If enabled, users need to confirm their email address before signing in.
|
||||
enable_confirmations = true
|
||||
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
|
||||
secure_password_change = false
|
||||
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
|
||||
max_frequency = "1m0s"
|
||||
# Number of characters used in the email OTP.
|
||||
otp_length = 6
|
||||
# Number of seconds before the email OTP expires (defaults to 1 hour).
|
||||
otp_expiry = 86400
|
||||
|
||||
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
|
||||
# are monitored, and you can view the emails that would have been sent from the web interface.
|
||||
[inbucket]
|
||||
enabled = true
|
||||
# Port to use for the email testing server web interface.
|
||||
port = 54324
|
||||
|
||||
# Enable auth hooks
|
||||
# https://supabase.com/docs/guides/auth/auth-hooks#local-development
|
||||
[auth.hook.custom_access_token]
|
||||
enabled = true
|
||||
uri = "pg-functions://postgres/public/custom_access_token_hook"
|
||||
|
||||
[auth.external.github]
|
||||
enabled = true
|
||||
client_id = "env(SUPABASE_AUTH_EXTERNAL_GITHUB_CLIENT_ID)"
|
||||
secret = "env(SUPABASE_AUTH_EXTERNAL_GITHUB_SECRET)"
|
||||
|
||||
[analytics]
|
||||
enabled = false
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
--
|
||||
-- For use with https://github.com/supabase/supabase/tree/master/examples/slack-clone/nextjs-slack-clone
|
||||
--
|
||||
|
||||
-- Custom types
|
||||
create type public.app_permission as enum ('channels.delete', 'messages.delete');
|
||||
create type public.app_role as enum ('admin', 'moderator');
|
||||
create type public.user_status as enum ('ONLINE', 'OFFLINE');
|
||||
|
||||
-- USERS
|
||||
create table public.users (
|
||||
id uuid references auth.users not null primary key, -- UUID from auth.users
|
||||
username text,
|
||||
status user_status default 'OFFLINE'::public.user_status
|
||||
);
|
||||
comment on table public.users is 'Profile data for each user.';
|
||||
comment on column public.users.id is 'References the internal Supabase Auth user.';
|
||||
|
||||
-- CHANNELS
|
||||
create table public.channels (
|
||||
id bigint generated by default as identity primary key,
|
||||
inserted_at timestamp with time zone default timezone('utc'::text, now()) not null,
|
||||
slug text not null unique,
|
||||
created_by uuid references public.users not null
|
||||
);
|
||||
comment on table public.channels is 'Topics and groups.';
|
||||
|
||||
-- MESSAGES
|
||||
create table public.messages (
|
||||
id bigint generated by default as identity primary key,
|
||||
inserted_at timestamp with time zone default timezone('utc'::text, now()) not null,
|
||||
message text,
|
||||
user_id uuid references public.users not null,
|
||||
channel_id bigint references public.channels on delete cascade not null
|
||||
);
|
||||
comment on table public.messages is 'Individual messages sent by each user.';
|
||||
|
||||
-- USER ROLES
|
||||
create table public.user_roles (
|
||||
id bigint generated by default as identity primary key,
|
||||
user_id uuid references public.users on delete cascade not null,
|
||||
role app_role not null,
|
||||
unique (user_id, role)
|
||||
);
|
||||
comment on table public.user_roles is 'Application roles for each user.';
|
||||
|
||||
-- ROLE PERMISSIONS
|
||||
create table public.role_permissions (
|
||||
id bigint generated by default as identity primary key,
|
||||
role app_role not null,
|
||||
permission app_permission not null,
|
||||
unique (role, permission)
|
||||
);
|
||||
comment on table public.role_permissions is 'Application permissions for each role.';
|
||||
|
||||
-- authorize with role-based access control (RBAC)
|
||||
create function public.authorize(
|
||||
requested_permission app_permission
|
||||
)
|
||||
returns boolean as $$
|
||||
declare
|
||||
bind_permissions int;
|
||||
begin
|
||||
select count(*)
|
||||
from public.role_permissions
|
||||
where role_permissions.permission = authorize.requested_permission
|
||||
and role_permissions.role = (auth.jwt() ->> 'user_role')::public.app_role
|
||||
into bind_permissions;
|
||||
|
||||
return bind_permissions > 0;
|
||||
end;
|
||||
$$ language plpgsql security definer set search_path = public;
|
||||
|
||||
-- Secure the tables
|
||||
alter table public.users enable row level security;
|
||||
alter table public.channels enable row level security;
|
||||
alter table public.messages enable row level security;
|
||||
alter table public.user_roles enable row level security;
|
||||
alter table public.role_permissions enable row level security;
|
||||
create policy "Allow logged-in read access" on public.users for select using ( auth.role() = 'authenticated' );
|
||||
create policy "Allow individual insert access" on public.users for insert with check ( auth.uid() = id );
|
||||
create policy "Allow individual update access" on public.users for update using ( auth.uid() = id );
|
||||
create policy "Allow logged-in read access" on public.channels for select using ( auth.role() = 'authenticated' );
|
||||
create policy "Allow individual insert access" on public.channels for insert with check ( auth.uid() = created_by );
|
||||
create policy "Allow individual delete access" on public.channels for delete using ( auth.uid() = created_by );
|
||||
create policy "Allow authorized delete access" on public.channels for delete using ( authorize('channels.delete') );
|
||||
create policy "Allow logged-in read access" on public.messages for select using ( auth.role() = 'authenticated' );
|
||||
create policy "Allow individual insert access" on public.messages for insert with check ( auth.uid() = user_id );
|
||||
create policy "Allow individual update access" on public.messages for update using ( auth.uid() = user_id );
|
||||
create policy "Allow individual delete access" on public.messages for delete using ( auth.uid() = user_id );
|
||||
create policy "Allow authorized delete access" on public.messages for delete using ( authorize('messages.delete') );
|
||||
create policy "Allow individual read access" on public.user_roles for select using ( auth.uid() = user_id );
|
||||
|
||||
-- Send "previous data" on change
|
||||
alter table public.users replica identity full;
|
||||
alter table public.channels replica identity full;
|
||||
alter table public.messages replica identity full;
|
||||
|
||||
-- inserts a row into public.users and assigns roles
|
||||
create function public.handle_new_user()
|
||||
returns trigger as $$
|
||||
declare is_admin boolean;
|
||||
begin
|
||||
insert into public.users (id, username)
|
||||
values (new.id, new.email);
|
||||
|
||||
select count(*) = 1 from auth.users into is_admin;
|
||||
|
||||
if position('+supaadmin@' in new.email) > 0 then
|
||||
insert into public.user_roles (user_id, role) values (new.id, 'admin');
|
||||
elsif position('+supamod@' in new.email) > 0 then
|
||||
insert into public.user_roles (user_id, role) values (new.id, 'moderator');
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql security definer set search_path = auth, public;
|
||||
|
||||
-- trigger the function every time a user is created
|
||||
create trigger on_auth_user_created
|
||||
after insert on auth.users
|
||||
for each row execute procedure public.handle_new_user();
|
||||
|
||||
/**
|
||||
* REALTIME SUBSCRIPTIONS
|
||||
* Only allow realtime listening on public tables.
|
||||
*/
|
||||
|
||||
begin;
|
||||
-- remove the realtime publication
|
||||
drop publication if exists supabase_realtime;
|
||||
|
||||
-- re-create the publication but don't enable it for any tables
|
||||
create publication supabase_realtime;
|
||||
commit;
|
||||
|
||||
-- add tables to the publication
|
||||
alter publication supabase_realtime add table public.channels;
|
||||
alter publication supabase_realtime add table public.messages;
|
||||
alter publication supabase_realtime add table public.users;
|
||||
|
||||
/**
|
||||
* HELPER FUNCTIONS
|
||||
* Create test user helper method.
|
||||
*/
|
||||
create or replace function public.create_user(
|
||||
email text
|
||||
) returns uuid
|
||||
security definer
|
||||
set search_path = auth
|
||||
as $$
|
||||
declare
|
||||
user_id uuid;
|
||||
begin
|
||||
user_id := extensions.uuid_generate_v4();
|
||||
|
||||
insert into auth.users (id, email)
|
||||
values (user_id, email)
|
||||
returning id into user_id;
|
||||
|
||||
return user_id;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* AUTH HOOKS
|
||||
* Create an auth hook to add a custom claim to the access token jwt.
|
||||
*/
|
||||
|
||||
-- Create the auth hook function
|
||||
-- https://supabase.com/docs/guides/auth/auth-hooks#hook-custom-access-token
|
||||
create or replace function public.custom_access_token_hook(event jsonb)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
stable
|
||||
as $$
|
||||
declare
|
||||
claims jsonb;
|
||||
user_role public.app_role;
|
||||
begin
|
||||
-- Check if the user is marked as admin in the profiles table
|
||||
select role into user_role from public.user_roles where user_id = (event->>'user_id')::uuid;
|
||||
|
||||
claims := event->'claims';
|
||||
|
||||
if user_role is not null then
|
||||
-- Set the claim
|
||||
claims := jsonb_set(claims, '{user_role}', to_jsonb(user_role));
|
||||
else
|
||||
claims := jsonb_set(claims, '{user_role}', 'null');
|
||||
end if;
|
||||
|
||||
-- Update the 'claims' object in the original event
|
||||
event := jsonb_set(event, '{claims}', claims);
|
||||
|
||||
-- Return the modified or original event
|
||||
return event;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant usage on schema public to supabase_auth_admin;
|
||||
|
||||
grant execute
|
||||
on function public.custom_access_token_hook
|
||||
to supabase_auth_admin;
|
||||
|
||||
revoke execute
|
||||
on function public.custom_access_token_hook
|
||||
from authenticated, anon;
|
||||
|
||||
grant all
|
||||
on table public.user_roles
|
||||
to supabase_auth_admin;
|
||||
|
||||
revoke all
|
||||
on table public.user_roles
|
||||
from authenticated, anon;
|
||||
|
||||
create policy "Allow auth admin to read user roles" ON public.user_roles
|
||||
as permissive for select
|
||||
to supabase_auth_admin
|
||||
using (true)
|
||||
@@ -0,0 +1,12 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user