mirror of
https://github.com/supabase/supabase.git
synced 2026-10-14 05:39:11 +03:00
Merge pull request #1587 from easy-street/feature/add-slack-clone-rbac
extend nextjs-slack-clone example with RBAC
This commit is contained in:
9 files changed
+181
-33
No files matched your search
@@ -24,3 +24,6 @@
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Supabase
|
||||
.supabase
|
||||
@@ -114,6 +114,17 @@ Visit http://localhost:3000 and start chatting! Open a channel across two browse
|
||||
|
||||
## Supabase details
|
||||
|
||||
### 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' Role Level Security.
|
||||
@@ -124,6 +135,26 @@ We can use these details to provide fine-grained control over what each user can
|
||||
This is a trimmed-down schema, with the policies:
|
||||
|
||||
```sql
|
||||
-- authorize with role-based access control (RBAC)
|
||||
CREATE FUNCTION public.authorize(
|
||||
requested_permission app_permission,
|
||||
user_id uuid
|
||||
)
|
||||
RETURNS BOOLEAN AS $$
|
||||
DECLARE
|
||||
bind_permissions int;
|
||||
BEGIN
|
||||
SELECT count(*)
|
||||
FROM public.role_permissions
|
||||
INNER JOIN public.user_roles on role_permissions.role = user_roles.role
|
||||
WHERE role_permissions.permission = authorize.requested_permission
|
||||
AND user_roles.user_id = authorize.user_id
|
||||
INTO bind_permissions;
|
||||
|
||||
RETURN bind_permissions > 0;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- USER PROFILES
|
||||
CREATE TYPE public.user_status AS ENUM ('ONLINE', 'OFFLINE');
|
||||
CREATE TABLE public.users (
|
||||
@@ -143,7 +174,11 @@ CREATE TABLE public.channels (
|
||||
slug text NOT NULL UNIQUE
|
||||
);
|
||||
ALTER TABLE public.channels ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "Allow logged-in full access" on public.channels FOR ALL USING ( auth.role() = 'authenticated' );
|
||||
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 update access" on public.channels FOR UPDATE USING ( 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', auth.uid()) );
|
||||
|
||||
-- MESSAGES
|
||||
CREATE TABLE public.messages (
|
||||
@@ -157,6 +192,8 @@ ALTER TABLE public.messages ENABLE ROW LEVEL SECURITY;
|
||||
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', auth.uid()) );
|
||||
```
|
||||
|
||||
## Authors
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import Link from 'next/link'
|
||||
import { useContext } from 'react'
|
||||
import UserContext from '~/lib/UserContext'
|
||||
import { addChannel } from '~/lib/Store'
|
||||
import { addChannel, deleteChannel } from '~/lib/Store'
|
||||
import TrashIcon from '~/components/TrashIcon'
|
||||
|
||||
export default function Layout(props) {
|
||||
const { signOut } = useContext(UserContext)
|
||||
const { signOut, user, userRoles } = useContext(UserContext)
|
||||
|
||||
const slugify = (text) => {
|
||||
return text
|
||||
@@ -20,7 +21,7 @@ export default function Layout(props) {
|
||||
const newChannel = async () => {
|
||||
const slug = prompt('Please enter your name')
|
||||
if (slug) {
|
||||
addChannel(slugify(slug))
|
||||
addChannel(slugify(slug), user.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +42,8 @@ export default function Layout(props) {
|
||||
</button>
|
||||
</div>
|
||||
<hr className="m-2" />
|
||||
<div className="p-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()}
|
||||
@@ -57,6 +59,8 @@ export default function Layout(props) {
|
||||
channel={x}
|
||||
key={x.id}
|
||||
isActiveChannel={x.id === props.activeChannelId}
|
||||
user={user}
|
||||
userRoles={userRoles}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@@ -69,12 +73,17 @@ export default function Layout(props) {
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarItem = ({ channel, isActiveChannel }) => (
|
||||
const SidebarItem = ({ channel, isActiveChannel, user, userRoles }) => (
|
||||
<>
|
||||
<li>
|
||||
<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 || userRoles.includes('admin')) && (
|
||||
<button onClick={() => deleteChannel(channel.id)}>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
</>
|
||||
)
|
||||
@@ -1,10 +1,27 @@
|
||||
const Message = ({ message }) => (
|
||||
<>
|
||||
<div className="py-1">
|
||||
<p className="text-blue-700 font-bold">{message.author.username}</p>
|
||||
<p className="text-white">{message.message}</p>
|
||||
import { useContext } from 'react'
|
||||
import UserContext from '~/lib/UserContext'
|
||||
import { deleteMessage } from '~/lib/Store'
|
||||
import TrashIcon from '~/components/TrashIcon'
|
||||
|
||||
const Message = ({ message }) => {
|
||||
const { user, userRoles } = 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 ||
|
||||
userRoles.some((role) => ['admin', 'moderator'].includes(role))) && (
|
||||
<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,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
|
||||
@@ -16,25 +16,29 @@ export const useStore = (props) => {
|
||||
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 messages
|
||||
// Listen for new and deleted messages
|
||||
const messageListener = supabase
|
||||
.from('messages')
|
||||
.on('INSERT', (payload) => handleNewMessage(payload.new))
|
||||
.on('DELETE', (payload) => handleDeletedMessage(payload.old))
|
||||
.subscribe()
|
||||
// Listen for changes to our users
|
||||
const userListener = supabase
|
||||
.from('users')
|
||||
.on('*', (payload) => handleNewOrUpdatedUser(payload.new))
|
||||
.subscribe()
|
||||
// Listen for new channels
|
||||
// Listen for new and deleted channels
|
||||
const channelListener = supabase
|
||||
.from('channels')
|
||||
.on('INSERT', (payload) => handleNewChannel(payload.new))
|
||||
.on('DELETE', (payload) => handleDeletedChannel(payload.old))
|
||||
.subscribe()
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
@@ -68,12 +72,24 @@ export const useStore = (props) => {
|
||||
// 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 recieved 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 recieved from Postgres
|
||||
useEffect(() => {
|
||||
if (newOrUpdatedUser) users.set(newOrUpdatedUser.id, newOrUpdatedUser)
|
||||
@@ -109,7 +125,7 @@ export const fetchChannels = async (setState) => {
|
||||
*/
|
||||
export const fetchUser = async (userId, setState) => {
|
||||
try {
|
||||
let { body } = await supabase.from('users').eq('id', userId).select(`*`)
|
||||
let { body } = await supabase.from('users').select(`*`).eq('id', userId)
|
||||
let user = body[0]
|
||||
if (setState) setState(user)
|
||||
return user
|
||||
@@ -118,6 +134,20 @@ export const fetchUser = async (userId, setState) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all roles for the current user
|
||||
* @param {function} setState Optionally pass in a hook or callback to set the state
|
||||
*/
|
||||
export const fetchUserRoles = async (setState) => {
|
||||
try {
|
||||
let { body } = await supabase.from('user_roles').select(`*`)
|
||||
if (setState) setState(body)
|
||||
return body
|
||||
} catch (error) {
|
||||
console.log('error', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all messages and their authors
|
||||
* @param {number} channelId
|
||||
@@ -140,10 +170,11 @@ export const fetchMessages = async (channelId, setState) => {
|
||||
/**
|
||||
* 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) => {
|
||||
export const addChannel = async (slug, user_id) => {
|
||||
try {
|
||||
let { body } = await supabase.from('channels').insert([{ slug }])
|
||||
let { body } = await supabase.from('channels').insert([{ slug, created_by: user_id }])
|
||||
return body
|
||||
} catch (error) {
|
||||
console.log('error', error)
|
||||
@@ -164,3 +195,29 @@ export const addMessage = async (message, channel_id, user_id) => {
|
||||
console.log('error', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a channel from the DB
|
||||
* @param {number} channel_id
|
||||
*/
|
||||
export const deleteChannel = async (channel_id) => {
|
||||
try {
|
||||
let { body } = await supabase.from('channels').delete().match({ id: channel_id })
|
||||
return body
|
||||
} catch (error) {
|
||||
console.log('error', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a message from the DB
|
||||
* @param {number} message_id
|
||||
*/
|
||||
export const deleteMessage = async (message_id) => {
|
||||
try {
|
||||
let { body } = await supabase.from('messages').delete().match({ id: message_id })
|
||||
return body
|
||||
} catch (error) {
|
||||
console.log('error', error)
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,13 @@ import '~/styles/style.scss'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Router from 'next/router'
|
||||
import UserContext from 'lib/UserContext'
|
||||
import { supabase } from 'lib/Store'
|
||||
import { supabase, fetchUserRoles } from 'lib/Store'
|
||||
|
||||
export default function SupabaseSlackClone({ Component, pageProps }) {
|
||||
const [userLoaded, setUserLoaded] = useState(false)
|
||||
const [user, setUser] = useState(null)
|
||||
const [session, setSession] = useState(null)
|
||||
const [userRoles, setUserRoles] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
const session = supabase.auth.session()
|
||||
@@ -15,7 +16,7 @@ export default function SupabaseSlackClone({ Component, pageProps }) {
|
||||
setUser(session?.user ?? null)
|
||||
setUserLoaded(session ? true : false)
|
||||
if (user) {
|
||||
signIn(user.id, user.email)
|
||||
signIn()
|
||||
Router.push('/channels/[id]', '/channels/1')
|
||||
}
|
||||
|
||||
@@ -35,15 +36,8 @@ export default function SupabaseSlackClone({ Component, pageProps }) {
|
||||
}
|
||||
}, [user])
|
||||
|
||||
const signIn = async (id, username) => {
|
||||
const { body } = await supabase.from('users').select('id, username').eq('id', id)
|
||||
const result = body[0]
|
||||
|
||||
// If the user exists in the users table, update the username.
|
||||
// If not, create a new row.
|
||||
result?.id
|
||||
? await supabase.from('users').update({ id, username }).match({ id }).single()
|
||||
: await supabase.from('users').insert([{ id, username }]).single()
|
||||
const signIn = async () => {
|
||||
await fetchUserRoles((userRoles) => setUserRoles(userRoles.map((userRole) => userRole.role)))
|
||||
}
|
||||
|
||||
const signOut = async () => {
|
||||
@@ -56,6 +50,7 @@ export default function SupabaseSlackClone({ Component, pageProps }) {
|
||||
value={{
|
||||
userLoaded,
|
||||
user,
|
||||
userRoles,
|
||||
signIn,
|
||||
signOut,
|
||||
}}
|
||||
|
||||
@@ -22,6 +22,13 @@ const ChannelsPage = (props) => {
|
||||
})
|
||||
}, [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}>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useState, useContext } from 'react'
|
||||
import UserContext from 'lib/UserContext'
|
||||
import { useState } from 'react'
|
||||
import { supabase } from 'lib/Store'
|
||||
|
||||
const Home = () => {
|
||||
const { signIn } = useContext(UserContext)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
|
||||
Reference in new issue
Block a user