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:
authored and GitHub committed 2025-01-17 14:33:15 +08:00
1 parent 30a86fe0e8
commit 7ce0038156
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
@@ -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;
@@ -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: [],
}