RBAC in place. Tailwind CSS working.

This commit is contained in:
Alireza Vaezi
2025-07-17 12:10:23 -04:00
parent 5fc7c89219
commit 90d874b15f
11 changed files with 1118 additions and 95 deletions

View File

@@ -1,34 +1,126 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
import React from 'react'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import { LoginForm } from './components/auth/LoginForm'
import { UserProfile } from './components/auth/UserProfile'
import { ProtectedRoute, AdminOnly, ModeratorOrAdmin, AuthenticatedOnly } from './components/auth/ProtectedRoute'
function App() {
const [count, setCount] = useState(0)
const AppContent: React.FC = () => {
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 (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
<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>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* User Profile Section */}
<div>
<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>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
</div>
)
}
function App() {
return (
<AuthProvider>
<AppContent />
</AuthProvider>
)
}

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

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

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

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

View File

@@ -1,68 +1 @@
:root {
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;
}
}
@import "tailwindcss";

107
src/lib/supabase.ts Normal file
View 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
View 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'