This commit is contained in:
Mattias Wiberg 2025-06-02 21:48:40 +02:00
commit 5aa9b32c03
27 changed files with 4142 additions and 0 deletions

26
.dockerignore Normal file
View file

@ -0,0 +1,26 @@
# Dependencies
node_modules
npm-debug.log
yarn-debug.log
yarn-error.log
# Next.js build output
.next
out
# Git
.git
.gitignore
# Environment variables
.env
.env.local
.env.development
.env.test
.env.production
# Misc
README.md
.DS_Store
*.pem
.vscode

30
.gitignore vendored Normal file
View file

@ -0,0 +1,30 @@
# 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
.env*
!.env.example
!.env.production.example
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Supabase
.supabase

63
Dockerfile Normal file
View file

@ -0,0 +1,63 @@
# Base on official Node.js Alpine image
FROM node:18-alpine AS builder
# Set working directory
WORKDIR /app
# Copy package.json and package-lock.json before other files
# Utilize Docker cache to save re-installing dependencies if unchanged
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy all files
COPY . .
# Define build arguments with defaults
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
# Set environment variables from build arguments
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
# Build app
RUN npm run build
# Production image, copy all the files and run next
FROM node:18-alpine AS runner
WORKDIR /app
# Set to production environment
ENV NODE_ENV=production
# Add user so we don't run as root
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
# Copy built app
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
# Switch to non-root user
USER nextjs
# Expose port
EXPOSE 3000
# Start the app
CMD ["npm", "start"]
# Note: When running the container, override environment variables with:
# docker run -p 3000:3000 -e NEXT_PUBLIC_SUPABASE_URL=your_url -e NEXT_PUBLIC_SUPABASE_ANON_KEY=your_key nextjs-slack-clone
# Note: These lines correctly declare and set up build arguments.
# To load values for these arguments from a file, you would use
# external mechanisms (like a shell script parsing an .env file and passing
# --build-arg flags, or Docker Compose with its .env file handling)
# when invoking `docker build`. The Dockerfile itself does not need changes
# for this purpose.

117
README.md Normal file
View file

