+ {/* Left side - Branding */}
+
+ {/* Gradient background */}
+
+
+ {/* Decorative elements */}
+
+
+
+
+ {/* Content */}
+
+
+ {/* Logo */}
+
+

+
+
+
+
+ Sauci
+
+
+ Admin Portal
+
+
+
+
+
+ Manage your content, monitor user engagement, and deliver exceptional experiences to couples everywhere.
+
-
Sauci Admin
-
- Sign in to access the admin dashboard
-
-
-
-
-
-
-
setEmail(e.target.value)}
- required
- disabled={loading}
- />
+ {/* Bottom decoration */}
+
+
+
+ {/* Right side - Login form */}
+
+
+ {/* Mobile logo */}
+
+

+
Sauci
+
Admin Portal
+
+
+ {/* Form card */}
+
+
+
+ Welcome Back
+
+
+ Sign in to continue to your dashboard
+
-
-
- setPassword(e.target.value)}
- required
- disabled={loading}
- />
-
-
-
-
-
-
+
+
+
+
+
+ setEmail(e.target.value)}
+ required
+ disabled={loading}
+ className="pl-11 h-12 bg-background/50 border-white/10 rounded-xl focus:border-primary/50 focus:ring-primary/20 transition-all"
+ />
+
+
+
+
+
+
+
+ setPassword(e.target.value)}
+ required
+ disabled={loading}
+ className="pl-11 h-12 bg-background/50 border-white/10 rounded-xl focus:border-primary/50 focus:ring-primary/20 transition-all"
+ />
+
+
+
+
+
+
+
+ {/* Footer */}
+
+ Sauci Admin Portal © {new Date().getFullYear()}
+
+
+
);
}
diff --git a/apps/admin/src/pages/UsageInsightsPage.tsx b/apps/admin/src/pages/UsageInsightsPage.tsx
new file mode 100644
index 0000000..056a9fe
--- /dev/null
+++ b/apps/admin/src/pages/UsageInsightsPage.tsx
@@ -0,0 +1,386 @@
+import { useState, useEffect } from 'react';
+import { supabase } from '@/config';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Target, Users, MessageSquare, Flame, Heart, Smile, Shield, TrendingUp, PieChart } from 'lucide-react';
+import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, Cell, Pie, PieChart as RechartsPieChart } from 'recharts';
+
+type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
+
+interface UsageReasonStat {
+ reason: UsageReason | null;
+ count: number;
+ percentage: number;
+}
+
+interface GenderStat {
+ gender: string | null;
+ count: number;
+ percentage: number;
+}
+
+const usageReasonConfig: Record
= {
+ improve_communication: {
+ label: 'Improve communication',
+ icon: ,
+ color: '#3b82f6',
+ },
+ spice_up_intimacy: {
+ label: 'Spice up intimacy',
+ icon: ,
+ color: '#ef4444',
+ },
+ deeper_connection: {
+ label: 'Build deeper connection',
+ icon: ,
+ color: '#ec4899',
+ },
+ have_fun: {
+ label: 'Have fun together',
+ icon: ,
+ color: '#f59e0b',
+ },
+ strengthen_relationship: {
+ label: 'Strengthen relationship',
+ icon: ,
+ color: '#10b981',
+ },
+};
+
+const genderConfig: Record = {
+ male: { label: 'Male', color: '#3b82f6' },
+ female: { label: 'Female', color: '#ec4899' },
+ 'non-binary': { label: 'Non-binary', color: '#8b5cf6' },
+ 'prefer-not-to-say': { label: 'Prefer not to say', color: '#6b7280' },
+};
+
+export function UsageInsightsPage() {
+ const [usageStats, setUsageStats] = useState([]);
+ const [genderStats, setGenderStats] = useState([]);
+ const [totalUsers, setTotalUsers] = useState(0);
+ const [onboardedUsers, setOnboardedUsers] = useState(0);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ const fetchStats = async () => {
+ try {
+ // Get total users count
+ const { count: total } = await supabase
+ .from('profiles')
+ .select('*', { count: 'exact', head: true });
+
+ setTotalUsers(total || 0);
+
+ // Get onboarded users count
+ const { count: onboarded } = await supabase
+ .from('profiles')
+ .select('*', { count: 'exact', head: true })
+ .eq('onboarding_completed', true);
+
+ setOnboardedUsers(onboarded || 0);
+
+ // Get usage reason breakdown
+ const { data: usageData } = await supabase
+ .from('profiles')
+ .select('usage_reason')
+ .not('usage_reason', 'is', null);
+
+ const usageCounts: Record = {};
+ usageData?.forEach(profile => {
+ const reason = profile.usage_reason as string;
+ usageCounts[reason] = (usageCounts[reason] || 0) + 1;
+ });
+
+ const totalWithReason = Object.values(usageCounts).reduce((a, b) => a + b, 0);
+ const usageStatsArray: UsageReasonStat[] = Object.entries(usageCounts)
+ .map(([reason, count]) => ({
+ reason: reason as UsageReason,
+ count,
+ percentage: totalWithReason > 0 ? Math.round((count / totalWithReason) * 100) : 0,
+ }))
+ .sort((a, b) => b.count - a.count);
+
+ setUsageStats(usageStatsArray);
+
+ // Get gender breakdown
+ const { data: genderData } = await supabase
+ .from('profiles')
+ .select('gender')
+ .not('gender', 'is', null);
+
+ const genderCounts: Record = {};
+ genderData?.forEach(profile => {
+ const gender = profile.gender as string;
+ genderCounts[gender] = (genderCounts[gender] || 0) + 1;
+ });
+
+ const totalWithGender = Object.values(genderCounts).reduce((a, b) => a + b, 0);
+ const genderStatsArray: GenderStat[] = Object.entries(genderCounts)
+ .map(([gender, count]) => ({
+ gender,
+ count,
+ percentage: totalWithGender > 0 ? Math.round((count / totalWithGender) * 100) : 0,
+ }))
+ .sort((a, b) => b.count - a.count);
+
+ setGenderStats(genderStatsArray);
+ } catch (error) {
+ console.error('Failed to fetch usage stats:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchStats();
+ }, []);
+
+ const chartData = usageStats.map(stat => ({
+ name: stat.reason ? usageReasonConfig[stat.reason]?.label || stat.reason : 'Unknown',
+ value: stat.count,
+ color: stat.reason ? usageReasonConfig[stat.reason]?.color : '#6b7280',
+ }));
+
+ const pieData = genderStats.map(stat => ({
+ name: stat.gender ? genderConfig[stat.gender]?.label || stat.gender : 'Unknown',
+ value: stat.count,
+ color: stat.gender ? genderConfig[stat.gender]?.color : '#6b7280',
+ }));
+
+ return (
+
+
+
+
+ Usage Insights
+
+
+ Understand why users are using Sauci based on onboarding responses
+
+
+
+ {/* Quick Stats */}
+
+
+
+ Total Users
+
+
+
+ {loading ? (
+
+ ) : (
+ {totalUsers.toLocaleString()}
+ )}
+
+
+
+
+
+ Completed Onboarding
+
+
+
+ {loading ? (
+
+ ) : (
+ <>
+ {onboardedUsers.toLocaleString()}
+
+ {totalUsers > 0 ? Math.round((onboardedUsers / totalUsers) * 100) : 0}% of total users
+
+ >
+ )}
+
+
+
+
+
+ Response Rate
+
+
+
+ {loading ? (
+
+ ) : (
+ <>
+
+ {usageStats.reduce((sum, s) => sum + s.count, 0).toLocaleString()}
+
+
+ users provided usage reason
+
+ >
+ )}
+
+
+
+
+
+ {/* Usage Reasons Chart */}
+
+
+
+
+ Why Users Join Sauci
+
+
+ Distribution of user motivations from onboarding
+
+
+
+ {loading ? (
+
+ ) : usageStats.length === 0 ? (
+
+ No usage data available yet
+
+ ) : (
+
+
+
+
+
+
+
+ {chartData.map((entry, index) => (
+ |
+ ))}
+
+
+
+
+ )}
+
+
+
+ {/* Gender Distribution */}
+
+
+
+
+ Gender Distribution
+
+
+ User demographics from onboarding
+
+
+
+ {loading ? (
+
+ ) : genderStats.length === 0 ? (
+
+ No gender data available yet
+
+ ) : (
+
+
+
+
+ `${name}: ${((percent ?? 0) * 100).toFixed(0)}%`
+ }
+ >
+ {pieData.map((entry, index) => (
+ |
+ ))}
+
+
+
+
+
+ )}
+
+
+
+
+ {/* Detailed Breakdown */}
+
+
+ Detailed Breakdown
+
+ Individual statistics for each usage reason
+
+
+
+ {loading ? (
+
+ {[1, 2, 3, 4, 5].map(i => (
+
+ ))}
+
+ ) : usageStats.length === 0 ? (
+
+ No usage data available yet
+
+ ) : (
+
+ {usageStats.map(stat => {
+ const config = stat.reason ? usageReasonConfig[stat.reason] : null;
+ return (
+
+
+
+
{config?.label || stat.reason}
+
+
+
+ {stat.percentage}%
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/admin/src/pages/admins/AdminsPage.tsx b/apps/admin/src/pages/admins/AdminsPage.tsx
index 4dc27f3..03076f0 100644
--- a/apps/admin/src/pages/admins/AdminsPage.tsx
+++ b/apps/admin/src/pages/admins/AdminsPage.tsx
@@ -6,6 +6,7 @@ import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
+import { Checkbox } from '@/components/ui/checkbox';
import {
Table,
TableBody,
@@ -23,23 +24,18 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
-import { Search, Shield, Trash2, UserPlus } from 'lucide-react';
+import { Separator } from "@/components/ui/separator";
+import { Search, Shield, Trash2, UserPlus, Settings } from 'lucide-react';
import { format } from 'date-fns';
-import { useAuth, AdminRole } from '@/contexts/AuthContext';
+import { useAuth, AdminRole, PERMISSION_METADATA, PermissionKey } from '@/contexts/AuthContext';
import { toast } from 'sonner';
interface AdminUser {
id: string; // admin_users PK
user_id: string;
role: AdminRole;
+ permissions: string[];
created_at: string;
profile?: {
name: string | null;
@@ -67,9 +63,16 @@ export function AdminsPage() {
const [searchResults, setSearchResults] = useState([]);
const [searchingUsers, setSearchingUsers] = useState(false);
const [selectedUser, setSelectedUser] = useState(null);
- const [selectedRole, setSelectedRole] = useState('pack_creator');
+ const [isSuperAdminChecked, setIsSuperAdminChecked] = useState(false);
+ const [selectedPermissions, setSelectedPermissions] = useState([]);
const [isSubmitting, setIsSubmitting] = useState(false);
+ // Edit Permissions State
+ const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
+ const [editingAdmin, setEditingAdmin] = useState(null);
+ const [editIsSuperAdmin, setEditIsSuperAdmin] = useState(false);
+ const [editPermissions, setEditPermissions] = useState([]);
+
// Initial load
useEffect(() => {
fetchAdmins();
@@ -113,10 +116,10 @@ export function AdminsPage() {
const fetchAdmins = async () => {
setLoading(true);
try {
- // First fetch admin users
+ // First fetch admin users with permissions
const { data: adminData, error: adminError } = await supabase
.from('admin_users')
- .select('*')
+ .select('id, user_id, role, permissions, created_at')
.order('created_at', { ascending: false });
if (adminError) throw adminError;
@@ -135,6 +138,7 @@ export function AdminsPage() {
// Merge data
const joinedAdmins = adminData.map(admin => ({
...admin,
+ permissions: (admin.permissions as string[]) || [],
profile: profileData?.find(p => p.id === admin.user_id)
}));
setAdmins(joinedAdmins);
@@ -154,18 +158,25 @@ export function AdminsPage() {
setIsSubmitting(true);
try {
+ const role: AdminRole = isSuperAdminChecked ? 'super_admin' : 'pack_creator';
+ const permissions = isSuperAdminChecked ? [] : selectedPermissions;
+
const { error } = await auditedSupabase.insert('admin_users', {
user_id: selectedUser.id,
- role: selectedRole
+ role,
+ permissions
});
if (error) throw error;
- toast.success(`${selectedUser.name || selectedUser.email} added as ${selectedRole.replace('_', ' ')}`);
+ const roleLabel = isSuperAdminChecked ? 'Super Admin' : 'Admin';
+ toast.success(`${selectedUser.name || selectedUser.email} added as ${roleLabel}`);
setIsAddDialogOpen(false);
setSelectedUser(null);
setUserSearch('');
+ setIsSuperAdminChecked(false);
+ setSelectedPermissions([]);
fetchAdmins();
} catch (error: any) {
console.error('Failed to add admin:', error);
@@ -175,6 +186,57 @@ export function AdminsPage() {
}
};
+ const handleEditPermissions = (admin: AdminUser) => {
+ setEditingAdmin(admin);
+ setEditIsSuperAdmin(admin.role === 'super_admin');
+ setEditPermissions(admin.permissions || []);
+ setIsEditDialogOpen(true);
+ };
+
+ const handleSavePermissions = async () => {
+ if (!editingAdmin) return;
+
+ setIsSubmitting(true);
+ try {
+ const role: AdminRole = editIsSuperAdmin ? 'super_admin' : 'pack_creator';
+ const permissions = editIsSuperAdmin ? [] : editPermissions;
+
+ const { error } = await auditedSupabase.update('admin_users', editingAdmin.id, {
+ role,
+ permissions
+ });
+
+ if (error) throw error;
+
+ toast.success("Permissions updated successfully");
+
+ setIsEditDialogOpen(false);
+ setEditingAdmin(null);
+ fetchAdmins();
+ } catch (error: any) {
+ console.error('Failed to update permissions:', error);
+ toast.error(error.message || "Failed to update permissions");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const togglePermission = (permission: string, isEdit: boolean) => {
+ if (isEdit) {
+ setEditPermissions(prev =>
+ prev.includes(permission)
+ ? prev.filter(p => p !== permission)
+ : [...prev, permission]
+ );
+ } else {
+ setSelectedPermissions(prev =>
+ prev.includes(permission)
+ ? prev.filter(p => p !== permission)
+ : [...prev, permission]
+ );
+ }
+ };
+
const handleRemoveAdmin = async (admin: AdminUser) => {
if (!confirm(`Are you sure you want to remove access for ${admin.profile?.name || admin.profile?.email || 'this user'}?`)) {
return;
@@ -229,7 +291,7 @@ export function AdminsPage() {
Add Admin
-
+
Add Administrator
@@ -289,17 +351,107 @@ export function AdminsPage() {