SaaS Multi-Tenancy Development Guide
Status: Complete
Purpose: Comprehensive development guideline for building multi-tenant SaaS platforms
Critical: Prevents single-user architecture gaps and ensures proper SaaS account management
Why This Guide Is Critical
Build-v1 Lesson: NudgeCampaign was built as a single-user application with hardcoded DEMO_USER_ID instead of a proper multi-tenant SaaS platform. This guide ensures SaaS products implement proper multi-tenancy, user management, and account lifecycle from the start.
The Multi-Tenancy Gap: Technical teams often build single-user prototypes without considering the architectural requirements for serving multiple customers securely and scalably. This guide prevents the single-user vs. multi-tenant architecture mismatch.
Multi-Tenant Architecture Foundation
1. Database Architecture with Row Level Security (RLS)
Core Principle: Every data row must be associated with a user/tenant and isolated through database-level security policies.
Supabase Multi-Tenant Database Design
-- Users are managed by Supabase Auth (auth.users table)
-- All application data tables must include user_id foreign key
-- Example: Campaigns table with RLS
CREATE TABLE campaigns (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
status TEXT DEFAULT 'draft',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Enable Row Level Security
ALTER TABLE campaigns ENABLE ROW LEVEL SECURITY;
-- RLS Policy: Users can only access their own data
CREATE POLICY "Users can only access their own campaigns"
ON campaigns FOR ALL
USING (auth.uid() = user_id);
-- Insert policy for new records
CREATE POLICY "Users can insert campaigns for themselves"
ON campaigns FOR INSERT
WITH CHECK (auth.uid() = user_id);
Complete Multi-Tenant Schema Pattern
-- Core business entities with user isolation
CREATE TABLE contacts (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
email TEXT NOT NULL,
first_name TEXT,
last_name TEXT,
company TEXT,
tags TEXT[],
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE automations (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
n8n_workflow_id TEXT,
trigger_type TEXT,
is_active BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE email_sends (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
campaign_id UUID REFERENCES campaigns(id) ON DELETE CASCADE,
contact_id UUID REFERENCES contacts(id) ON DELETE CASCADE,
postmark_message_id TEXT,
status TEXT DEFAULT 'pending',
sent_at TIMESTAMPTZ,
opened_at TIMESTAMPTZ,
clicked_at TIMESTAMPTZ,
bounced_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Apply RLS to all tables
ALTER TABLE contacts ENABLE ROW LEVEL SECURITY;
ALTER TABLE automations ENABLE ROW LEVEL SECURITY;
ALTER TABLE email_sends ENABLE ROW LEVEL SECURITY;
-- RLS Policies for all tables
CREATE POLICY "contacts_user_isolation" ON contacts FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "automations_user_isolation" ON automations FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "email_sends_user_isolation" ON email_sends FOR ALL USING (auth.uid() = user_id);
User Profile and Subscription Management
-- Extended user profiles (beyond Supabase Auth)
CREATE TABLE user_profiles (
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE PRIMARY KEY,
company_name TEXT,
website TEXT,
industry TEXT,
team_size TEXT,
plan_type TEXT DEFAULT 'trial',
subscription_status TEXT DEFAULT 'active',
trial_ends_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Usage tracking for billing and limits
CREATE TABLE usage_metrics (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
metric_type TEXT NOT NULL, -- 'emails_sent', 'contacts_stored', etc.
metric_value INTEGER NOT NULL,
period_start TIMESTAMPTZ NOT NULL,
period_end TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Plan limits and restrictions
CREATE TABLE plan_limits (
plan_type TEXT PRIMARY KEY,
max_contacts INTEGER,
max_emails_per_month INTEGER,
max_automations INTEGER,
features JSONB,
price_cents INTEGER,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Insert plan definitions
INSERT INTO plan_limits (plan_type, max_contacts, max_emails_per_month, max_automations, features, price_cents) VALUES
('trial', 100, 500, 2, '{"advanced_analytics": false, "custom_domains": false, "api_access": false}', 0),
('professional', 10000, 10000, 10, '{"advanced_analytics": true, "custom_domains": false, "api_access": true}', 2900),
('enterprise', -1, -1, -1, '{"advanced_analytics": true, "custom_domains": true, "api_access": true, "priority_support": true}', 9900);
-- Apply RLS to user-specific tables
ALTER TABLE user_profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE usage_metrics ENABLE ROW LEVEL SECURITY;
CREATE POLICY "user_profiles_self_access" ON user_profiles FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "usage_metrics_user_access" ON usage_metrics FOR ALL USING (auth.uid() = user_id);
2. Authentication and Session Management
Next.js 14 App Router with Supabase Auth
// middleware.ts - Protect routes and handle authentication
import { createMiddlewareClient } from '@supabase/auth-helpers-nextjs'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export async function middleware(req: NextRequest) {
const res = NextResponse.next()
const supabase = createMiddlewareClient({ req, res })
const {
data: { session },
} = await supabase.auth.getSession()
// Redirect to login if not authenticated and accessing protected routes
if (!session && req.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/auth/login', req.url))
}
// Redirect to dashboard if authenticated and accessing auth pages
if (session && req.nextUrl.pathname.startsWith('/auth/')) {
return NextResponse.redirect(new URL('/dashboard', req.url))
}
return res
}
export const config = {
matcher: ['/dashboard/:path*', '/auth/:path*', '/admin/:path*']
}
Authentication Context Provider
// contexts/AuthContext.tsx
'use client'
import { createContext, useContext, useEffect, useState } from 'react'
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'
import type { User, Session } from '@supabase/auth-helpers-nextjs'
interface AuthContextType {
user: User | null
session: Session | null
userProfile: UserProfile | null
loading: boolean
signOut: () => Promise<void>
refreshProfile: () => Promise<void>
}
interface UserProfile {
user_id: string
company_name?: string
plan_type: string
subscription_status: string
trial_ends_at?: string
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [session, setSession] = useState<Session | null>(null)
const [userProfile, setUserProfile] = useState<UserProfile | null>(null)
const [loading, setLoading] = useState(true)
const supabase = createClientComponentClient()
const fetchUserProfile = async (userId: string) => {
const { data, error } = await supabase
.from('user_profiles')
.select('*')
.eq('user_id', userId)
.single()
if (data) setUserProfile(data)
}
const refreshProfile = async () => {
if (user) {
await fetchUserProfile(user.id)
}
}
useEffect(() => {
const getSession = async () => {
const { data: { session } } = await supabase.auth.getSession()
setSession(session)
setUser(session?.user ?? null)
if (session?.user) {
await fetchUserProfile(session.user.id)
}
setLoading(false)
}
getSession()
const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event, session) => {
setSession(session)
setUser(session?.user ?? null)
if (session?.user) {
await fetchUserProfile(session.user.id)
} else {
setUserProfile(null)
}
setLoading(false)
}
)
return () => subscription.unsubscribe()
}, [])
const signOut = async () => {
await supabase.auth.signOut()
}
return (
<AuthContext.Provider value={{
user,
session,
userProfile,
loading,
signOut,
refreshProfile
}}>
{children}
</AuthContext.Provider>
)
}
export const useAuth = () => {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
User Lifecycle Management
1. Self-Signup Flow
Registration Component with Company Information
// components/auth/SignupForm.tsx
'use client'
import { useState } from 'react'
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'
import { useRouter } from 'next/navigation'
interface SignupFormData {
email: string
password: string
fullName: string
companyName: string
industry: string
teamSize: string
}
export function SignupForm() {
const [formData, setFormData] = useState<SignupFormData>({
email: '',
password: '',
fullName: '',
companyName: '',
industry: '',
teamSize: ''
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const supabase = createClientComponentClient()
const router = useRouter()
const handleSignup = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
try {
// Create auth user
const { data: authData, error: authError } = await supabase.auth.signUp({
email: formData.email,
password: formData.password,
options: {
data: {
full_name: formData.fullName,
},
emailRedirectTo: `${window.location.origin}/auth/verify-email`
}
})
if (authError) throw authError
if (authData.user) {
// Create user profile
const { error: profileError } = await supabase
.from('user_profiles')
.insert({
user_id: authData.user.id,
company_name: formData.companyName,
industry: formData.industry,
team_size: formData.teamSize,
plan_type: 'trial',
subscription_status: 'active',
trial_ends_at: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString() // 14 days
})
if (profileError) throw profileError
// Initialize usage metrics
await fetch('/api/users/initialize-usage', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: authData.user.id })
})
router.push('/auth/verify-email')
}
} catch (error: any) {
setError(error.message)
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSignup} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="email" className="block text-sm font-medium mb-2">
Email Address
</label>
<input
id="email"
type="email"
required
value={formData.email}
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label htmlFor="fullName" className="block text-sm font-medium mb-2">
Full Name
</label>
<input
id="fullName"
type="text"
required
value={formData.fullName}
onChange={(e) => setFormData(prev => ({ ...prev, fullName: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-2">
Password
</label>
<input
id="password"
type="password"
required
minLength={8}
value={formData.password}
onChange={(e) => setFormData(prev => ({ ...prev, password: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="companyName" className="block text-sm font-medium mb-2">
Company Name
</label>
<input
id="companyName"
type="text"
required
value={formData.companyName}
onChange={(e) => setFormData(prev => ({ ...prev, companyName: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label htmlFor="teamSize" className="block text-sm font-medium mb-2">
Team Size
</label>
<select
id="teamSize"
required
value={formData.teamSize}
onChange={(e) => setFormData(prev => ({ ...prev, teamSize: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
>
<option value="">Select team size</option>
<option value="1">Just me</option>
<option value="2-10">2-10 people</option>
<option value="11-50">11-50 people</option>
<option value="51-200">51-200 people</option>
<option value="200+">200+ people</option>
</select>
</div>
</div>
<div>
<label htmlFor="industry" className="block text-sm font-medium mb-2">
Industry
</label>
<select
id="industry"
required
value={formData.industry}
onChange={(e) => setFormData(prev => ({ ...prev, industry: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
>
<option value="">Select industry</option>
<option value="technology">Technology</option>
<option value="marketing">Marketing & Advertising</option>
<option value="ecommerce">E-commerce</option>
<option value="healthcare">Healthcare</option>
<option value="education">Education</option>
<option value="finance">Finance</option>
<option value="other">Other</option>
</select>
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-md">
<p className="text-red-600 text-sm">{error}</p>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Creating Account...' : 'Start Free Trial'}
</button>
</form>
)
}
Email Verification and Onboarding
// app/auth/verify-email/page.tsx
export default function VerifyEmailPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8">
<div className="text-center">
<h2 className="text-3xl font-bold text-gray-900">Check your email</h2>
<p className="mt-2 text-gray-600">
We've sent a verification link to your email address. Click the link to activate your account and complete your setup.
</p>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex">
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800">
What happens next?
</h3>
<div className="mt-2 text-sm text-blue-700">
<ul className="list-disc pl-5 space-y-1">
<li>Verify your email address</li>
<li>Complete your account setup</li>
<li>Take a quick product tour</li>
<li>Create your first campaign</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
2. Account Management Dashboard
Account Settings Component
// components/dashboard/AccountSettings.tsx
'use client'
import { useState, useEffect } from 'react'
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'
import { useAuth } from '@/contexts/AuthContext'
interface AccountFormData {
fullName: string
companyName: string
website: string
industry: string
teamSize: string
}
export function AccountSettings() {
const { user, userProfile, refreshProfile } = useAuth()
const [formData, setFormData] = useState<AccountFormData>({
fullName: '',
companyName: '',
website: '',
industry: '',
teamSize: ''
})
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null)
const supabase = createClientComponentClient()
useEffect(() => {
if (user && userProfile) {
setFormData({
fullName: user.user_metadata?.full_name || '',
companyName: userProfile.company_name || '',
website: userProfile.website || '',
industry: userProfile.industry || '',
teamSize: userProfile.team_size || ''
})
}
}, [user, userProfile])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setMessage(null)
try {
// Update auth user metadata
const { error: authError } = await supabase.auth.updateUser({
data: { full_name: formData.fullName }
})
if (authError) throw authError
// Update user profile
const { error: profileError } = await supabase
.from('user_profiles')
.update({
company_name: formData.companyName,
website: formData.website,
industry: formData.industry,
team_size: formData.teamSize,
updated_at: new Date().toISOString()
})
.eq('user_id', user?.id)
if (profileError) throw profileError
await refreshProfile()
setMessage({ type: 'success', text: 'Account updated successfully!' })
} catch (error: any) {
setMessage({ type: 'error', text: error.message })
} finally {
setLoading(false)
}
}
return (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
Account Settings
</h3>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="fullName" className="block text-sm font-medium text-gray-700">
Full Name
</label>
<input
type="text"
id="fullName"
value={formData.fullName}
onChange={(e) => setFormData(prev => ({ ...prev, fullName: e.target.value }))}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm"
/>
</div>
<div>
<label htmlFor="companyName" className="block text-sm font-medium text-gray-700">
Company Name
</label>
<input
type="text"
id="companyName"
value={formData.companyName}
onChange={(e) => setFormData(prev => ({ ...prev, companyName: e.target.value }))}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm"
/>
</div>
</div>
<div>
<label htmlFor="website" className="block text-sm font-medium text-gray-700">
Website
</label>
<input
type="url"
id="website"
value={formData.website}
onChange={(e) => setFormData(prev => ({ ...prev, website: e.target.value }))}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm"
placeholder="https://www.example.com"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="industry" className="block text-sm font-medium text-gray-700">
Industry
</label>
<select
id="industry"
value={formData.industry}
onChange={(e) => setFormData(prev => ({ ...prev, industry: e.target.value }))}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm"
>
<option value="">Select industry</option>
<option value="technology">Technology</option>
<option value="marketing">Marketing & Advertising</option>
<option value="ecommerce">E-commerce</option>
<option value="healthcare">Healthcare</option>
<option value="education">Education</option>
<option value="finance">Finance</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label htmlFor="teamSize" className="block text-sm font-medium text-gray-700">
Team Size
</label>
<select
id="teamSize"
value={formData.teamSize}
onChange={(e) => setFormData(prev => ({ ...prev, teamSize: e.target.value }))}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm"
>
<option value="">Select team size</option>
<option value="1">Just me</option>
<option value="2-10">2-10 people</option>
<option value="11-50">11-50 people</option>
<option value="51-200">51-200 people</option>
<option value="200+">200+ people</option>
</select>
</div>
</div>
{message && (
<div className={`p-3 rounded-md ${
message.type === 'success'
? 'bg-green-50 border border-green-200'
: 'bg-red-50 border border-red-200'
}`}>
<p className={`text-sm ${
message.type === 'success' ? 'text-green-600' : 'text-red-600'
}`}>
{message.text}
</p>
</div>
)}
<div className="flex justify-end">
<button
type="submit"
disabled={loading}
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
</div>
)
}
3. Subscription Status Management
Subscription Dashboard Component
// components/dashboard/SubscriptionDashboard.tsx
'use client'
import { useState, useEffect } from 'react'
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'
import { useAuth } from '@/contexts/AuthContext'
interface UsageData {
contacts_stored: number
emails_sent_this_month: number
automations_active: number
}
interface PlanLimits {
max_contacts: number
max_emails_per_month: number
max_automations: number
features: Record<string, boolean>
price_cents: number
}
export function SubscriptionDashboard() {
const { userProfile } = useAuth()
const [usage, setUsage] = useState<UsageData | null>(null)
const [planLimits, setPlanLimits] = useState<PlanLimits | null>(null)
const [loading, setLoading] = useState(true)
const supabase = createClientComponentClient()
useEffect(() => {
const fetchData = async () => {
if (!userProfile) return
try {
// Fetch current usage
const { data: usageData } = await supabase
.from('usage_metrics')
.select('metric_type, metric_value')
.eq('user_id', userProfile.user_id)
.gte('period_start', new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString())
// Fetch plan limits
const { data: planData } = await supabase
.from('plan_limits')
.select('*')
.eq('plan_type', userProfile.plan_type)
.single()
// Process usage data
const usageMap = usageData?.reduce((acc, item) => {
acc[item.metric_type] = item.metric_value
return acc
}, {} as Record<string, number>) || {}
setUsage({
contacts_stored: usageMap.contacts_stored || 0,
emails_sent_this_month: usageMap.emails_sent || 0,
automations_active: usageMap.automations_active || 0
})
setPlanLimits(planData)
} catch (error) {
console.error('Error fetching subscription data:', error)
} finally {
setLoading(false)
}
}
fetchData()
}, [userProfile])
if (loading) {
return <div className="animate-pulse bg-gray-200 rounded-lg h-64"></div>
}
const formatPrice = (cents: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(cents / 100)
}
const getUsagePercentage = (current: number, limit: number) => {
if (limit === -1) return 0 // Unlimited
return Math.min((current / limit) * 100, 100)
}
const isTrialExpiring = userProfile?.trial_ends_at &&
new Date(userProfile.trial_ends_at) < new Date(Date.now() + 3 * 24 * 60 * 60 * 1000)
return (
<div className="space-y-6">
{/* Plan Status Card */}
<div className="bg-white shadow rounded-lg p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-medium text-gray-900 capitalize">
{userProfile?.plan_type} Plan
</h3>
<p className="text-sm text-gray-500">
{planLimits && planLimits.price_cents > 0
? `${formatPrice(planLimits.price_cents)}/month`
: 'Free Trial'
}
</p>
{userProfile?.plan_type === 'trial' && userProfile.trial_ends_at && (
<p className={`text-sm ${isTrialExpiring ? 'text-red-600' : 'text-gray-500'}`}>
Trial ends {new Date(userProfile.trial_ends_at).toLocaleDateString()}
</p>
)}
</div>
<div className="flex space-x-3">
{userProfile?.plan_type === 'trial' && (
<button className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700">
Upgrade Plan
</button>
)}
<button className="border border-gray-300 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-50">
Manage Billing
</button>
</div>
</div>
</div>
{/* Usage Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-white shadow rounded-lg p-6">
<div className="flex items-center">
<div className="flex-1">
<h4 className="text-sm font-medium text-gray-500">Contacts Stored</h4>
<p className="text-2xl font-bold text-gray-900">
{usage?.contacts_stored?.toLocaleString() || 0}
</p>
<p className="text-sm text-gray-500">
{planLimits?.max_contacts === -1
? 'Unlimited'
: `of ${planLimits?.max_contacts?.toLocaleString() || 0}`
}
</p>
</div>
</div>
{planLimits && planLimits.max_contacts !== -1 && (
<div className="mt-4">
<div className="bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full"
style={{
width: `${getUsagePercentage(usage?.contacts_stored || 0, planLimits.max_contacts)}%`
}}
></div>
</div>
</div>
)}
</div>
<div className="bg-white shadow rounded-lg p-6">
<div className="flex items-center">
<div className="flex-1">
<h4 className="text-sm font-medium text-gray-500">Emails This Month</h4>
<p className="text-2xl font-bold text-gray-900">
{usage?.emails_sent_this_month?.toLocaleString() || 0}
</p>
<p className="text-sm text-gray-500">
{planLimits?.max_emails_per_month === -1
? 'Unlimited'
: `of ${planLimits?.max_emails_per_month?.toLocaleString() || 0}`
}
</p>
</div>
</div>
{planLimits && planLimits.max_emails_per_month !== -1 && (
<div className="mt-4">
<div className="bg-gray-200 rounded-full h-2">
<div
className="bg-green-600 h-2 rounded-full"
style={{
width: `${getUsagePercentage(usage?.emails_sent_this_month || 0, planLimits.max_emails_per_month)}%`
}}
></div>
</div>
</div>
)}
</div>
<div className="bg-white shadow rounded-lg p-6">
<div className="flex items-center">
<div className="flex-1">
<h4 className="text-sm font-medium text-gray-500">Active Automations</h4>
<p className="text-2xl font-bold text-gray-900">
{usage?.automations_active || 0}
</p>
<p className="text-sm text-gray-500">
{planLimits?.max_automations === -1
? 'Unlimited'
: `of ${planLimits?.max_automations || 0}`
}
</p>
</div>
</div>
{planLimits && planLimits.max_automations !== -1 && (
<div className="mt-4">
<div className="bg-gray-200 rounded-full h-2">
<div
className="bg-purple-600 h-2 rounded-full"
style={{
width: `${getUsagePercentage(usage?.automations_active || 0, planLimits.max_automations)}%`
}}
></div>
</div>
</div>
)}
</div>
</div>
{/* Features List */}
{planLimits && (
<div className="bg-white shadow rounded-lg p-6">
<h4 className="text-lg font-medium text-gray-900 mb-4">Plan Features</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Object.entries(planLimits.features).map(([feature, enabled]) => (
<div key={feature} className="flex items-center">
<div className={`w-4 h-4 rounded-full mr-3 ${enabled ? 'bg-green-500' : 'bg-gray-300'}`}></div>
<span className={`text-sm ${enabled ? 'text-gray-900' : 'text-gray-500'}`}>
{feature.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
</span>
</div>
))}
</div>
</div>
)}
</div>
)
}
4. Account Deletion and GDPR Compliance
Account Deletion Component with Data Export
// components/dashboard/AccountDeletion.tsx
'use client'
import { useState } from 'react'
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'
import { useAuth } from '@/contexts/AuthContext'
import { useRouter } from 'next/navigation'
interface DataExportOptions {
contacts: boolean
campaigns: boolean
email_history: boolean
automations: boolean
}
export function AccountDeletion() {
const { user } = useAuth()
const [step, setStep] = useState<'options' | 'confirm' | 'processing'>('options')
const [exportOptions, setExportOptions] = useState<DataExportOptions>({
contacts: true,
campaigns: true,
email_history: false,
automations: true
})
const [confirmText, setConfirmText] = useState('')
const [loading, setLoading] = useState(false)
const supabase = createClientComponentClient()
const router = useRouter()
const handleDataExport = async () => {
setLoading(true)
try {
const response = await fetch('/api/users/export-data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(exportOptions)
})
if (response.ok) {
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `data-export-${new Date().toISOString().split('T')[0]}.zip`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
}
} catch (error) {
console.error('Export failed:', error)
} finally {
setLoading(false)
}
}
const handleAccountDeletion = async () => {
if (confirmText !== 'DELETE MY ACCOUNT') {
return
}
setStep('processing')
setLoading(true)
try {
// Request account deletion
const response = await fetch('/api/users/delete-account', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' }
})
if (response.ok) {
// Sign out and redirect
await supabase.auth.signOut()
router.push('/auth/account-deleted')
} else {
throw new Error('Account deletion failed')
}
} catch (error) {
console.error('Account deletion failed:', error)
setStep('confirm')
} finally {
setLoading(false)
}
}
if (step === 'processing') {
return (
<div className="bg-white shadow rounded-lg p-6">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-red-600 mx-auto mb-4"></div>
<h3 className="text-lg font-medium text-gray-900">Deleting Account...</h3>
<p className="text-gray-500">This may take a few moments</p>
</div>
</div>
)
}
return (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 text-red-600 mb-4">
Delete Account
</h3>
{step === 'options' && (
<div className="space-y-6">
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-4">
<div className="flex">
<div className="ml-3">
<h3 className="text-sm font-medium text-yellow-800">
Before you delete your account
</h3>
<div className="mt-2 text-sm text-yellow-700">
<p>
Account deletion is permanent and cannot be undone. We recommend exporting your data first.
</p>
</div>
</div>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-gray-900 mb-3">Export Your Data (GDPR Compliant)</h4>
<div className="space-y-2">
{Object.entries(exportOptions).map(([key, value]) => (
<label key={key} className="flex items-center">
<input
type="checkbox"
checked={value}
onChange={(e) => setExportOptions(prev => ({
...prev,
[key]: e.target.checked
}))}
className="mr-2"
/>
<span className="text-sm text-gray-700">
{key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
</span>
</label>
))}
</div>
<button
onClick={handleDataExport}
disabled={loading}
className="mt-3 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Exporting...' : 'Export My Data'}
</button>
</div>
<div className="border-t pt-6">
<button
onClick={() => setStep('confirm')}
className="bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700"
>
Continue to Account Deletion
</button>
</div>
</div>
)}
{step === 'confirm' && (
<div className="space-y-6">
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<div className="flex">
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">
This action cannot be undone
</h3>
<div className="mt-2 text-sm text-red-700">
<ul className="list-disc pl-5 space-y-1">
<li>Your account will be permanently deleted</li>
<li>All your campaigns, contacts, and automations will be removed</li>
<li>Your subscription will be cancelled immediately</li>
<li>You will not be able to recover this data</li>
</ul>
</div>
</div>
</div>
</div>
<div>
<label htmlFor="confirmText" className="block text-sm font-medium text-gray-700">
Type "DELETE MY ACCOUNT" to confirm
</label>
<input
type="text"
id="confirmText"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm"
placeholder="DELETE MY ACCOUNT"
/>
</div>
<div className="flex space-x-3">
<button
onClick={() => setStep('options')}
className="border border-gray-300 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={handleAccountDeletion}
disabled={confirmText !== 'DELETE MY ACCOUNT' || loading}
className="bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700 disabled:opacity-50"
>
{loading ? 'Deleting...' : 'Delete My Account'}
</button>
</div>
</div>
)}
</div>
</div>
)
}
π¨β Admin Management System
1. Admin Dashboard with User Management
Admin User List Component
// components/admin/UserManagement.tsx
'use client'
import { useState, useEffect } from 'react'
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'
interface AdminUser {
id: string
email: string
created_at: string
user_metadata: {
full_name?: string
}
profile: {
company_name?: string
plan_type: string
subscription_status: string
trial_ends_at?: string
}
}
export function UserManagement() {
const [users, setUsers] = useState<AdminUser[]>([])
const [loading, setLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [filterPlan, setFilterPlan] = useState<string>('all')
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null)
const supabase = createClientComponentClient()
useEffect(() => {
fetchUsers()
}, [])
const fetchUsers = async () => {
try {
// Note: This would typically require admin-level RLS policies or a separate admin API
const { data: profiles } = await supabase
.from('user_profiles')
.select(`
user_id,
company_name,
plan_type,
subscription_status,
trial_ends_at,
created_at
`)
.order('created_at', { ascending: false })
// Fetch auth users (would need admin privileges)
const response = await fetch('/api/admin/users')
const authUsers = await response.json()
// Combine auth and profile data
const combinedUsers = authUsers.map((authUser: any) => {
const profile = profiles?.find(p => p.user_id === authUser.id)
return {
...authUser,
profile: profile || {}
}
})
setUsers(combinedUsers)
} catch (error) {
console.error('Error fetching users:', error)
} finally {
setLoading(false)
}
}
const filteredUsers = users.filter(user => {
const matchesSearch = user.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.user_metadata?.full_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.profile?.company_name?.toLowerCase().includes(searchTerm.toLowerCase())
const matchesPlan = filterPlan === 'all' || user.profile?.plan_type === filterPlan
return matchesSearch && matchesPlan
})
const handleSuspendUser = async (userId: string) => {
try {
await fetch('/api/admin/users/suspend', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId })
})
await fetchUsers()
} catch (error) {
console.error('Error suspending user:', error)
}
}
const handleChangePlan = async (userId: string, newPlan: string) => {
try {
await fetch('/api/admin/users/change-plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, planType: newPlan })
})
await fetchUsers()
} catch (error) {
console.error('Error changing plan:', error)
}
}
if (loading) {
return <div className="animate-pulse bg-gray-200 rounded-lg h-96"></div>
}
return (
<div className="space-y-6">
{/* Filters */}
<div className="bg-white shadow rounded-lg p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="search" className="block text-sm font-medium text-gray-700">
Search Users
</label>
<input
type="text"
id="search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search by email, name, or company..."
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm"
/>
</div>
<div>
<label htmlFor="planFilter" className="block text-sm font-medium text-gray-700">
Filter by Plan
</label>
<select
id="planFilter"
value={filterPlan}
onChange={(e) => setFilterPlan(e.target.value)}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm"
>
<option value="all">All Plans</option>
<option value="trial">Trial</option>
<option value="professional">Professional</option>
<option value="enterprise">Enterprise</option>
</select>
</div>
</div>
</div>
{/* User Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="bg-white shadow rounded-lg p-6">
<div className="text-2xl font-bold text-gray-900">{users.length}</div>
<div className="text-sm text-gray-500">Total Users</div>
</div>
<div className="bg-white shadow rounded-lg p-6">
<div className="text-2xl font-bold text-blue-600">
{users.filter(u => u.profile?.plan_type === 'trial').length}
</div>
<div className="text-sm text-gray-500">Trial Users</div>
</div>
<div className="bg-white shadow rounded-lg p-6">
<div className="text-2xl font-bold text-green-600">
{users.filter(u => u.profile?.plan_type === 'professional').length}
</div>
<div className="text-sm text-gray-500">Professional</div>
</div>
<div className="bg-white shadow rounded-lg p-6">
<div className="text-2xl font-bold text-purple-600">
{users.filter(u => u.profile?.plan_type === 'enterprise').length}
</div>
<div className="text-sm text-gray-500">Enterprise</div>
</div>
</div>
{/* User Table */}
<div className="bg-white shadow rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Company
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Plan
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredUsers.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div>
<div className="text-sm font-medium text-gray-900">
{user.user_metadata?.full_name || 'N/A'}
</div>
<div className="text-sm text-gray-500">{user.email}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{user.profile?.company_name || 'N/A'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
user.profile?.plan_type === 'trial' ? 'bg-blue-100 text-blue-800' :
user.profile?.plan_type === 'professional' ? 'bg-green-100 text-green-800' :
user.profile?.plan_type === 'enterprise' ? 'bg-purple-100 text-purple-800' :
'bg-gray-100 text-gray-800'
}`}>
{user.profile?.plan_type || 'unknown'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
user.profile?.subscription_status === 'active' ? 'bg-green-100 text-green-800' :
user.profile?.subscription_status === 'suspended' ? 'bg-red-100 text-red-800' :
'bg-yellow-100 text-yellow-800'
}`}>
{user.profile?.subscription_status || 'unknown'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(user.created_at).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
<button
onClick={() => setSelectedUser(user)}
className="text-blue-600 hover:text-blue-900"
>
View
</button>
<button
onClick={() => handleSuspendUser(user.id)}
className="text-red-600 hover:text-red-900"
>
Suspend
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* User Detail Modal */}
{selectedUser && (
<UserDetailModal
user={selectedUser}
onClose={() => setSelectedUser(null)}
onChangePlan={handleChangePlan}
/>
)}
</div>
)
}
interface UserDetailModalProps {
user: AdminUser
onClose: () => void
onChangePlan: (userId: string, newPlan: string) => void
}
function UserDetailModal({ user, onClose, onChangePlan }: UserDetailModalProps) {
const [newPlan, setNewPlan] = useState(user.profile?.plan_type || '')
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full mx-4 max-h-96 overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900">User Details</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
β
</button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">Email</label>
<p className="text-sm text-gray-900">{user.email}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Full Name</label>
<p className="text-sm text-gray-900">{user.user_metadata?.full_name || 'N/A'}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">Company</label>
<p className="text-sm text-gray-900">{user.profile?.company_name || 'N/A'}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Status</label>
<p className="text-sm text-gray-900">{user.profile?.subscription_status || 'N/A'}</p>
</div>
</div>
<div>
<label htmlFor="planSelect" className="block text-sm font-medium text-gray-700">
Change Plan
</label>
<div className="mt-1 flex space-x-2">
<select
id="planSelect"
value={newPlan}
onChange={(e) => setNewPlan(e.target.value)}
className="block w-full border-gray-300 rounded-md shadow-sm"
>
<option value="trial">Trial</option>
<option value="professional">Professional</option>
<option value="enterprise">Enterprise</option>
</select>
<button
onClick={() => onChangePlan(user.id, newPlan)}
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700"
>
Update
</button>
</div>
</div>
</div>
</div>
</div>
)
}
2. Admin API Routes
Admin User Management API
// app/api/admin/users/route.ts
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'
import { cookies } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const supabase = createRouteHandlerClient({ cookies })
try {
// Verify admin access
const { data: { session } } = await supabase.auth.getSession()
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check if user is admin (you would implement admin role checking)
const { data: userProfile } = await supabase
.from('user_profiles')
.select('*')
.eq('user_id', session.user.id)
.single()
if (userProfile?.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
// Get all users (requires admin privileges)
// Note: This would typically use Supabase Admin API or RLS policies
const { data: users, error } = await supabase.auth.admin.listUsers()
if (error) throw error
return NextResponse.json(users.users)
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
}
User Suspension API
// app/api/admin/users/suspend/route.ts
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'
import { cookies } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const supabase = createRouteHandlerClient({ cookies })
try {
const { userId } = await request.json()
// Verify admin access
const { data: { session } } = await supabase.auth.getSession()
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Update user profile to suspended status
const { error } = await supabase
.from('user_profiles')
.update({
subscription_status: 'suspended',
updated_at: new Date().toISOString()
})
.eq('user_id', userId)
if (error) throw error
// Optionally disable the auth user
// await supabase.auth.admin.updateUserById(userId, { banned: true })
return NextResponse.json({ success: true })
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
}
Plan Change API
// app/api/admin/users/change-plan/route.ts
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'
import { cookies } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const supabase = createRouteHandlerClient({ cookies })
try {
const { userId, planType } = await request.json()
// Verify admin access
const { data: { session } } = await supabase.auth.getSession()
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Update user plan
const { error } = await supabase
.from('user_profiles')
.update({
plan_type: planType,
updated_at: new Date().toISOString()
})
.eq('user_id', userId)
if (error) throw error
// Log the plan change
await supabase
.from('admin_actions')
.insert({
admin_user_id: session.user.id,
target_user_id: userId,
action_type: 'plan_change',
action_details: { old_plan: '', new_plan: planType },
created_at: new Date().toISOString()
})
return NextResponse.json({ success: true })
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
}
Security & Compliance Implementation
1. Row Level Security (RLS) Policies
-- Comprehensive RLS policies for multi-tenant isolation
-- Enable RLS on all user tables
ALTER TABLE campaigns ENABLE ROW LEVEL SECURITY;
ALTER TABLE contacts ENABLE ROW LEVEL SECURITY;
ALTER TABLE automations ENABLE ROW LEVEL SECURITY;
ALTER TABLE email_sends ENABLE ROW LEVEL SECURITY;
ALTER TABLE user_profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE usage_metrics ENABLE ROW LEVEL SECURITY;
-- User isolation policies
CREATE POLICY "campaigns_user_isolation" ON campaigns
FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "contacts_user_isolation" ON contacts
FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "automations_user_isolation" ON automations
FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "email_sends_user_isolation" ON email_sends
FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "user_profiles_self_access" ON user_profiles
FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "usage_metrics_user_access" ON usage_metrics
FOR ALL USING (auth.uid() = user_id);
-- Admin access policies
CREATE POLICY "admin_full_access_campaigns" ON campaigns
FOR ALL USING (
EXISTS (
SELECT 1 FROM user_profiles
WHERE user_id = auth.uid()
AND role = 'admin'
)
);
CREATE POLICY "admin_full_access_contacts" ON contacts
FOR ALL USING (
EXISTS (
SELECT 1 FROM user_profiles
WHERE user_id = auth.uid()
AND role = 'admin'
)
);
-- Audit logging table with admin access
CREATE TABLE admin_actions (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
admin_user_id UUID REFERENCES auth.users(id),
target_user_id UUID REFERENCES auth.users(id),
action_type TEXT NOT NULL,
action_details JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
ALTER TABLE admin_actions ENABLE ROW LEVEL SECURITY;
CREATE POLICY "admin_actions_admin_only" ON admin_actions
FOR ALL USING (
EXISTS (
SELECT 1 FROM user_profiles
WHERE user_id = auth.uid()
AND role = 'admin'
)
);
2. Data Validation and Sanitization
// utils/validation.ts
import { z } from 'zod'
export const signupSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
fullName: z.string().min(1, 'Full name is required'),
companyName: z.string().min(1, 'Company name is required'),
industry: z.string().min(1, 'Industry is required'),
teamSize: z.string().min(1, 'Team size is required')
})
export const accountUpdateSchema = z.object({
fullName: z.string().min(1, 'Full name is required'),
companyName: z.string().min(1, 'Company name is required'),
website: z.string().url('Invalid website URL').optional().or(z.literal('')),
industry: z.string().min(1, 'Industry is required'),
teamSize: z.string().min(1, 'Team size is required')
})
export const campaignSchema = z.object({
name: z.string().min(1, 'Campaign name is required').max(100, 'Name too long'),
description: z.string().max(500, 'Description too long').optional(),
subject: z.string().min(1, 'Subject is required').max(200, 'Subject too long'),
content: z.string().min(1, 'Content is required'),
status: z.enum(['draft', 'scheduled', 'sent', 'paused'])
})
export const contactSchema = z.object({
email: z.string().email('Invalid email address'),
firstName: z.string().max(50, 'First name too long').optional(),
lastName: z.string().max(50, 'Last name too long').optional(),
company: z.string().max(100, 'Company name too long').optional(),
tags: z.array(z.string()).optional()
})
3. Usage Tracking and Limits Enforcement
// utils/usageTracking.ts
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'
export class UsageTracker {
private supabase = createClientComponentClient()
async checkContactLimit(userId: string): Promise<boolean> {
// Get user's plan limits
const { data: profile } = await this.supabase
.from('user_profiles')
.select('plan_type')
.eq('user_id', userId)
.single()
const { data: planLimits } = await this.supabase
.from('plan_limits')
.select('max_contacts')
.eq('plan_type', profile?.plan_type)
.single()
if (planLimits?.max_contacts === -1) return true // Unlimited
// Get current contact count
const { count } = await this.supabase
.from('contacts')
.select('*', { count: 'exact', head: true })
.eq('user_id', userId)
return (count || 0) < planLimits?.max_contacts
}
async checkEmailLimit(userId: string): Promise<boolean> {
const { data: profile } = await this.supabase
.from('user_profiles')
.select('plan_type')
.eq('user_id', userId)
.single()
const { data: planLimits } = await this.supabase
.from('plan_limits')
.select('max_emails_per_month')
.eq('plan_type', profile?.plan_type)
.single()
if (planLimits?.max_emails_per_month === -1) return true // Unlimited
// Get current month's email count
const startOfMonth = new Date(new Date().getFullYear(), new Date().getMonth(), 1)
const { data: usage } = await this.supabase
.from('usage_metrics')
.select('metric_value')
.eq('user_id', userId)
.eq('metric_type', 'emails_sent')
.gte('period_start', startOfMonth.toISOString())
.single()
return (usage?.metric_value || 0) < planLimits?.max_emails_per_month
}
async recordUsage(userId: string, metricType: string, value: number = 1) {
const now = new Date()
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0)
// Update or insert usage metric
const { error } = await this.supabase
.from('usage_metrics')
.upsert({
user_id: userId,
metric_type: metricType,
metric_value: value,
period_start: startOfMonth.toISOString(),
period_end: endOfMonth.toISOString()
}, {
onConflict: 'user_id,metric_type,period_start'
})
if (error) {
console.error('Error recording usage:', error)
}
}
}
export const usageTracker = new UsageTracker()
Business Model Integration
1. Subscription Tier Management
// components/billing/PlanSelector.tsx
'use client'
import { useState } from 'react'
import { useAuth } from '@/contexts/AuthContext'
interface PlanFeatures {
[key: string]: boolean
}
interface Plan {
type: string
name: string
price: number
features: PlanFeatures
maxContacts: number
maxEmails: number
maxAutomations: number
popular?: boolean
}
const plans: Plan[] = [
{
type: 'trial',
name: 'Free Trial',
price: 0,
maxContacts: 100,
maxEmails: 500,
maxAutomations: 2,
features: {
'Email campaigns': true,
'Basic automation': true,
'Contact management': true,
'Basic analytics': true,
'Email support': true,
'Advanced analytics': false,
'Custom domains': false,
'API access': false,
'Priority support': false
}
},
{
type: 'professional',
name: 'Professional',
price: 29,
maxContacts: 10000,
maxEmails: 10000,
maxAutomations: 10,
popular: true,
features: {
'Email campaigns': true,
'Advanced automation': true,
'Contact management': true,
'Advanced analytics': true,
'Email support': true,
'API access': true,
'Custom domains': false,
'Priority support': false
}
},
{
type: 'enterprise',
name: 'Enterprise',
price: 99,
maxContacts: -1, // Unlimited
maxEmails: -1, // Unlimited
maxAutomations: -1, // Unlimited
features: {
'Everything in Professional': true,
'Unlimited contacts': true,
'Unlimited emails': true,
'Custom domains': true,
'Priority support': true,
'Dedicated success manager': true,
'Custom integrations': true,
'SLA guarantee': true
}
}
]
export function PlanSelector() {
const { userProfile } = useAuth()
const [selectedPlan, setSelectedPlan] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const handleUpgrade = async (planType: string) => {
setLoading(true)
try {
// Integrate with Stripe or other payment processor
const response = await fetch('/api/billing/create-checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ planType })
})
const { checkoutUrl } = await response.json()
if (checkoutUrl) {
window.location.href = checkoutUrl
}
} catch (error) {
console.error('Error creating checkout:', error)
} finally {
setLoading(false)
}
}
return (
<div className="space-y-8">
<div className="text-center">
<h2 className="text-3xl font-bold text-gray-900">Choose Your Plan</h2>
<p className="mt-2 text-gray-600">
Start with a free trial, upgrade anytime
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{plans.map((plan) => (
<div
key={plan.type}
className={`relative rounded-lg border ${
plan.popular
? 'border-blue-500 shadow-lg'
: 'border-gray-200'
} p-6`}
>
{plan.popular && (
<div className="absolute -top-3 left-1/2 transform -translate-x-1/2">
<span className="bg-blue-500 text-white px-3 py-1 text-sm rounded-full">
Most Popular
</span>
</div>
)}
<div className="text-center">
<h3 className="text-xl font-semibold text-gray-900">{plan.name}</h3>
<div className="mt-2">
<span className="text-3xl font-bold text-gray-900">
${plan.price}
</span>
{plan.price > 0 && (
<span className="text-gray-500">/month</span>
)}
</div>
</div>
<div className="mt-6">
<div className="text-sm text-gray-600 space-y-1">
<div>
{plan.maxContacts === -1 ? 'Unlimited' : plan.maxContacts.toLocaleString()} contacts
</div>
<div>
{plan.maxEmails === -1 ? 'Unlimited' : plan.maxEmails.toLocaleString()} emails/month
</div>
<div>
{plan.maxAutomations === -1 ? 'Unlimited' : plan.maxAutomations} automations
</div>
</div>
</div>
<ul className="mt-6 space-y-3">
{Object.entries(plan.features).map(([feature, included]) => (
<li key={feature} className="flex items-center">
<div className={`w-4 h-4 rounded-full mr-3 ${
included ? 'bg-green-500' : 'bg-gray-300'
}`}></div>
<span className={`text-sm ${
included ? 'text-gray-900' : 'text-gray-500'
}`}>
{feature}
</span>
</li>
))}
</ul>
<div className="mt-8">
{userProfile?.plan_type === plan.type ? (
<div className="text-center py-2 text-green-600 font-medium">
Current Plan
</div>
) : (
<button
onClick={() => handleUpgrade(plan.type)}
disabled={loading}
className={`w-full py-2 px-4 rounded-md font-medium ${
plan.popular
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'border border-gray-300 text-gray-700 hover:bg-gray-50'
} disabled:opacity-50`}
>
{plan.type === 'trial' ? 'Start Free Trial' : 'Upgrade'}
</button>
)}
</div>
</div>
))}
</div>
</div>
)
}
2. Billing Integration with Stripe
// app/api/billing/create-checkout/route.ts
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'
import { cookies } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2023-10-16'
})
export async function POST(request: NextRequest) {
const supabase = createRouteHandlerClient({ cookies })
try {
const { planType } = await request.json()
const { data: { session } } = await supabase.auth.getSession()
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Get plan details
const { data: planLimits } = await supabase
.from('plan_limits')
.select('*')
.eq('plan_type', planType)
.single()
if (!planLimits) {
return NextResponse.json({ error: 'Plan not found' }, { status: 404 })
}
// Create Stripe checkout session
const checkoutSession = await stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
customer_email: session.user.email,
line_items: [
{
price_data: {
currency: 'usd',
product_data: {
name: `${planType.charAt(0).toUpperCase() + planType.slice(1)} Plan`,
},
unit_amount: planLimits.price_cents,
recurring: {
interval: 'month',
},
},
quantity: 1,
},
],
metadata: {
userId: session.user.id,
planType,
},
success_url: `${process.env.NEXT_PUBLIC_BASE_URL}/dashboard/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/dashboard/billing`,
})
return NextResponse.json({ checkoutUrl: checkoutSession.url })
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
}
Multi-Tenant Testing Strategies
1. RLS Policy Testing
// tests/rls-policies.test.ts
import { createClient } from '@supabase/supabase-js'
describe('Row Level Security Policies', () => {
let supabaseUser1: any
let supabaseUser2: any
let user1Id: string
let user2Id: string
beforeAll(async () => {
// Create test users
supabaseUser1 = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!)
supabaseUser2 = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!)
// Sign up test users
const { data: user1 } = await supabaseUser1.auth.signUp({
email: 'user1@test.com',
password: 'testpassword123'
})
const { data: user2 } = await supabaseUser2.auth.signUp({
email: 'user2@test.com',
password: 'testpassword123'
})
user1Id = user1.user!.id
user2Id = user2.user!.id
})
test('Users can only access their own campaigns', async () => {
// User 1 creates a campaign
const { data: campaign1 } = await supabaseUser1
.from('campaigns')
.insert({
user_id: user1Id,
name: 'User 1 Campaign',
description: 'Test campaign'
})
.select()
.single()
// User 2 creates a campaign
const { data: campaign2 } = await supabaseUser2
.from('campaigns')
.insert({
user_id: user2Id,
name: 'User 2 Campaign',
description: 'Test campaign'
})
.select()
.single()
// User 1 should only see their own campaigns
const { data: user1Campaigns } = await supabaseUser1
.from('campaigns')
.select('*')
expect(user1Campaigns).toHaveLength(1)
expect(user1Campaigns![0].user_id).toBe(user1Id)
// User 2 should only see their own campaigns
const { data: user2Campaigns } = await supabaseUser2
.from('campaigns')
.select('*')
expect(user2Campaigns).toHaveLength(1)
expect(user2Campaigns![0].user_id).toBe(user2Id)
})
test('Users cannot modify other users data', async () => {
// User 1 tries to update User 2's campaign (should fail)
const { error } = await supabaseUser1
.from('campaigns')
.update({ name: 'Hacked Campaign' })
.eq('user_id', user2Id)
expect(error).toBeDefined()
})
})
2. Usage Limits Testing
// tests/usage-limits.test.ts
import { usageTracker } from '@/utils/usageTracking'
describe('Usage Limits Enforcement', () => {
test('Trial users cannot exceed contact limits', async () => {
const trialUserId = 'test-trial-user-id'
// Mock trial user with 100 contact limit
jest.spyOn(usageTracker, 'checkContactLimit').mockResolvedValue(false)
const canAddContact = await usageTracker.checkContactLimit(trialUserId)
expect(canAddContact).toBe(false)
})
test('Professional users have higher limits', async () => {
const proUserId = 'test-pro-user-id'
jest.spyOn(usageTracker, 'checkContactLimit').mockResolvedValue(true)
const canAddContact = await usageTracker.checkContactLimit(proUserId)
expect(canAddContact).toBe(true)
})
test('Usage is properly recorded', async () => {
const userId = 'test-user-id'
const recordUsageSpy = jest.spyOn(usageTracker, 'recordUsage')
await usageTracker.recordUsage(userId, 'emails_sent', 1)
expect(recordUsageSpy).toHaveBeenCalledWith(userId, 'emails_sent', 1)
})
})
Implementation Integration with Framework
Framework Phase Integration
This guide integrates with the autonomous MVP framework phases as follows:
Phase 1: Pre-Build Assessment
- Business Model Compliance: Validate multi-tenancy requirements against business model
- Architecture Requirements: Ensure authentication and user isolation planned
Phase 1.5: Commercial SaaS Platform Validation
- User Lifecycle Management: Self-signup, account management, subscription status
- Legal Compliance: GDPR-compliant account deletion and data export
Phase 3: Design-First Feature Development
- Multi-Tenant Database: Implement RLS policies and user isolation
- Authentication Integration: Supabase Auth with Next.js middleware
Phase 4: Integration & Testing
- Multi-Tenant Testing: RLS policy validation and user isolation testing
- Usage Limits Testing: Subscription tier and billing validation
Critical Prevention Measures
This guide prevents the build-v1 multi-tenancy gaps by:
- Database Architecture: Proper RLS policies ensure user data isolation
- Authentication Flow: Complete signup-to-deletion user lifecycle
- Admin Management: Comprehensive admin tools for user and subscription management
- Business Integration: Usage tracking, billing, and subscription management
- Security Compliance: GDPR-compliant data handling and deletion
Success Criteria
A SaaS platform is properly multi-tenant when:
- Multiple users can sign up and use the platform independently
- User data is completely isolated at the database level
- Usage is tracked and enforced according to subscription tiers
- Admin tools allow comprehensive user and subscription management
- Account deletion is GDPR-compliant with proper data export
- All functionality works seamlessly across subscription tiers
This multi-tenancy development guide ensures SaaS platforms implement proper user isolation, account management, and business model integration from the start, preventing the single-user architecture gap that occurred in build-v1.