Authentication & Authorization
Client Portal uses Supabase Auth for identity management and PostgreSQL Row Level Security (RLS) for authorization. Authentication is email/password based. Authorization is role-based with three tiers.
Authentication
Supabase Auth handles all authentication flows:
- Sign up -- Email + password, creates entry in
auth.users - Sign in -- Returns a JWT containing the user's
sub(user ID) - Password recovery -- Email-based reset flow
- Session management -- JWTs are refreshed automatically by the Supabase client
The JWT is attached to every Supabase request (queries, mutations, edge function calls) and is used by RLS policies to determine access.
Role Model
Three roles exist, stored in the user_roles table:
| Role | Type | Description |
|---|---|---|
admin | Internal | Full platform access. Can manage all clients, users, campaigns, and settings. |
bdr | Internal | Business Development Representative. Access to assigned clients and all sales tables. |
client | External | Client user. Access restricted to their own organization's data only. |
Role Hierarchy
Admins can do everything BDRs and clients can do. BDRs and clients have no overlap -- they access different parts of the application.
Permissions Matrix
| Resource | Admin | BDR | Client |
|---|---|---|---|
| All clients list | Read/Write | Read (assigned) | -- |
| Client data | Read/Write | Read/Write (assigned) | Read (own) |
| Campaigns | Read/Write | Read/Write (assigned) | Read (own) |
| Contacts | Read/Write | Read/Write (assigned) | Read (own) |
| Calls | Read/Write | Read/Write | Read (own) |
| Meetings | Read/Write | Read/Write | Read/Write (own) |
| Markets | Read/Write | Read | Read (own) |
| Signals | Read/Write/Authorize | Read | Read (own) |
| Attorneys | Read/Write | Read | -- |
| Firms | Read/Write | Read | -- |
| Sales prospects | Read/Write | Read/Write | -- |
| Sales calls | Read/Write | Read/Write | -- |
| Users management | Read/Write | -- | -- |
| Booking links | Read/Write | Read/Write | Read/Write (own) |
RLS Helper Functions
Three SQL functions are used across all RLS policies:
has_role(role text)
Checks if the current authenticated user has the specified role.
CREATE OR REPLACE FUNCTION has_role(role text)
RETURNS boolean AS $$
SELECT EXISTS (
SELECT 1 FROM user_roles
WHERE user_id = auth.uid()
AND role = $1
);
$$ LANGUAGE sql SECURITY DEFINER STABLE;
get_user_client_id()
Returns the client_id from user_profiles for the current user. Returns NULL for internal users.
CREATE OR REPLACE FUNCTION get_user_client_id()
RETURNS uuid AS $$
SELECT client_id FROM user_profiles
WHERE user_id = auth.uid();
$$ LANGUAGE sql SECURITY DEFINER STABLE;
is_internal_user()
Returns true if the current user has either the admin or bdr role.
CREATE OR REPLACE FUNCTION is_internal_user()
RETURNS boolean AS $$
SELECT EXISTS (
SELECT 1 FROM user_roles
WHERE user_id = auth.uid()
AND role IN ('admin', 'bdr')
);
$$ LANGUAGE sql SECURITY DEFINER STABLE;
All helper functions use SECURITY DEFINER so they can read user_roles and user_profiles regardless of the calling user's RLS permissions on those tables.
Frontend Route Protection
Route protection is implemented with three nested components:
ProtectedRoute
Ensures the user is authenticated. Redirects to /auth if not.
<ProtectedRoute>
{/* Only authenticated users reach here */}
</ProtectedRoute>
RoleGuard
Checks the user's role against an allowed list. Renders a "not authorized" message if the role does not match.
<RoleGuard allowedRoles={['admin', 'bdr']}>
{/* Only admin and bdr users reach here */}
</RoleGuard>
Specialized Guards
AdminGuard-- Shorthand forallowedRoles={['admin']}WorkspaceGuard-- Ensures the user has aclient_idand is accessing their own workspace
Route Structure
Auth Context Hook
The useAuth hook provides all authentication state and helpers:
import { useAuth } from '@/hooks/useAuth';
const {
user, // Supabase User object (id, email, etc.)
profile, // user_profiles row (display_name, client_id, timezone, etc.)
roles, // string[] of role names (e.g., ['admin'])
clientId, // UUID | null -- the user's client_id from user_profiles
isInternalUser, // boolean -- true if admin or bdr
signOut, // () => Promise<void>
hasRole, // (role: string) => boolean
} = useAuth();
Usage Examples
// Check if user is admin
if (hasRole('admin')) {
// Show admin-only UI
}
// Get client-scoped data
const { data } = useQuery({
queryKey: ['campaigns', clientId],
queryFn: () => supabase.from('campaigns')
.select('*')
.eq('client_id', clientId),
enabled: !!clientId,
});
// Conditional rendering based on role
{isInternalUser && <AdminSidebar />}
Security Notes
- RLS is enabled on every table. There are no tables without RLS.
- The Supabase anon key is public (embedded in the frontend bundle). Security relies entirely on RLS policies.
- Edge functions that accept external webhooks have
verify_jwt = falseinconfig.toml. They validate payloads through other means (e.g., checkingexternal_call_idfor deduplication). - The
SUPABASE_SERVICE_ROLE_KEYis a secret and must never be exposed to the frontend. It bypasses all RLS and is used only in edge functions and server-side scripts.