RBAC in place. Tailwind CSS working.
This commit is contained in:
146
src/App.tsx
146
src/App.tsx
@@ -1,34 +1,126 @@
|
|||||||
import { useState } from 'react'
|
import React from 'react'
|
||||||
import reactLogo from './assets/react.svg'
|
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||||
import viteLogo from '/vite.svg'
|
import { LoginForm } from './components/auth/LoginForm'
|
||||||
import './App.css'
|
import { UserProfile } from './components/auth/UserProfile'
|
||||||
|
import { ProtectedRoute, AdminOnly, ModeratorOrAdmin, AuthenticatedOnly } from './components/auth/ProtectedRoute'
|
||||||
|
|
||||||
function App() {
|
const AppContent: React.FC = () => {
|
||||||
const [count, setCount] = useState(0)
|
const { user, loading } = useAuth()
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-xl">Loading...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100 py-8">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<h1 className="text-3xl font-bold text-center mb-8">RBAC Demo Application</h1>
|
||||||
|
<LoginForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="min-h-screen bg-gray-100 py-8">
|
||||||
<div>
|
<div className="container mx-auto px-4">
|
||||||
<a href="https://vite.dev" target="_blank">
|
<h1 className="text-3xl font-bold text-center mb-8">RBAC Demo Application</h1>
|
||||||
<img src={viteLogo} className="logo" alt="Vite logo" />
|
|
||||||
</a>
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<a href="https://react.dev" target="_blank">
|
{/* User Profile Section */}
|
||||||
<img src={reactLogo} className="logo react" alt="React logo" />
|
<div>
|
||||||
</a>
|
<UserProfile />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role-Based Content Section */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Content for all authenticated users */}
|
||||||
|
<AuthenticatedOnly>
|
||||||
|
<div className="bg-green-50 p-4 rounded-lg border border-green-200">
|
||||||
|
<h3 className="text-lg font-semibold text-green-800 mb-2">
|
||||||
|
✅ Authenticated User Content
|
||||||
|
</h3>
|
||||||
|
<p className="text-green-700">
|
||||||
|
This content is visible to all authenticated users.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</AuthenticatedOnly>
|
||||||
|
|
||||||
|
{/* Content for moderators and admins */}
|
||||||
|
<ModeratorOrAdmin>
|
||||||
|
<div className="bg-yellow-50 p-4 rounded-lg border border-yellow-200">
|
||||||
|
<h3 className="text-lg font-semibold text-yellow-800 mb-2">
|
||||||
|
🛡️ Moderator/Admin Content
|
||||||
|
</h3>
|
||||||
|
<p className="text-yellow-700">
|
||||||
|
This content is visible to moderators and administrators only.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ModeratorOrAdmin>
|
||||||
|
|
||||||
|
{/* Content for admins only */}
|
||||||
|
<AdminOnly>
|
||||||
|
<div className="bg-red-50 p-4 rounded-lg border border-red-200">
|
||||||
|
<h3 className="text-lg font-semibold text-red-800 mb-2">
|
||||||
|
🔑 Admin Only Content
|
||||||
|
</h3>
|
||||||
|
<p className="text-red-700">
|
||||||
|
This content is visible to administrators only. You have full system access.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</AdminOnly>
|
||||||
|
|
||||||
|
{/* Custom role check example */}
|
||||||
|
<ProtectedRoute
|
||||||
|
requiredRole="user"
|
||||||
|
fallback={
|
||||||
|
<div className="bg-gray-50 p-4 rounded-lg border border-gray-200">
|
||||||
|
<p className="text-gray-600">You need 'user' role to see this content.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="bg-blue-50 p-4 rounded-lg border border-blue-200">
|
||||||
|
<h3 className="text-lg font-semibold text-blue-800 mb-2">
|
||||||
|
👤 User Role Content
|
||||||
|
</h3>
|
||||||
|
<p className="text-blue-700">
|
||||||
|
This content is visible to users with the 'user' role.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ProtectedRoute>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Instructions */}
|
||||||
|
<div className="mt-8 bg-white p-6 rounded-lg shadow-md">
|
||||||
|
<h2 className="text-xl font-bold mb-4">RBAC System Instructions</h2>
|
||||||
|
<div className="space-y-2 text-sm text-gray-600">
|
||||||
|
<p><strong>Admin User:</strong> s.alireza.v@gmail.com (password: ???????)</p>
|
||||||
|
<p><strong>Features:</strong></p>
|
||||||
|
<ul className="list-disc list-inside ml-4 space-y-1">
|
||||||
|
<li>Role-based content visibility</li>
|
||||||
|
<li>Protected routes and components</li>
|
||||||
|
<li>User profile with role display</li>
|
||||||
|
<li>Secure authentication with Supabase</li>
|
||||||
|
<li>Row Level Security (RLS) policies</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1>Vite + React</h1>
|
</div>
|
||||||
<div className="card">
|
)
|
||||||
<button onClick={() => setCount((count) => count + 1)}>
|
}
|
||||||
count is {count}
|
|
||||||
</button>
|
function App() {
|
||||||
<p>
|
return (
|
||||||
Edit <code>src/App.tsx</code> and save to test HMR
|
<AuthProvider>
|
||||||
</p>
|
<AppContent />
|
||||||
</div>
|
</AuthProvider>
|
||||||
<p className="read-the-docs">
|
|
||||||
Click on the Vite and React logos to learn more
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
85
src/components/auth/LoginForm.tsx
Normal file
85
src/components/auth/LoginForm.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
|
|
||||||
|
export const LoginForm: React.FC = () => {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const { signIn } = useAuth()
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error } = await signIn(email, password)
|
||||||
|
if (error) {
|
||||||
|
setError(error.message)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('An unexpected error occurred')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-md mx-auto mt-8 p-6 bg-white rounded-lg shadow-md">
|
||||||
|
<h2 className="text-2xl font-bold mb-6 text-center">Sign In</h2>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Enter your email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-blue-500 text-white py-2 px-4 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? 'Signing in...' : 'Sign In'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-4 text-sm text-gray-600">
|
||||||
|
<p>Test admin credentials:</p>
|
||||||
|
<p>Email: s.alireza.v@gmail.com</p>
|
||||||
|
<p>Password: ???????</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
79
src/components/auth/ProtectedRoute.tsx
Normal file
79
src/components/auth/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
|
import type { RoleName } from '../../types/auth'
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
requiredRole?: RoleName
|
||||||
|
requiredRoles?: RoleName[]
|
||||||
|
fallback?: React.ReactNode
|
||||||
|
requireAuth?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||||
|
children,
|
||||||
|
requiredRole,
|
||||||
|
requiredRoles,
|
||||||
|
fallback = <div className="text-red-500">Access denied. You don't have permission to view this content.</div>,
|
||||||
|
requireAuth = true
|
||||||
|
}) => {
|
||||||
|
const { user, loading } = useAuth()
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<div className="text-gray-500">Loading...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if authentication is required
|
||||||
|
if (requireAuth && !user) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<div className="text-red-500">Please sign in to access this content.</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check single required role
|
||||||
|
if (requiredRole && user && !user.roles?.includes(requiredRole)) {
|
||||||
|
return <>{fallback}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check multiple required roles (user must have at least one)
|
||||||
|
if (requiredRoles && user && !requiredRoles.some(role => user.roles?.includes(role))) {
|
||||||
|
return <>{fallback}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience components for common role checks
|
||||||
|
export const AdminOnly: React.FC<{ children: React.ReactNode; fallback?: React.ReactNode }> = ({
|
||||||
|
children,
|
||||||
|
fallback
|
||||||
|
}) => (
|
||||||
|
<ProtectedRoute requiredRole="admin" fallback={fallback}>
|
||||||
|
{children}
|
||||||
|
</ProtectedRoute>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const ModeratorOrAdmin: React.FC<{ children: React.ReactNode; fallback?: React.ReactNode }> = ({
|
||||||
|
children,
|
||||||
|
fallback
|
||||||
|
}) => (
|
||||||
|
<ProtectedRoute requiredRoles={['admin', 'moderator']} fallback={fallback}>
|
||||||
|
{children}
|
||||||
|
</ProtectedRoute>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const AuthenticatedOnly: React.FC<{ children: React.ReactNode; fallback?: React.ReactNode }> = ({
|
||||||
|
children,
|
||||||
|
fallback
|
||||||
|
}) => (
|
||||||
|
<ProtectedRoute requireAuth={true} fallback={fallback}>
|
||||||
|
{children}
|
||||||
|
</ProtectedRoute>
|
||||||
|
)
|
||||||
78
src/components/auth/UserProfile.tsx
Normal file
78
src/components/auth/UserProfile.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
|
|
||||||
|
export const UserProfile: React.FC = () => {
|
||||||
|
const { user, signOut, isAdmin } = useAuth()
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||||
|
<h2 className="text-2xl font-bold mb-4">User Profile</h2>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Email</label>
|
||||||
|
<p className="text-gray-900">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user.profile && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">First Name</label>
|
||||||
|
<p className="text-gray-900">{user.profile.first_name || 'Not set'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Last Name</label>
|
||||||
|
<p className="text-gray-900">{user.profile.last_name || 'Not set'}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Roles</label>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-1">
|
||||||
|
{user.roles && user.roles.length > 0 ? (
|
||||||
|
user.roles.map((role) => (
|
||||||
|
<span
|
||||||
|
key={role}
|
||||||
|
className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||||
|
role === 'admin'
|
||||||
|
? 'bg-red-100 text-red-800'
|
||||||
|
: role === 'moderator'
|
||||||
|
? 'bg-yellow-100 text-yellow-800'
|
||||||
|
: 'bg-blue-100 text-blue-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{role}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-500">No roles assigned</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAdmin() && (
|
||||||
|
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded">
|
||||||
|
<p className="text-sm text-red-700 font-medium">
|
||||||
|
🔑 You have administrator privileges
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<button
|
||||||
|
onClick={signOut}
|
||||||
|
className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
174
src/contexts/AuthContext.tsx
Normal file
174
src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import React, { createContext, useContext, useEffect, useState } from 'react'
|
||||||
|
import type { User } from '@supabase/supabase-js'
|
||||||
|
import { supabase } from '../lib/supabase'
|
||||||
|
import type { AuthContextType, AuthUser, UserProfile } from '../types/auth'
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext)
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthProviderProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||||
|
const [user, setUser] = useState<AuthUser | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
// Fetch user profile and roles
|
||||||
|
const fetchUserData = async (authUser: User): Promise<AuthUser> => {
|
||||||
|
try {
|
||||||
|
// Fetch user profile
|
||||||
|
const { data: profile, error: profileError } = await supabase
|
||||||
|
.from('user_profiles')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', authUser.id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (profileError && profileError.code !== 'PGRST116') {
|
||||||
|
console.error('Error fetching user profile:', profileError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch user roles using the database function
|
||||||
|
const { data: rolesData, error: rolesError } = await supabase
|
||||||
|
.rpc('get_user_roles', { user_uuid: authUser.id })
|
||||||
|
|
||||||
|
if (rolesError) {
|
||||||
|
console.error('Error fetching user roles:', rolesError)
|
||||||
|
}
|
||||||
|
|
||||||
|
const roles = rolesData?.map(r => r.role_name) || []
|
||||||
|
|
||||||
|
return {
|
||||||
|
...authUser,
|
||||||
|
profile: profile as UserProfile,
|
||||||
|
roles
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user data:', error)
|
||||||
|
return {
|
||||||
|
...authUser,
|
||||||
|
profile: undefined,
|
||||||
|
roles: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh user data
|
||||||
|
const refreshUserData = async () => {
|
||||||
|
const { data: { user: authUser } } = await supabase.auth.getUser()
|
||||||
|
if (authUser) {
|
||||||
|
const userData = await fetchUserData(authUser)
|
||||||
|
setUser(userData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign in
|
||||||
|
const signIn = async (email: string, password: string) => {
|
||||||
|
const { error } = await supabase.auth.signInWithPassword({
|
||||||
|
email,
|
||||||
|
password
|
||||||
|
})
|
||||||
|
return { error }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign up
|
||||||
|
const signUp = async (
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
userData?: { first_name?: string; last_name?: string }
|
||||||
|
) => {
|
||||||
|
const { data, error } = await supabase.auth.signUp({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
options: {
|
||||||
|
data: userData
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// If signup successful and user data provided, create profile
|
||||||
|
if (!error && data.user && userData) {
|
||||||
|
const { error: profileError } = await supabase
|
||||||
|
.from('user_profiles')
|
||||||
|
.insert({
|
||||||
|
id: data.user.id,
|
||||||
|
first_name: userData.first_name,
|
||||||
|
last_name: userData.last_name
|
||||||
|
})
|
||||||
|
|
||||||
|
if (profileError) {
|
||||||
|
console.error('Error creating user profile:', profileError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign default 'user' role
|
||||||
|
const { error: roleError } = await supabase
|
||||||
|
.from('user_roles')
|
||||||
|
.insert({
|
||||||
|
user_id: data.user.id,
|
||||||
|
role_id: 6 // 'user' role ID from our database
|
||||||
|
})
|
||||||
|
|
||||||
|
if (roleError) {
|
||||||
|
console.error('Error assigning default role:', roleError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { error }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign out
|
||||||
|
const signOut = async () => {
|
||||||
|
await supabase.auth.signOut()
|
||||||
|
setUser(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has specific role
|
||||||
|
const hasRole = (roleName: string): boolean => {
|
||||||
|
return user?.roles?.includes(roleName) || false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is admin
|
||||||
|
const isAdmin = (): boolean => {
|
||||||
|
return hasRole('admin')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle auth state changes
|
||||||
|
useEffect(() => {
|
||||||
|
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
||||||
|
async (event, session) => {
|
||||||
|
if (session?.user) {
|
||||||
|
const userData = await fetchUserData(session.user)
|
||||||
|
setUser(userData)
|
||||||
|
} else {
|
||||||
|
setUser(null)
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return () => subscription.unsubscribe()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const value: AuthContextType = {
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
signIn,
|
||||||
|
signUp,
|
||||||
|
signOut,
|
||||||
|
hasRole,
|
||||||
|
isAdmin,
|
||||||
|
refreshUserData
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,68 +1 @@
|
|||||||
:root {
|
@import "tailwindcss";
|
||||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
|
||||||
line-height: 1.5;
|
|
||||||
font-weight: 400;
|
|
||||||
|
|
||||||
color-scheme: light dark;
|
|
||||||
color: rgba(255, 255, 255, 0.87);
|
|
||||||
background-color: #242424;
|
|
||||||
|
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #646cff;
|
|
||||||
text-decoration: inherit;
|
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
color: #535bf2;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
place-items: center;
|
|
||||||
min-width: 320px;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 3.2em;
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
padding: 0.6em 1.2em;
|
|
||||||
font-size: 1em;
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: inherit;
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.25s;
|
|
||||||
}
|
|
||||||
button:hover {
|
|
||||||
border-color: #646cff;
|
|
||||||
}
|
|
||||||
button:focus,
|
|
||||||
button:focus-visible {
|
|
||||||
outline: 4px auto -webkit-focus-ring-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
:root {
|
|
||||||
color: #213547;
|
|
||||||
background-color: #ffffff;
|
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
color: #747bff;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
107
src/lib/supabase.ts
Normal file
107
src/lib/supabase.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js'
|
||||||
|
|
||||||
|
// Local Supabase instance configuration
|
||||||
|
const supabaseUrl = 'http://127.0.0.1:54321'
|
||||||
|
const supabaseAnonKey = '[REDACTED]'
|
||||||
|
|
||||||
|
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
|
||||||
|
|
||||||
|
// Database types for TypeScript
|
||||||
|
export interface Database {
|
||||||
|
public: {
|
||||||
|
Tables: {
|
||||||
|
roles: {
|
||||||
|
Row: {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
id?: number
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
created_at?: string
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
id?: number
|
||||||
|
name?: string
|
||||||
|
description?: string | null
|
||||||
|
created_at?: string
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user_profiles: {
|
||||||
|
Row: {
|
||||||
|
id: string
|
||||||
|
first_name: string | null
|
||||||
|
last_name: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
id: string
|
||||||
|
first_name?: string | null
|
||||||
|
last_name?: string | null
|
||||||
|
created_at?: string
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
id?: string
|
||||||
|
first_name?: string | null
|
||||||
|
last_name?: string | null
|
||||||
|
created_at?: string
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user_roles: {
|
||||||
|
Row: {
|
||||||
|
id: number
|
||||||
|
user_id: string | null
|
||||||
|
role_id: number | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
id?: number
|
||||||
|
user_id?: string | null
|
||||||
|
role_id?: number | null
|
||||||
|
created_at?: string
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
id?: number
|
||||||
|
user_id?: string | null
|
||||||
|
role_id?: number | null
|
||||||
|
created_at?: string
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Views: {
|
||||||
|
[_ in never]: never
|
||||||
|
}
|
||||||
|
Functions: {
|
||||||
|
get_user_roles: {
|
||||||
|
Args: {
|
||||||
|
user_uuid: string
|
||||||
|
}
|
||||||
|
Returns: {
|
||||||
|
role_name: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
user_has_role: {
|
||||||
|
Args: {
|
||||||
|
user_uuid: string
|
||||||
|
role_name: string
|
||||||
|
}
|
||||||
|
Returns: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Enums: {
|
||||||
|
[_ in never]: never
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/types/auth.ts
Normal file
43
src/types/auth.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { User } from '@supabase/supabase-js'
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
id: string
|
||||||
|
first_name: string | null
|
||||||
|
last_name: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Role {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserRole {
|
||||||
|
id: number
|
||||||
|
user_id: string | null
|
||||||
|
role_id: number | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthUser extends User {
|
||||||
|
profile?: UserProfile
|
||||||
|
roles?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthContextType {
|
||||||
|
user: AuthUser | null
|
||||||
|
loading: boolean
|
||||||
|
signIn: (email: string, password: string) => Promise<{ error: any }>
|
||||||
|
signUp: (email: string, password: string, userData?: { first_name?: string; last_name?: string }) => Promise<{ error: any }>
|
||||||
|
signOut: () => Promise<void>
|
||||||
|
hasRole: (roleName: string) => boolean
|
||||||
|
isAdmin: () => boolean
|
||||||
|
refreshUserData: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoleName = 'admin' | 'user' | 'moderator' | 'coordinator' | 'conductor' | 'analyst'
|
||||||
8
supabase/.gitignore
vendored
Normal file
8
supabase/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Supabase
|
||||||
|
.branches
|
||||||
|
.temp
|
||||||
|
|
||||||
|
# dotenvx
|
||||||
|
.env.keys
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
322
supabase/config.toml
Normal file
322
supabase/config.toml
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
# For detailed configuration reference documentation, visit:
|
||||||
|
# https://supabase.com/docs/guides/local-development/cli/config
|
||||||
|
# A string used to distinguish different Supabase projects on the same host. Defaults to the
|
||||||
|
# working directory name when running `supabase init`.
|
||||||
|
project_id = "pecan_experiments"
|
||||||
|
|
||||||
|
[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 `graphql_public` schemas are included by default.
|
||||||
|
schemas = ["public", "graphql_public"]
|
||||||
|
# Extra schemas to add to the search_path of every request.
|
||||||
|
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
|
||||||
|
|
||||||
|
[api.tls]
|
||||||
|
# Enable HTTPS endpoints locally using a self-signed certificate.
|
||||||
|
enabled = false
|
||||||
|
|
||||||
|
[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 = 17
|
||||||
|
|
||||||
|
[db.pooler]
|
||||||
|
enabled = false
|
||||||
|
# Port to use for the local connection pooler.
|
||||||
|
port = 54329
|
||||||
|
# Specifies when a server connection can be reused by other clients.
|
||||||
|
# Configure one of the supported pooler modes: `transaction`, `session`.
|
||||||
|
pool_mode = "transaction"
|
||||||
|
# How many server connections to allow per user/database pair.
|
||||||
|
default_pool_size = 20
|
||||||
|
# Maximum number of client connections allowed.
|
||||||
|
max_client_conn = 100
|
||||||
|
|
||||||
|
# [db.vault]
|
||||||
|
# secret_key = "env(SECRET_VALUE)"
|
||||||
|
|
||||||
|
[db.migrations]
|
||||||
|
# If disabled, migrations will be skipped during a db push or reset.
|
||||||
|
enabled = true
|
||||||
|
# Specifies an ordered list of schema files that describe your database.
|
||||||
|
# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
|
||||||
|
schema_paths = []
|
||||||
|
|
||||||
|
[db.seed]
|
||||||
|
# If enabled, seeds the database after migrations during a db reset.
|
||||||
|
enabled = true
|
||||||
|
# Specifies an ordered list of seed files to load during db reset.
|
||||||
|
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
|
||||||
|
sql_paths = ["./seed.sql"]
|
||||||
|
|
||||||
|
[realtime]
|
||||||
|
enabled = true
|
||||||
|
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
|
||||||
|
# ip_version = "IPv6"
|
||||||
|
# The maximum length in bytes of HTTP request headers. (default: 4096)
|
||||||
|
# max_header_length = 4096
|
||||||
|
|
||||||
|
[studio]
|
||||||
|
enabled = true
|
||||||
|
# Port to use for Supabase Studio.
|
||||||
|
port = 54323
|
||||||
|
# External URL of the API server that frontend connects to.
|
||||||
|
api_url = "http://127.0.0.1"
|
||||||
|
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
|
||||||
|
openai_api_key = "env(OPENAI_API_KEY)"
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
[storage]
|
||||||
|
enabled = true
|
||||||
|
# The maximum file size allowed (e.g. "5MB", "500KB").
|
||||||
|
file_size_limit = "50MiB"
|
||||||
|
|
||||||
|
# Image transformation API is available to Supabase Pro plan.
|
||||||
|
# [storage.image_transformation]
|
||||||
|
# enabled = true
|
||||||
|
|
||||||
|
# Uncomment to configure local storage buckets
|
||||||
|
# [storage.buckets.images]
|
||||||
|
# public = false
|
||||||
|
# file_size_limit = "50MiB"
|
||||||
|
# allowed_mime_types = ["image/png", "image/jpeg"]
|
||||||
|
# objects_path = "./images"
|
||||||
|
|
||||||
|
[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 = "http://127.0.0.1:3000"
|
||||||
|
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
|
||||||
|
additional_redirect_urls = ["https://127.0.0.1:3000"]
|
||||||
|
# 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
|
||||||
|
# Allow/disallow anonymous sign-ins to your project.
|
||||||
|
enable_anonymous_sign_ins = false
|
||||||
|
# Allow/disallow testing manual linking of accounts
|
||||||
|
enable_manual_linking = false
|
||||||
|
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
|
||||||
|
minimum_password_length = 6
|
||||||
|
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
|
||||||
|
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
|
||||||
|
password_requirements = ""
|
||||||
|
|
||||||
|
[auth.rate_limit]
|
||||||
|
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
|
||||||
|
email_sent = 2
|
||||||
|
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
|
||||||
|
sms_sent = 30
|
||||||
|
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
|
||||||
|
anonymous_users = 30
|
||||||
|
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
|
||||||
|
token_refresh = 150
|
||||||
|
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
|
||||||
|
sign_in_sign_ups = 30
|
||||||
|
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
|
||||||
|
token_verifications = 30
|
||||||
|
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
|
||||||
|
web3 = 30
|
||||||
|
|
||||||
|
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
|
||||||
|
# [auth.captcha]
|
||||||
|
# enabled = true
|
||||||
|
# provider = "hcaptcha"
|
||||||
|
# secret = ""
|
||||||
|
|
||||||
|
[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 = false
|
||||||
|
# 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 = "1s"
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Use a production-ready SMTP server
|
||||||
|
# [auth.email.smtp]
|
||||||
|
# enabled = true
|
||||||
|
# host = "smtp.sendgrid.net"
|
||||||
|
# port = 587
|
||||||
|
# user = "apikey"
|
||||||
|
# pass = "env(SENDGRID_API_KEY)"
|
||||||
|
# admin_email = "admin@email.com"
|
||||||
|
# sender_name = "Admin"
|
||||||
|
|
||||||
|
# Uncomment to customize email template
|
||||||
|
# [auth.email.template.invite]
|
||||||
|
# subject = "You have been invited"
|
||||||
|
# content_path = "./supabase/templates/invite.html"
|
||||||
|
|
||||||
|
[auth.sms]
|
||||||
|
# Allow/disallow new user signups via SMS to your project.
|
||||||
|
enable_signup = false
|
||||||
|
# If enabled, users need to confirm their phone number before signing in.
|
||||||
|
enable_confirmations = false
|
||||||
|
# Template for sending OTP to users
|
||||||
|
template = "Your code is {{ .Code }}"
|
||||||
|
# Controls the minimum amount of time that must pass before sending another sms otp.
|
||||||
|
max_frequency = "5s"
|
||||||
|
|
||||||
|
# Use pre-defined map of phone number to OTP for testing.
|
||||||
|
# [auth.sms.test_otp]
|
||||||
|
# 4152127777 = "123456"
|
||||||
|
|
||||||
|
# Configure logged in session timeouts.
|
||||||
|
# [auth.sessions]
|
||||||
|
# Force log out after the specified duration.
|
||||||
|
# timebox = "24h"
|
||||||
|
# Force log out if the user has been inactive longer than the specified duration.
|
||||||
|
# inactivity_timeout = "8h"
|
||||||
|
|
||||||
|
# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
|
||||||
|
# [auth.hook.before_user_created]
|
||||||
|
# enabled = true
|
||||||
|
# uri = "pg-functions://postgres/auth/before-user-created-hook"
|
||||||
|
|
||||||
|
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
|
||||||
|
# [auth.hook.custom_access_token]
|
||||||
|
# enabled = true
|
||||||
|
# uri = "pg-functions://<database>/<schema>/<hook_name>"
|
||||||
|
|
||||||
|
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
|
||||||
|
[auth.sms.twilio]
|
||||||
|
enabled = false
|
||||||
|
account_sid = ""
|
||||||
|
message_service_sid = ""
|
||||||
|
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
|
||||||
|
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
|
||||||
|
|
||||||
|
# Multi-factor-authentication is available to Supabase Pro plan.
|
||||||
|
[auth.mfa]
|
||||||
|
# Control how many MFA factors can be enrolled at once per user.
|
||||||
|
max_enrolled_factors = 10
|
||||||
|
|
||||||
|
# Control MFA via App Authenticator (TOTP)
|
||||||
|
[auth.mfa.totp]
|
||||||
|
enroll_enabled = false
|
||||||
|
verify_enabled = false
|
||||||
|
|
||||||
|
# Configure MFA via Phone Messaging
|
||||||
|
[auth.mfa.phone]
|
||||||
|
enroll_enabled = false
|
||||||
|
verify_enabled = false
|
||||||
|
otp_length = 6
|
||||||
|
template = "Your code is {{ .Code }}"
|
||||||
|
max_frequency = "5s"
|
||||||
|
|
||||||
|
# Configure MFA via WebAuthn
|
||||||
|
# [auth.mfa.web_authn]
|
||||||
|
# enroll_enabled = true
|
||||||
|
# verify_enabled = true
|
||||||
|
|
||||||
|
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
|
||||||
|
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
|
||||||
|
# `twitter`, `slack`, `spotify`, `workos`, `zoom`.
|
||||||
|
[auth.external.apple]
|
||||||
|
enabled = false
|
||||||
|
client_id = ""
|
||||||
|
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
|
||||||
|
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
|
||||||
|
# Overrides the default auth redirectUrl.
|
||||||
|
redirect_uri = ""
|
||||||
|
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
|
||||||
|
# or any other third-party OIDC providers.
|
||||||
|
url = ""
|
||||||
|
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
|
||||||
|
skip_nonce_check = false
|
||||||
|
|
||||||
|
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
|
||||||
|
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
|
||||||
|
[auth.web3.solana]
|
||||||
|
enabled = false
|
||||||
|
|
||||||
|
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
|
||||||
|
[auth.third_party.firebase]
|
||||||
|
enabled = false
|
||||||
|
# project_id = "my-firebase-project"
|
||||||
|
|
||||||
|
# Use Auth0 as a third-party provider alongside Supabase Auth.
|
||||||
|
[auth.third_party.auth0]
|
||||||
|
enabled = false
|
||||||
|
# tenant = "my-auth0-tenant"
|
||||||
|
# tenant_region = "us"
|
||||||
|
|
||||||
|
# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
|
||||||
|
[auth.third_party.aws_cognito]
|
||||||
|
enabled = false
|
||||||
|
# user_pool_id = "my-user-pool-id"
|
||||||
|
# user_pool_region = "us-east-1"
|
||||||
|
|
||||||
|
# Use Clerk as a third-party provider alongside Supabase Auth.
|
||||||
|
[auth.third_party.clerk]
|
||||||
|
enabled = false
|
||||||
|
# Obtain from https://clerk.com/setup/supabase
|
||||||
|
# domain = "example.clerk.accounts.dev"
|
||||||
|
|
||||||
|
[edge_runtime]
|
||||||
|
enabled = true
|
||||||
|
# Configure one of the supported request policies: `oneshot`, `per_worker`.
|
||||||
|
# Use `oneshot` for hot reload, or `per_worker` for load testing.
|
||||||
|
policy = "oneshot"
|
||||||
|
# Port to attach the Chrome inspector for debugging edge functions.
|
||||||
|
inspector_port = 8083
|
||||||
|
# The Deno major version to use.
|
||||||
|
deno_version = 1
|
||||||
|
|
||||||
|
# [edge_runtime.secrets]
|
||||||
|
# secret_key = "env(SECRET_VALUE)"
|
||||||
|
|
||||||
|
[analytics]
|
||||||
|
enabled = true
|
||||||
|
port = 54327
|
||||||
|
# Configure one of the supported backends: `postgres`, `bigquery`.
|
||||||
|
backend = "postgres"
|
||||||
|
|
||||||
|
# Experimental features may be deprecated any time
|
||||||
|
[experimental]
|
||||||
|
# Configures Postgres storage engine to use OrioleDB (S3)
|
||||||
|
orioledb_version = ""
|
||||||
|
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
|
||||||
|
s3_host = "env(S3_HOST)"
|
||||||
|
# Configures S3 bucket region, eg. us-east-1
|
||||||
|
s3_region = "env(S3_REGION)"
|
||||||
|
# Configures AWS_ACCESS_KEY_ID for S3 bucket
|
||||||
|
s3_access_key = "env(S3_ACCESS_KEY)"
|
||||||
|
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
|
||||||
|
s3_secret_key = "env(S3_SECRET_KEY)"
|
||||||
102
supabase/migrations/20250717153538_setup_rbac.sql
Normal file
102
supabase/migrations/20250717153538_setup_rbac.sql
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
-- Create roles table
|
||||||
|
CREATE TABLE IF NOT EXISTS public.roles (
|
||||||
|
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
name VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create user profiles table
|
||||||
|
CREATE TABLE IF NOT EXISTS public.user_profiles (
|
||||||
|
id UUID REFERENCES auth.users(id) ON DELETE CASCADE PRIMARY KEY,
|
||||||
|
first_name VARCHAR(100),
|
||||||
|
last_name VARCHAR(100),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create user roles junction table
|
||||||
|
CREATE TABLE IF NOT EXISTS public.user_roles (
|
||||||
|
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
role_id UUID REFERENCES public.roles(id) ON DELETE CASCADE,
|
||||||
|
assigned_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
assigned_by UUID REFERENCES auth.users(id),
|
||||||
|
UNIQUE(user_id, role_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Insert default roles
|
||||||
|
INSERT INTO public.roles (name, description) VALUES
|
||||||
|
('admin', 'Administrator with full system access'),
|
||||||
|
('user', 'Regular user with basic access'),
|
||||||
|
('moderator', 'Moderator with limited administrative access')
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
-- Enable RLS on all tables
|
||||||
|
ALTER TABLE public.roles ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.user_profiles ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.user_roles ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Roles table policies
|
||||||
|
CREATE POLICY "Anyone can view roles" ON public.roles FOR SELECT USING (true);
|
||||||
|
|
||||||
|
CREATE POLICY "Only admins can manage roles" ON public.roles FOR ALL USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.user_roles ur
|
||||||
|
JOIN public.roles r ON ur.role_id = r.id
|
||||||
|
WHERE ur.user_id = auth.uid() AND r.name = 'admin'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- User profiles policies
|
||||||
|
CREATE POLICY "Users can view their own profile" ON public.user_profiles FOR SELECT USING (auth.uid() = id);
|
||||||
|
|
||||||
|
CREATE POLICY "Users can update their own profile" ON public.user_profiles FOR UPDATE USING (auth.uid() = id);
|
||||||
|
|
||||||
|
CREATE POLICY "Users can insert their own profile" ON public.user_profiles FOR INSERT WITH CHECK (auth.uid() = id);
|
||||||
|
|
||||||
|
CREATE POLICY "Admins can view all profiles" ON public.user_profiles FOR SELECT USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.user_roles ur
|
||||||
|
JOIN public.roles r ON ur.role_id = r.id
|
||||||
|
WHERE ur.user_id = auth.uid() AND r.name = 'admin'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- User roles policies
|
||||||
|
CREATE POLICY "Users can view their own roles" ON public.user_roles FOR SELECT USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
CREATE POLICY "Admins can manage all user roles" ON public.user_roles FOR ALL USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.user_roles ur
|
||||||
|
JOIN public.roles r ON ur.role_id = r.id
|
||||||
|
WHERE ur.user_id = auth.uid() AND r.name = 'admin'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Function to get user roles
|
||||||
|
CREATE OR REPLACE FUNCTION get_user_roles(user_uuid UUID)
|
||||||
|
RETURNS TABLE(role_name VARCHAR(50))
|
||||||
|
LANGUAGE sql
|
||||||
|
SECURITY DEFINER
|
||||||
|
AS $$
|
||||||
|
SELECT r.name
|
||||||
|
FROM public.user_roles ur
|
||||||
|
JOIN public.roles r ON ur.role_id = r.id
|
||||||
|
WHERE ur.user_id = user_uuid;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Function to check if user has specific role
|
||||||
|
CREATE OR REPLACE FUNCTION user_has_role(user_uuid UUID, role_name VARCHAR(50))
|
||||||
|
RETURNS BOOLEAN
|
||||||
|
LANGUAGE sql
|
||||||
|
SECURITY DEFINER
|
||||||
|
AS $$
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM public.user_roles ur
|
||||||
|
JOIN public.roles r ON ur.role_id = r.id
|
||||||
|
WHERE ur.user_id = user_uuid AND r.name = role_name
|
||||||
|
);
|
||||||
|
$$;
|
||||||
Reference in New Issue
Block a user