@ -0,0 +1,117 @@
# Realtime chat example using Supabase
This is a full-stack Slack clone example using:
- Frontend:
- [Next.js](https://github.com/vercel/next.js) - a React framework for production.
- [Supabase.js](https://supabase.com/docs/library/getting-started) for user management and realtime data syncing.
- Backend:
- [supabase.com/dashboard](https://supabase.com/dashboard/): hosted Postgres database with restful API for usage with Supabase.js.
## Demo
- CodeSandbox: https://codesandbox.io/s/github/supabase/supabase/tree/master/examples/nextjs-slack-clone
![Demo animation gif](./public/slack-clone-demo.gif)
## Deploy with Vercel
The Vercel deployment will guide you through creating a Supabase account and project. After installation of the Supabase integration, all relevant environment variables will be set up so that the project is usable immediately after deployment 🚀
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fsupabase%2Fsupabase%2Ftree%2Fmaster%2Fexamples%2Fslack-clone%2Fnextjs-slack-clone&project-name=supabase-nextjs-slack-clone&repository-name=supabase-nextjs-slack-clone&integration-ids=oac_VqOgBHqhEoFTPzGkPd7L0iH6&external-id=https%3A%2F%2Fgithub.com%2Fsupabase%2Fsupabase%2Ftree%2Fmaster%2Fexamples%2Fslack-clone%2Fnextjs-slack-clone)
## Build from scratch
### 1. Create new project
Sign up to Supabase - [https://supabase.com/dashboard](https://supabase.com/dashboard) and create a new project. Wait for your database to start.
### 2. Run "Slack Clone" Quickstart
Once your database has started, run the "Slack Clone" quickstart.
![Slack Clone Quick Start](https://user-images.githubusercontent.com/1811651/101558751-73fecc80-3974-11eb-80be-423fa2789877.png)
### 3. Get the URL and Key
Go to the Project Settings (the cog icon), open the API tab, and find your API URL and `anon` key. You'll need these in the next step.
The `anon` key is your client-side API key. It allows "anonymous access" to your database, until the user has logged in. Once they have logged in, the keys will switch to the user's own login token. This enables row level security for your data. Read more about this [below](#postgres-row-level-security).
![image](https://user-images.githubusercontent.com/10214025/88916245-528c2680-d298-11ea-8a71-708f93e1ce4f.png)
**_NOTE_**: The `service_role` key has full access to your data, bypassing any security policies. These keys have to be kept secret and are meant to be used in server environments and never on a client or browser.
## Supabase details
### Using a Remote Supabase Project
1. Create or select a project on [Supabase Dashboard](https://supabase.com/dashboard).
2. Copy and fill the dotenv template `cp .env.production.example .env.production`
3. Link the local project and merge the local configuration with the remote one:
```bash
SUPABASE_ENV=production npx supabase@latest link --project-ref <your-project-ref>
```
3. Sync the configuration:
```bash
SUPABASE_ENV=production npx supabase@latest config push
```
4. Sync the database schema:
```bash
SUPABASE_ENV=production npx supabase@latest db push
```
## Vercel Preview with Branching
Supabase integrates seamlessly with Vercel's preview branches, giving each branch a dedicated Supabase project. This setup allows testing database migrations or service configurations safely before applying them to production.
### Steps
1. Ensure the Vercel project is linked to a Git repository.
2. Configure the "Preview" environment variables in Vercel:
- `NEXT_PUBLIC_SUPABASE_URL`
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
3. Create a new branch, make changes (e.g., update `max_frequency`), and push the branch to Git.
- Open a pull request to trigger Vercel + Supabase integration.
- Upon successful deployment, the preview environment reflects the changes.
![Preview Checks](https://github.com/user-attachments/assets/db688cc2-60fd-4463-bbed-e8ecc11b1a39)
---
### Role-based access control (RBAC)
Use [plus addressing](https://en.wikipedia.org/wiki/Email_address#Subaddressing) to sign up users with the `admin` & `moderator` roles. Email addresses including `+supaadmin@` will be assigned the `admin` role, and email addresses including `+supamod@` will be assigned the `moderator` role. For example:
```
// admin user
email+supaadmin@example.com
// moderator user
email+supamod@example.com
```
Users with the `moderator` role can delete all messages. Users with the `admin` role can delete all messages and channels (note: it's not recommended to delete the `public` channel).
### Postgres Row level security
This project uses very high-level Authorization using Postgres' Row Level Security.
When you start a Postgres database on Supabase, we populate it with an `auth` schema, and some helper functions.
When a user logs in, they are issued a JWT with the role `authenticated` and their UUID.
We can use these details to provide fine-grained control over what each user can and cannot do.
- For the full schema refer to [full-schema.sql](./full-schema.sql).
- For documentation on Role-based Access Control, refer to the [docs](https://supabase.com/docs/guides/auth/custom-claims-and-role-based-access-control-rbac).
## Authors
- [Supabase](https://supabase.com)
Supabase is open source, we'd love for you to follow along and get involved at https://github.com/supabase/supabase

88
components/Layout.js Normal file
View file

@ -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>
</>
)

26
components/Message.js Normal file
View file

@ -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

View file

@ -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

25
components/TrashIcon.js Normal file
View file

@ -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

224
full-schema.sql Normal file
View file

@ -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;

8
jsconfig.json Normal file
View file

@ -0,0 +1,8 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~/*": ["./*"]
}
}
}

225
lib/Store.js Normal file
View file

@ -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)
}
}

5
lib/UserContext.js Normal file
View file

@ -0,0 +1,5 @@
import { createContext } from 'react'
const UserContext = createContext()
export default UserContext

6
next.config.js Normal file
View file

@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
}
module.exports = nextConfig

2670
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

23
package.json Normal file
View file

@ -0,0 +1,23 @@
{
"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/supabase-js": "^2.39.6",
"jwt-decode": "^4.0.0",
"next": "latest",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"autoprefixer": "^10.4.14",
"postcss": "^8.4.24",
"tailwindcss": "^3.3.2"
}
}

64
pages/_app.js Normal file
View file

@ -0,0 +1,64 @@
import '~/styles/style.scss'
import React, { 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 [session, 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) => {
console.log(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>
)
}

52
pages/channels/[id].js Normal file
View file

@ -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

79
pages/index.js Normal file
View file

@ -0,0 +1,79 @@
import { useState } from 'react'
import { supabase } from 'lib/Store'
const Home = () => {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const handleLogin = async (type, username, password) => {
try {
const { error, data: { user } } =
type === 'LOGIN'
? await supabase.auth.signInWithPassword({ email: username, password })
: await supabase.auth.signUp({ email: username, password })
// If the user doesn't exist here and an error hasn't been raised yet,
// that must mean that a confirmation email has been sent.
// NOTE: Confirming your email address is required by default.
if (error) {
alert('Error with auth: ' + error.message)
} else if (!user) alert('Signup successful, confirmation mail should be sent soon!')
} catch (error) {
console.log('error', error)
alert(error.error_description || error)
}
}
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">
<div className="mb-4">
<label className="font-bold text-grey-darker block mb-2">Email</label>
<input
type="text"
className="block appearance-none w-full bg-white border border-grey-light hover:border-grey px-2 py-2 rounded shadow"
placeholder="Your Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="mb-4">
<label className="font-bold text-grey-darker block mb-2">Password</label>
<input
type="password"
className="block appearance-none w-full bg-white border border-grey-light hover:border-grey px-2 py-2 rounded shadow"
placeholder="Your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<a
onClick={(e) => {
e.preventDefault()
handleLogin('SIGNUP', username, password)
}}
href={'/channels'}
className="bg-indigo-700 hover:bg-teal text-white py-2 px-4 rounded text-center transition duration-150 hover:bg-indigo-600 hover:text-white"
>
Sign up
</a>
<a
onClick={(e) => {
e.preventDefault()
handleLogin('LOGIN', username, password)
}}
href={'/channels'}
className="border border-indigo-700 text-indigo-700 py-2 px-4 rounded w-full text-center transition duration-150 hover:bg-indigo-700 hover:text-white"
>
Login
</a>
</div>
</div>
</div>
</div>
)
}
export default Home

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
public/slack-clone-demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

28
styles/style.scss Normal file
View file

@ -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;
}

4
supabase/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
# Supabase
.branches
.temp
.env

88
supabase/config.toml Normal file
View file

@ -0,0 +1,88 @@
# 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
[realtime]
enabled = true
# Bind realtime via either IPv4 or IPv6. (default: IPv6)
# ip_version = "IPv6"
[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(NEXT_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(NEXT_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 = 3600
# 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
# Uncomment to expose additional ports for testing user applications that send emails.
# smtp_port = 54325
# pop3_port = 54326
# admin_email = "admin@email.com"
# sender_name = "Admin"
# 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"
[analytics]
enabled = false

View file

@ -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;

View file

@ -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)

23
supabase/seed.sql Normal file
View file

@ -0,0 +1,23 @@
insert into public.role_permissions (role, permission)
values
('admin', 'channels.delete'),
('admin', 'messages.delete'),
('moderator', 'messages.delete');
DO $$
DECLARE
user_id uuid;
BEGIN
user_id := public.create_user('supabot+supaadmin@example.com');
insert into public.channels (slug, created_by)
values
('public', user_id),
('random', user_id);
insert into public.messages (message, channel_id, user_id)
values
('Hello World 👋', 1, user_id),
('Perfection is attained, not when there is nothing more to add, but when there is nothing left to take away.', 2, user_id);
END $$;

12
tailwind.config.js Normal file
View file

@ -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: [],
}