mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Implement E2EE and message content moderation
This commit is contained in:
1 parent
e1ff87dc03
commit
198cd80dfd
74 files changed
+12939
-2178
No files matched your search
+177
-168
@@ -1,168 +1,177 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute';
|
||||
import { AppLayout } from '@/components/layout/AppLayout';
|
||||
import { LoginPage } from '@/pages/LoginPage';
|
||||
import { DashboardPage } from '@/pages/DashboardPage';
|
||||
import { CategoriesPage } from '@/pages/content/CategoriesPage';
|
||||
import { PacksPage } from '@/pages/content/PacksPage';
|
||||
import { QuestionsPage } from '@/pages/content/QuestionsPage';
|
||||
import { UsersPage } from '@/pages/users/UsersPage';
|
||||
import { UserDetailPage } from '@/pages/users/UserDetailPage';
|
||||
import { MatchChatPage } from '@/pages/users/MatchChatPage';
|
||||
import { AuditLogsPage } from '@/pages/AuditLogsPage';
|
||||
import { AdminsPage } from '@/pages/admins/AdminsPage';
|
||||
import { RedemptionCodesPage } from '@/pages/RedemptionCodesPage';
|
||||
import { FeedbackPage } from '@/pages/FeedbackPage';
|
||||
import { UsageInsightsPage } from '@/pages/UsageInsightsPage';
|
||||
import { UserActivityPage } from '@/pages/activity/UserActivityPage';
|
||||
import { AiSettingsPage } from '@/pages/AiSettingsPage';
|
||||
import { AppSettingsPage } from '@/pages/AppSettingsPage';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
{/* Dashboard */}
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
|
||||
{/* Content management */}
|
||||
<Route
|
||||
path="/categories"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_CATEGORIES}>
|
||||
<CategoriesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/categories/:categoryId/packs"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_PACKS}>
|
||||
<PacksPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/packs/:packId/questions"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_QUESTIONS}>
|
||||
<QuestionsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* User management */}
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_USERS}>
|
||||
<UsersPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users/:userId"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_USERS}>
|
||||
<UserDetailPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users/:userId/matches/:matchId"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_CHATS}>
|
||||
<MatchChatPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/usage-insights"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_USERS}>
|
||||
<UsageInsightsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/activity"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_ACTIVITY}>
|
||||
<UserActivityPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* System */}
|
||||
<Route
|
||||
path="/admins"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_ADMINS}>
|
||||
<AdminsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/redemption-codes"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_CODES}>
|
||||
<RedemptionCodesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/audit-logs"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_AUDIT_LOGS}>
|
||||
<AuditLogsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/feedback"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_FEEDBACK}>
|
||||
<FeedbackPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/ai-settings"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_AI_CONFIG}>
|
||||
<AiSettingsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/app-settings"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_APP_CONFIG}>
|
||||
<AppSettingsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
{/* Catch all - redirect to dashboard */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute';
|
||||
import { AppLayout } from '@/components/layout/AppLayout';
|
||||
import { LoginPage } from '@/pages/LoginPage';
|
||||
import { DashboardPage } from '@/pages/DashboardPage';
|
||||
import { CategoriesPage } from '@/pages/content/CategoriesPage';
|
||||
import { PacksPage } from '@/pages/content/PacksPage';
|
||||
import { QuestionsPage } from '@/pages/content/QuestionsPage';
|
||||
import { UsersPage } from '@/pages/users/UsersPage';
|
||||
import { UserDetailPage } from '@/pages/users/UserDetailPage';
|
||||
import { MatchChatPage } from '@/pages/users/MatchChatPage';
|
||||
import { AuditLogsPage } from '@/pages/AuditLogsPage';
|
||||
import { AdminsPage } from '@/pages/admins/AdminsPage';
|
||||
import { RedemptionCodesPage } from '@/pages/RedemptionCodesPage';
|
||||
import { FeedbackPage } from '@/pages/FeedbackPage';
|
||||
import { UsageInsightsPage } from '@/pages/UsageInsightsPage';
|
||||
import { UserActivityPage } from '@/pages/activity/UserActivityPage';
|
||||
import { AiSettingsPage } from '@/pages/AiSettingsPage';
|
||||
import { AppSettingsPage } from '@/pages/AppSettingsPage';
|
||||
import { FlaggedMessagesPage } from '@/pages/FlaggedMessagesPage';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
{/* Dashboard */}
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
|
||||
{/* Content management */}
|
||||
<Route
|
||||
path="/categories"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_CATEGORIES}>
|
||||
<CategoriesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/categories/:categoryId/packs"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_PACKS}>
|
||||
<PacksPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/packs/:packId/questions"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_QUESTIONS}>
|
||||
<QuestionsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* User management */}
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_USERS}>
|
||||
<UsersPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users/:userId"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_USERS}>
|
||||
<UserDetailPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users/:userId/matches/:matchId"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_CHATS}>
|
||||
<MatchChatPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/usage-insights"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_USERS}>
|
||||
<UsageInsightsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/activity"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_ACTIVITY}>
|
||||
<UserActivityPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* System */}
|
||||
<Route
|
||||
path="/admins"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_ADMINS}>
|
||||
<AdminsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/redemption-codes"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_CODES}>
|
||||
<RedemptionCodesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/audit-logs"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_AUDIT_LOGS}>
|
||||
<AuditLogsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/feedback"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_FEEDBACK}>
|
||||
<FeedbackPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/ai-settings"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_AI_CONFIG}>
|
||||
<AiSettingsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/flagged-messages"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_AI_CONFIG}>
|
||||
<FlaggedMessagesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/app-settings"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_APP_CONFIG}>
|
||||
<AppSettingsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
{/* Catch all - redirect to dashboard */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
X,
|
||||
Home,
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@@ -82,6 +83,12 @@ const systemNav: NavItem[] = [
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
requiredPermission: PERMISSION_KEYS.MANAGE_ADMINS,
|
||||
},
|
||||
{
|
||||
label: 'Flagged Messages',
|
||||
href: '/flagged-messages',
|
||||
icon: <AlertTriangle className="h-5 w-5" />,
|
||||
requiredPermission: PERMISSION_KEYS.MANAGE_AI_CONFIG,
|
||||
},
|
||||
{
|
||||
label: 'AI Settings',
|
||||
href: '/ai-settings',
|
||||
|
||||
@@ -20,6 +20,9 @@ export interface AiConfig {
|
||||
council_reviewer_model: string | null;
|
||||
council_selection_mode: CouncilSelectionMode | null; // 'whole_set' or 'cherry_pick'
|
||||
cherry_pick_ensure_intensity_distribution: boolean | null; // Balance intensity levels in cherry-pick mode
|
||||
classifier_enabled: boolean | null;
|
||||
classifier_model: string | null;
|
||||
classifier_prompt: string | null;
|
||||
updated_at: string | null;
|
||||
updated_by: string | null;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Save, Eye, EyeOff, AlertTriangle, Bot, Sparkles, RefreshCw, Plus, Trash2, Package, Cherry } from 'lucide-react';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Save, Eye, EyeOff, AlertTriangle, Bot, Sparkles, RefreshCw, Plus, Trash2, Package, Cherry, Shield } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { format } from 'date-fns';
|
||||
import { ModelCombobox } from '@/components/ModelCombobox';
|
||||
@@ -47,6 +48,9 @@ export function AiSettingsPage() {
|
||||
council_reviewer_model: config.council_reviewer_model || '',
|
||||
council_selection_mode: config.council_selection_mode || 'whole_set',
|
||||
cherry_pick_ensure_intensity_distribution: config.cherry_pick_ensure_intensity_distribution ?? true,
|
||||
classifier_enabled: config.classifier_enabled ?? true,
|
||||
classifier_model: config.classifier_model || 'openai/gpt-4o',
|
||||
classifier_prompt: config.classifier_prompt || '',
|
||||
});
|
||||
setHasChanges(false);
|
||||
}
|
||||
@@ -92,6 +96,9 @@ export function AiSettingsPage() {
|
||||
council_reviewer_model: config.council_reviewer_model || '',
|
||||
council_selection_mode: config.council_selection_mode || 'whole_set',
|
||||
cherry_pick_ensure_intensity_distribution: config.cherry_pick_ensure_intensity_distribution ?? true,
|
||||
classifier_enabled: config.classifier_enabled ?? true,
|
||||
classifier_model: config.classifier_model || 'openai/gpt-4o',
|
||||
classifier_prompt: config.classifier_prompt || '',
|
||||
});
|
||||
setHasChanges(false);
|
||||
}
|
||||
@@ -248,6 +255,59 @@ export function AiSettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Moderation Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Moderation Settings
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure automatic content moderation for messages
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Enable Auto-Moderation</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Automatically flag illegal/dangerous content
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.classifier_enabled ?? true}
|
||||
onCheckedChange={(checked) => handleChange('classifier_enabled', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="classifier-model">Classifier Model</Label>
|
||||
<ModelCombobox
|
||||
value={formData.classifier_model || ''}
|
||||
onChange={(value) => handleChange('classifier_model', value)}
|
||||
placeholder="openai/gpt-4o"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Model used for classification (must support vision if image moderation is needed)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="classifier-prompt">System Prompt</Label>
|
||||
<Textarea
|
||||
id="classifier-prompt"
|
||||
value={formData.classifier_prompt || ''}
|
||||
onChange={(e) => handleChange('classifier_prompt', e.target.value)}
|
||||
className="h-32 font-mono text-sm"
|
||||
placeholder="You are a content moderator..."
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Instructions for the AI classifier.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Model Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Shield, CheckCircle, AlertTriangle, Eye, RefreshCw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface FlaggedMessage {
|
||||
id: string;
|
||||
created_at: string;
|
||||
user_id: string;
|
||||
moderation_status: 'flagged' | 'safe' | 'unmoderated';
|
||||
flag_reason: string | null;
|
||||
content?: string; // Encrypted usually
|
||||
media_path?: string;
|
||||
media_type?: string;
|
||||
}
|
||||
|
||||
export function FlaggedMessagesPage() {
|
||||
const [messages, setMessages] = useState<FlaggedMessage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedMessage, setSelectedMessage] = useState<FlaggedMessage | null>(null);
|
||||
const [decryptedContent, setDecryptedContent] = useState<{ text: string | null; mediaUrl: string | null }>({ text: null, mediaUrl: null });
|
||||
const [decrypting, setDecrypting] = useState(false);
|
||||
const [processing, setProcessing] = useState<string | null>(null); // messageId being processed
|
||||
|
||||
const fetchMessages = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('messages')
|
||||
.select('id, created_at, user_id, moderation_status, flag_reason, content, media_path, media_type')
|
||||
.eq('moderation_status', 'flagged')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
setMessages(data || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load flagged messages:', error);
|
||||
toast.error('Failed to load messages');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchMessages();
|
||||
}, []);
|
||||
|
||||
const handleView = async (message: FlaggedMessage) => {
|
||||
setSelectedMessage(message);
|
||||
setDecrypting(true);
|
||||
setDecryptedContent({ text: null, mediaUrl: null });
|
||||
|
||||
try {
|
||||
// Call edge function to decrypt
|
||||
// We use admin-decrypt-message for text
|
||||
let text = null;
|
||||
let mediaUrl = null;
|
||||
|
||||
// 1. Decrypt Text/Metadata
|
||||
const { data: decryptData, error: decryptError } = await supabase.functions.invoke('admin-decrypt-message', {
|
||||
body: { messageId: message.id }
|
||||
});
|
||||
|
||||
if (decryptError) throw decryptError;
|
||||
|
||||
if (decryptData) {
|
||||
text = decryptData.content;
|
||||
|
||||
// 2. Handle Media
|
||||
// If it has media, admin-decrypt-message returns media_path but not the file content directly decrypted usually
|
||||
// But admin-decrypt-media function returns the raw bytes
|
||||
if (decryptData.media_path) {
|
||||
const { data: mediaBlob, error: mediaError } = await supabase.functions.invoke('admin-decrypt-media', {
|
||||
body: { messageId: message.id },
|
||||
// Response is a blob/file usually?
|
||||
// supabase.functions.invoke by default parses JSON.
|
||||
// We might need to handle blob response.
|
||||
});
|
||||
|
||||
// Invoke returns data as parsed JSON if header is json, or blob?
|
||||
// The admin-decrypt-media returns binary body.
|
||||
// Supabase js client might try to parse it.
|
||||
// We might need a direct fetch or handle responseType.
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: For now, displaying text content. Media decryption in frontend via edge function binary response
|
||||
// is tricky with supabase-js invoke().
|
||||
// We will just show the text for now or implement media later if critical.
|
||||
// Actually, we can just use a direct fetch with the user's session token.
|
||||
|
||||
setDecryptedContent({ text, mediaUrl });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Decryption failed:', error);
|
||||
toast.error('Failed to decrypt content');
|
||||
setDecryptedContent({ text: 'Error decrypting content', mediaUrl: null });
|
||||
} finally {
|
||||
setDecrypting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Custom media fetcher because supabase.functions.invoke is JSON-centric
|
||||
const fetchDecryptedMedia = async (messageId: string) => {
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) return null;
|
||||
|
||||
const response = await fetch(`https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/admin-decrypt-media`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${session.access_token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ messageId })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch media');
|
||||
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Improved handleView with media support
|
||||
const handleViewComplete = async (message: FlaggedMessage) => {
|
||||
setSelectedMessage(message);
|
||||
setDecrypting(true);
|
||||
setDecryptedContent({ text: null, mediaUrl: null });
|
||||
|
||||
try {
|
||||
// Text
|
||||
const { data: textData, error: textError } = await supabase.functions.invoke('admin-decrypt-message', {
|
||||
body: { messageId: message.id }
|
||||
});
|
||||
|
||||
let mediaUrl = null;
|
||||
if (message.media_path) {
|
||||
mediaUrl = await fetchDecryptedMedia(message.id);
|
||||
}
|
||||
|
||||
setDecryptedContent({
|
||||
text: textData?.content || (textError ? 'Error decrypting text' : null),
|
||||
mediaUrl
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
toast.error("Error loading content");
|
||||
} finally {
|
||||
setDecrypting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkSafe = async (messageId: string) => {
|
||||
setProcessing(messageId);
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('messages')
|
||||
.update({ moderation_status: 'safe' })
|
||||
.eq('id', messageId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success('Message marked as safe');
|
||||
setMessages(prev => prev.filter(m => m.id !== messageId));
|
||||
if (selectedMessage?.id === messageId) setSelectedMessage(null);
|
||||
} catch (error) {
|
||||
toast.error('Failed to update status');
|
||||
} finally {
|
||||
setProcessing(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Flagged Messages</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Review content flagged by AI moderation
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={fetchMessages} disabled={loading}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>Reason</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{messages.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<CheckCircle className="h-8 w-8 text-green-500/50" />
|
||||
<p>No flagged messages found. Good job!</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<TableRow key={message.id}>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{format(new Date(message.created_at), 'MMM d, HH:mm')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="destructive" className="bg-red-500/10 text-red-500 hover:bg-red-500/20 border-0">
|
||||
{message.flag_reason || 'Unknown'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{message.media_path ? (
|
||||
<Badge variant="outline">{message.media_type || 'Media'}</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Text</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewComplete(message)}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Review
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-green-600 hover:text-green-700 hover:bg-green-50"
|
||||
onClick={() => handleMarkSafe(message.id)}
|
||||
disabled={processing === message.id}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Mark Safe
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<Dialog open={!!selectedMessage} onOpenChange={(open) => !open && setSelectedMessage(null)}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Review Content</DialogTitle>
|
||||
<DialogDescription>
|
||||
Decrypted content for review. Flagged for: {selectedMessage?.flag_reason}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-[200px] bg-muted/30 rounded-md p-4 flex items-center justify-center">
|
||||
{decrypting ? (
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||
<p className="text-sm">Decrypting secure content...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full space-y-4">
|
||||
{decryptedContent.text && (
|
||||
<div className="bg-background border rounded p-3 text-sm">
|
||||
{decryptedContent.text}
|
||||
</div>
|
||||
)}
|
||||
{decryptedContent.mediaUrl && (
|
||||
<div className="flex justify-center">
|
||||
{selectedMessage?.media_type === 'video' ? (
|
||||
<video src={decryptedContent.mediaUrl} controls className="max-h-[400px] rounded" />
|
||||
) : (
|
||||
<img src={decryptedContent.mediaUrl} alt="Flagged content" className="max-h-[400px] rounded object-contain" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!decryptedContent.text && !decryptedContent.mediaUrl && (
|
||||
<p className="text-center text-muted-foreground">No content displayed</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setSelectedMessage(null)}>Cancel</Button>
|
||||
<Button
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
onClick={() => selectedMessage && handleMarkSafe(selectedMessage.id)}
|
||||
>
|
||||
Mark Safe
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { supabase, supabaseConfig } from '@/config';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
@@ -36,6 +37,10 @@ interface Message {
|
||||
created_at: string;
|
||||
read_at: string | null;
|
||||
media_viewed_at: string | null;
|
||||
version?: number | null;
|
||||
encrypted_content?: string | null;
|
||||
encryption_iv?: string | null;
|
||||
keys_metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const matchTypeLabels = {
|
||||
@@ -55,44 +60,72 @@ function formatMessageTime(date: Date): string {
|
||||
|
||||
|
||||
|
||||
function ChatImage({ mediaPath }: { mediaPath: string }) {
|
||||
function ChatImage({ messageId }: { messageId: string }) {
|
||||
const { session, isSuperAdmin } = useAuth();
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mediaPath) return;
|
||||
const getUrl = async () => {
|
||||
if (!messageId) return;
|
||||
|
||||
if (!isSuperAdmin) {
|
||||
setError('Decryption requires super admin');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session?.access_token) {
|
||||
setError('Not authenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
// Handle legacy full URLs
|
||||
let path = mediaPath;
|
||||
if (path.startsWith('http')) {
|
||||
const parts = path.split('/chat-media/');
|
||||
if (parts.length > 1) {
|
||||
path = decodeURIComponent(parts[1]);
|
||||
setError(null);
|
||||
setUrl(null);
|
||||
|
||||
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
apikey: supabaseConfig.anonKey,
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({ messageId }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const raw = await res.text().catch(() => '');
|
||||
let message = raw;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { error?: unknown };
|
||||
if (typeof parsed?.error === 'string') message = parsed.error;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(message || `Failed to load media (${res.status})`);
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
const { data, error } = await supabase.storage
|
||||
.from('chat-media')
|
||||
.createSignedUrl(path, 3600);
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating signed URL:', error);
|
||||
setError(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data?.signedUrl) {
|
||||
setUrl(data.signedUrl);
|
||||
}
|
||||
if (cancelled) return;
|
||||
setUrl(objectUrl);
|
||||
} catch (err) {
|
||||
console.error('Unexpected error loading image:', err);
|
||||
setError('Failed to load');
|
||||
if (cancelled) return;
|
||||
setError(err instanceof Error ? err.message : 'Failed to load');
|
||||
}
|
||||
};
|
||||
getUrl();
|
||||
}, [mediaPath]);
|
||||
|
||||
load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [messageId, isSuperAdmin, session?.access_token]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
@@ -118,43 +151,72 @@ function ChatImage({ mediaPath }: { mediaPath: string }) {
|
||||
}
|
||||
|
||||
|
||||
function ChatVideo({ mediaPath }: { mediaPath: string }) {
|
||||
function ChatVideo({ messageId }: { messageId: string }) {
|
||||
const { session, isSuperAdmin } = useAuth();
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mediaPath) return;
|
||||
const getUrl = async () => {
|
||||
if (!messageId) return;
|
||||
|
||||
if (!isSuperAdmin) {
|
||||
setError('Decryption requires super admin');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session?.access_token) {
|
||||
setError('Not authenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
// Handle legacy full URLs
|
||||
let path = mediaPath;
|
||||
if (path.startsWith('http')) {
|
||||
const parts = path.split('/chat-media/');
|
||||
if (parts.length > 1) {
|
||||
path = decodeURIComponent(parts[1]);
|
||||
setError(null);
|
||||
setUrl(null);
|
||||
|
||||
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
apikey: supabaseConfig.anonKey,
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({ messageId }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const raw = await res.text().catch(() => '');
|
||||
let message = raw;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { error?: unknown };
|
||||
if (typeof parsed?.error === 'string') message = parsed.error;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(message || `Failed to load media (${res.status})`);
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.storage
|
||||
.from('chat-media')
|
||||
.createSignedUrl(path, 3600);
|
||||
const blob = await res.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating signed URL for video:', error);
|
||||
setError(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data?.signedUrl) {
|
||||
setUrl(data.signedUrl);
|
||||
}
|
||||
if (cancelled) return;
|
||||
setUrl(objectUrl);
|
||||
} catch (err) {
|
||||
console.error('Unexpected error loading video:', err);
|
||||
setError('Failed to load');
|
||||
if (cancelled) return;
|
||||
setError(err instanceof Error ? err.message : 'Failed to load');
|
||||
}
|
||||
};
|
||||
getUrl();
|
||||
}, [mediaPath]);
|
||||
|
||||
load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [messageId, isSuperAdmin, session?.access_token]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
@@ -191,6 +253,77 @@ function ChatVideo({ mediaPath }: { mediaPath: string }) {
|
||||
}
|
||||
|
||||
|
||||
function DecryptedMessageText({ message }: { message: Message }) {
|
||||
const { isSuperAdmin } = useAuth();
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
const [isDecrypting, setIsDecrypting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const version = message.version ?? 1;
|
||||
|
||||
// v1: legacy plaintext
|
||||
if (version !== 2) {
|
||||
setText(message.content ?? null);
|
||||
setIsDecrypting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// v2: encrypted
|
||||
if (!message.encrypted_content) {
|
||||
setText(null);
|
||||
setIsDecrypting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSuperAdmin) {
|
||||
setText('[Encrypted]');
|
||||
setIsDecrypting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const decrypt = async () => {
|
||||
setIsDecrypting(true);
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke('admin-decrypt-message', {
|
||||
body: { messageId: message.id },
|
||||
});
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (error) {
|
||||
setText('[Unable to decrypt]');
|
||||
return;
|
||||
}
|
||||
|
||||
setText((data as { content?: string | null } | null)?.content ?? null);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setText('[Unable to decrypt]');
|
||||
} finally {
|
||||
if (!cancelled) setIsDecrypting(false);
|
||||
}
|
||||
};
|
||||
|
||||
decrypt();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isSuperAdmin, message.content, message.encrypted_content, message.id, message.version]);
|
||||
|
||||
const shouldRender = Boolean(message.content || message.encrypted_content);
|
||||
if (!shouldRender) return null;
|
||||
|
||||
return (
|
||||
<p className="text-sm whitespace-pre-wrap">
|
||||
{isDecrypting ? 'Loading...' : (text ?? '')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function MatchChatPage() {
|
||||
const { userId, matchId } = useParams<{ userId: string; matchId: string }>();
|
||||
const [user, setUser] = useState<Profile | null>(null);
|
||||
@@ -405,16 +538,14 @@ export function MatchChatPage() {
|
||||
: "bg-muted rounded-tl-sm"
|
||||
)}
|
||||
>
|
||||
{message.content && (
|
||||
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
|
||||
)}
|
||||
<DecryptedMessageText message={message} />
|
||||
{message.media_path && (
|
||||
<div className="mt-2">
|
||||
<div className="relative group">
|
||||
{message.media_type === 'video' ? (
|
||||
<ChatVideo mediaPath={message.media_path} />
|
||||
<ChatVideo messageId={message.id} />
|
||||
) : (
|
||||
<ChatImage mediaPath={message.media_path} />
|
||||
<ChatImage messageId={message.id} />
|
||||
)}
|
||||
{message.media_viewed_at && (
|
||||
<Badge
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { supabase, supabaseConfig } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||
@@ -37,12 +37,14 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, HardDrive, ExternalLink, Target, User, Eye, EyeOff, CheckCircle } from 'lucide-react';
|
||||
import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
|
||||
type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
|
||||
|
||||
// ... (interfaces and constants are the same)
|
||||
|
||||
interface Profile {
|
||||
id: string;
|
||||
name: string | null;
|
||||
@@ -102,18 +104,6 @@ interface Match {
|
||||
message_count?: number;
|
||||
}
|
||||
|
||||
interface MediaFile {
|
||||
id: string;
|
||||
name: string;
|
||||
bucket_id: string;
|
||||
created_at: string | null;
|
||||
metadata: {
|
||||
size: number;
|
||||
mimetype: string;
|
||||
};
|
||||
signedUrl?: string;
|
||||
}
|
||||
|
||||
const answerIcons = {
|
||||
yes: <ThumbsUp className="h-4 w-4 text-green-500" />,
|
||||
no: <ThumbsDown className="h-4 w-4 text-red-500" />,
|
||||
@@ -126,12 +116,117 @@ const matchTypeLabels = {
|
||||
maybe_maybe: 'Both Maybe',
|
||||
};
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
|
||||
function AdminDecryptedImage({ messageId, alt }: { messageId: string; alt: string }) {
|
||||
const { session, isSuperAdmin } = useAuth();
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!messageId) return;
|
||||
if (!isSuperAdmin) {
|
||||
setError("Super admin required");
|
||||
return;
|
||||
}
|
||||
if (!session?.access_token) {
|
||||
setError("Not authenticated");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', apikey: supabaseConfig.anonKey, Authorization: `Bearer ${session.access_token}` },
|
||||
body: JSON.stringify({ messageId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const message = (await res.json().catch(() => ({})))?.error || `Failed to load media (${res.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
if (!cancelled) setUrl(objectUrl);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load');
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [isSuperAdmin, messageId, session?.access_token]);
|
||||
|
||||
if (error) {
|
||||
return <div className="w-full h-full flex items-center justify-center text-center p-2"><Image className="h-8 w-8 text-muted-foreground mx-auto" /><div className="text-[10px] text-red-500 mt-1 line-clamp-2" title={error}>{error}</div></div>;
|
||||
}
|
||||
if (!url) {
|
||||
return <div className="w-full h-full flex items-center justify-center"><Skeleton className="h-16 w-16 rounded-md" /></div>;
|
||||
}
|
||||
return <img src={url} alt={alt} className="w-full h-full object-cover cursor-pointer" onClick={() => window.open(url, '_blank')} />;
|
||||
}
|
||||
|
||||
function AdminDecryptedVideo({ messageId }: { messageId: string }) {
|
||||
const { session, isSuperAdmin } = useAuth();
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!messageId) return;
|
||||
if (!isSuperAdmin) {
|
||||
setError("Super admin required");
|
||||
return;
|
||||
}
|
||||
if (!session?.access_token) {
|
||||
setError("Not authenticated");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', apikey: supabaseConfig.anonKey, Authorization: `Bearer ${session.access_token}` },
|
||||
body: JSON.stringify({ messageId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const message = (await res.json().catch(() => ({})))?.error || `Failed to load media (${res.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
if (!cancelled) setUrl(objectUrl);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load');
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [isSuperAdmin, messageId, session?.access_token]);
|
||||
|
||||
if (error) {
|
||||
return <div className="w-full h-full flex items-center justify-center text-center p-2"><VideoIcon className="h-8 w-8 text-muted-foreground mx-auto" /><div className="text-[10px] text-red-500 mt-1 line-clamp-2" title={error}>{error}</div></div>;
|
||||
}
|
||||
if (!url) {
|
||||
return <div className="w-full h-full flex items-center justify-center"><Skeleton className="h-16 w-16 rounded-md" /></div>;
|
||||
}
|
||||
return <video src={url} controls className="w-full h-full object-cover" preload="metadata" />;
|
||||
}
|
||||
|
||||
export function UserDetailPage() {
|
||||
@@ -141,8 +236,7 @@ export function UserDetailPage() {
|
||||
const [partner, setPartner] = useState<Partner | null>(null);
|
||||
const [responses, setResponses] = useState<Response[]>([]);
|
||||
const [matches, setMatches] = useState<Match[]>([]);
|
||||
const [media, setMedia] = useState<MediaFile[]>([]);
|
||||
const [totalStorage, setTotalStorage] = useState(0);
|
||||
const [mediaMessages, setMediaMessages] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const canViewResponses = hasPermission(PERMISSION_KEYS.VIEW_RESPONSES);
|
||||
@@ -158,11 +252,9 @@ export function UserDetailPage() {
|
||||
const handleUpgrade = async () => {
|
||||
if (!profile || !userId) return;
|
||||
setUpgrading(true);
|
||||
|
||||
try {
|
||||
let expiresAt = null;
|
||||
const now = new Date();
|
||||
|
||||
if (expiryType === '1_month') {
|
||||
const date = new Date(now);
|
||||
date.setMonth(date.getMonth() + 1);
|
||||
@@ -172,7 +264,6 @@ export function UserDetailPage() {
|
||||
date.setFullYear(date.getFullYear() + 1);
|
||||
expiresAt = date.toISOString();
|
||||
} else if (expiryType === 'custom' && customDate) {
|
||||
// Ensure custom date is in the future
|
||||
const date = new Date(customDate);
|
||||
if (date <= now) {
|
||||
alert('Expiry date must be in the future');
|
||||
@@ -181,7 +272,6 @@ export function UserDetailPage() {
|
||||
}
|
||||
expiresAt = date.toISOString();
|
||||
}
|
||||
|
||||
const { error } = await auditedSupabase.insert('subscriptions', {
|
||||
user_id: userId,
|
||||
revenuecat_app_user_id: 'admin_grant',
|
||||
@@ -191,24 +281,10 @@ export function UserDetailPage() {
|
||||
purchased_at: new Date().toISOString(),
|
||||
expires_at: expiresAt,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Refresh profile to get updated is_premium status
|
||||
// The trigger sync_premium_on_subscription_change will update the profile
|
||||
// We might need a small delay or just wait for the select
|
||||
const { data: updatedProfile } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
|
||||
if (updatedProfile) {
|
||||
setProfile(updatedProfile);
|
||||
}
|
||||
|
||||
const { data: updatedProfile } = await supabase.from('profiles').select('*').eq('id', userId).single();
|
||||
if (updatedProfile) setProfile(updatedProfile);
|
||||
setUpgradeOpen(false);
|
||||
// Optionally add toast here if available in context
|
||||
alert('User upgraded to premium successfully!');
|
||||
} catch (err) {
|
||||
console.error('Failed to upgrade:', err);
|
||||
@@ -220,101 +296,43 @@ export function UserDetailPage() {
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch profile
|
||||
const { data: profileData } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
|
||||
const { data: profileData } = await supabase.from('profiles').select('*').eq('id', userId).single();
|
||||
setProfile(profileData);
|
||||
|
||||
// Fetch partner if coupled
|
||||
if (profileData?.couple_id) {
|
||||
const { data: partnerData } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, name')
|
||||
.eq('couple_id', profileData.couple_id)
|
||||
.neq('id', userId)
|
||||
.single();
|
||||
|
||||
const { data: partnerData } = await supabase.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).single();
|
||||
setPartner(partnerData);
|
||||
} else {
|
||||
setPartner(null);
|
||||
}
|
||||
|
||||
// Fetch responses with question and pack info
|
||||
const { data: responseData } = await supabase
|
||||
.from('responses')
|
||||
.select(`
|
||||
id, answer, created_at,
|
||||
question:questions (
|
||||
id, text,
|
||||
pack:question_packs (name)
|
||||
)
|
||||
`)
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50);
|
||||
|
||||
const { data: responseData } = await supabase.from('responses').select('id, answer, created_at, question:questions(id, text, pack:question_packs(name))').eq('user_id', userId).order('created_at', { ascending: false }).limit(50);
|
||||
setResponses((responseData || []) as unknown as Response[]);
|
||||
|
||||
// Fetch matches if coupled
|
||||
if (profileData?.couple_id) {
|
||||
const { data: matchData } = await supabase
|
||||
.from('matches')
|
||||
.select(`
|
||||
id, match_type, is_new, created_at,
|
||||
question:questions (id, text)
|
||||
`)
|
||||
.eq('couple_id', profileData.couple_id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50);
|
||||
|
||||
// Get message counts for each match
|
||||
const { data: matchData } = await supabase.from('matches').select('id, match_type, is_new, created_at, question:questions(id, text)').eq('couple_id', profileData.couple_id).order('created_at', { ascending: false }).limit(50);
|
||||
const matchIds = (matchData || []).map(m => m.id);
|
||||
const { data: messages } = await supabase
|
||||
.from('messages')
|
||||
.select('match_id')
|
||||
.in('match_id', matchIds);
|
||||
|
||||
const messageCounts: Record<string, number> = {};
|
||||
messages?.forEach(m => {
|
||||
messageCounts[m.match_id] = (messageCounts[m.match_id] || 0) + 1;
|
||||
});
|
||||
|
||||
setMatches(
|
||||
(matchData || []).map(m => ({
|
||||
...m,
|
||||
message_count: messageCounts[m.id] || 0,
|
||||
})) as unknown as Match[]
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch media files uploaded by this user
|
||||
const { data: mediaData } = await supabase
|
||||
.rpc('get_user_media_files', { user_id: userId });
|
||||
|
||||
if (mediaData && mediaData.length > 0) {
|
||||
// Calculate total storage
|
||||
const total = mediaData.reduce((sum: number, file: MediaFile) =>
|
||||
sum + (file.metadata?.size || 0), 0);
|
||||
setTotalStorage(total);
|
||||
|
||||
// Get signed URLs for each file
|
||||
const mediaWithUrls = await Promise.all(
|
||||
mediaData.map(async (file: MediaFile) => {
|
||||
const { data: signedData } = await supabase.storage
|
||||
.from('chat-media')
|
||||
.createSignedUrl(file.name, 3600); // 1 hour expiry
|
||||
return {
|
||||
...file,
|
||||
signedUrl: signedData?.signedUrl,
|
||||
};
|
||||
})
|
||||
);
|
||||
setMedia(mediaWithUrls);
|
||||
if (matchIds.length > 0) {
|
||||
const { data: messages } = await supabase.from('messages').select('match_id').in('match_id', matchIds);
|
||||
const messageCounts: Record<string, number> = {};
|
||||
messages?.forEach(m => { messageCounts[m.match_id] = (messageCounts[m.match_id] || 0) + 1; });
|
||||
setMatches((matchData || []).map(m => ({ ...m, message_count: messageCounts[m.id] || 0 })) as unknown as Match[]);
|
||||
const { data: mediaMessagesData, error: mediaError } = await supabase.from('messages').select('id, user_id, media_path, media_type, version, created_at, keys_metadata, match_id').in('match_id', matchIds).not('media_path', 'is', null).order('created_at', { ascending: false });
|
||||
if (mediaError) {
|
||||
console.error("Error fetching media messages:", mediaError);
|
||||
setMediaMessages([]);
|
||||
} else {
|
||||
setMediaMessages(mediaMessagesData || []);
|
||||
}
|
||||
} else {
|
||||
setMatches([]);
|
||||
setMediaMessages([]);
|
||||
}
|
||||
} else {
|
||||
setMatches([]);
|
||||
setMediaMessages([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load user data:', error);
|
||||
@@ -323,431 +341,56 @@ export function UserDetailPage() {
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
// Real-time subscription for this user's profile
|
||||
const { status: profileStatus } = useRealtimeSubscription<Profile>({
|
||||
table: 'profiles',
|
||||
filter: userId ? `id=eq.${userId}` : undefined,
|
||||
enabled: !!userId,
|
||||
onUpdate: useCallback(({ new: updated }: { old: Profile; new: Profile }) => {
|
||||
setProfile(updated);
|
||||
}, []),
|
||||
});
|
||||
const { status: profileStatus } = useRealtimeSubscription<Profile>({ table: 'profiles', filter: userId ? `id=eq.${userId}` : undefined, enabled: !!userId, onUpdate: useCallback(({ new: updated }: { old: Profile; new: Profile }) => { setProfile(updated); }, []) });
|
||||
const { status: responsesStatus } = useRealtimeSubscription<Response>({ table: 'responses', filter: userId ? `user_id=eq.${userId}` : undefined, enabled: !!userId, onInsert: fetchData, onDelete: fetchData });
|
||||
|
||||
// Real-time subscription for this user's responses
|
||||
const { status: responsesStatus } = useRealtimeSubscription<Response>({
|
||||
table: 'responses',
|
||||
filter: userId ? `user_id=eq.${userId}` : undefined,
|
||||
enabled: !!userId,
|
||||
onInsert: fetchData, // Refetch to get question/pack relations
|
||||
onDelete: fetchData,
|
||||
});
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-32" />
|
||||
<Skeleton className="h-96" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<p className="text-lg font-medium">User not found</p>
|
||||
<Link to="/users">
|
||||
<Button variant="link">Back to users</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (loading) return <div className="space-y-6"><Skeleton className="h-32" /><Skeleton className="h-96" /></div>;
|
||||
if (!profile) return <div className="flex flex-col items-center justify-center py-12"><p className="text-lg font-medium">User not found</p><Link to="/users"><Button variant="link">Back to users</Button></Link></div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Profile Header */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-6">
|
||||
<Avatar className="h-20 w-20">
|
||||
<AvatarImage src={profile.avatar_url || undefined} />
|
||||
<AvatarFallback className="text-2xl">
|
||||
{profile.name?.charAt(0).toUpperCase() || 'U'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{profile.name || 'Unnamed User'}
|
||||
</h1>
|
||||
<RealtimeStatusIndicator
|
||||
status={profileStatus === 'SUBSCRIBED' && responsesStatus === 'SUBSCRIBED' ? 'SUBSCRIBED' : profileStatus}
|
||||
showLabel
|
||||
/>
|
||||
{profile.is_premium && (
|
||||
<Badge className="bg-amber-500">
|
||||
<Crown className="h-3 w-3 mr-1" />
|
||||
Premium
|
||||
</Badge>
|
||||
|
||||
)}
|
||||
{!profile.is_premium && (
|
||||
<Dialog open={upgradeOpen} onOpenChange={setUpgradeOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant="outline" className="ml-2 gap-1 h-7">
|
||||
<Gift className="h-3.5 w-3.5" />
|
||||
Gift Premium
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Gift Premium Access</DialogTitle>
|
||||
<DialogDescription>
|
||||
Manually upgrade this user to premium status.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="expiry-type">Duration</Label>
|
||||
<Select value={expiryType} onValueChange={setExpiryType}>
|
||||
<SelectTrigger id="expiry-type">
|
||||
<SelectValue placeholder="Select duration" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="forever">Lifetime (No Expiry)</SelectItem>
|
||||
<SelectItem value="1_month">1 Month</SelectItem>
|
||||
<SelectItem value="1_year">1 Year</SelectItem>
|
||||
<SelectItem value="custom">Custom Date</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{expiryType === 'custom' && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="custom-date">Expiry Date</Label>
|
||||
<Input
|
||||
id="custom-date"
|
||||
type="datetime-local"
|
||||
value={customDate}
|
||||
onChange={(e) => setCustomDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setUpgradeOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUpgrade} disabled={upgrading}>
|
||||
{upgrading ? 'Upgrading...' : 'Confirm Upgrade'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{profile.email || 'No email'}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-6 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Joined: </span>
|
||||
<span>
|
||||
{profile.created_at
|
||||
? format(new Date(profile.created_at), 'MMMM d, yyyy')
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{partner ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Paired with: </span>
|
||||
<Link to={`/users/${partner.id}`} className="text-primary hover:underline">
|
||||
{partner.name || 'Unnamed Partner'}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">Not paired</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Card><CardContent className="pt-6"><div className="flex items-start gap-6">
|
||||
<Avatar className="h-20 w-20"><AvatarImage src={profile.avatar_url || undefined} /><AvatarFallback className="text-2xl">{profile.name?.charAt(0).toUpperCase() || 'U'}</AvatarFallback></Avatar>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-2xl font-bold">{profile.name || 'Unnamed User'}</h1>
|
||||
<RealtimeStatusIndicator status={profileStatus === 'SUBSCRIBED' && responsesStatus === 'SUBSCRIBED' ? 'SUBSCRIBED' : profileStatus} showLabel />
|
||||
{profile.is_premium && <Badge className="bg-amber-500"><Crown className="h-3 w-3 mr-1" />Premium</Badge>}
|
||||
{!profile.is_premium && <Dialog open={upgradeOpen} onOpenChange={setUpgradeOpen}><DialogTrigger asChild><Button size="sm" variant="outline" className="ml-2 gap-1 h-7"><Gift className="h-3.5 w-3.5" />Gift Premium</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>Gift Premium Access</DialogTitle><DialogDescription>Manually upgrade this user to premium status.</DialogDescription></DialogHeader><div className="grid gap-4 py-4"><div className="grid gap-2"><Label htmlFor="expiry-type">Duration</Label><Select value={expiryType} onValueChange={setExpiryType}><SelectTrigger id="expiry-type"><SelectValue placeholder="Select duration" /></SelectTrigger><SelectContent><SelectItem value="forever">Lifetime (No Expiry)</SelectItem><SelectItem value="1_month">1 Month</SelectItem><SelectItem value="1_year">1 Year</SelectItem><SelectItem value="custom">Custom Date</SelectItem></SelectContent></Select></div>{expiryType === 'custom' && (<div className="grid gap-2"><Label htmlFor="custom-date">Expiry Date</Label><Input id="custom-date" type="datetime-local" value={customDate} onChange={(e) => setCustomDate(e.target.value)} /></div>)}</div><DialogFooter><Button variant="outline" onClick={() => setUpgradeOpen(false)}>Cancel</Button><Button onClick={handleUpgrade} disabled={upgrading}>{upgrading ? 'Upgrading...' : 'Confirm Upgrade'}</Button></DialogFooter></DialogContent></Dialog>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-muted-foreground mb-4">{profile.email || 'No email'}</p>
|
||||
<div className="flex gap-6 text-sm">
|
||||
<div><span className="text-muted-foreground">Joined: </span><span>{profile.created_at ? format(new Date(profile.created_at), 'MMMM d, yyyy') : '—'}</span></div>
|
||||
{partner ? <div className="flex items-center gap-1"><Users className="h-4 w-4 text-muted-foreground" /><span className="text-muted-foreground">Paired with: </span><Link to={`/users/${partner.id}`} className="text-primary hover:underline">{partner.name || 'Unnamed Partner'}</Link></div> : <div className="text-muted-foreground">Not paired</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div></CardContent></Card>
|
||||
{/* Onboarding Details */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-primary" />
|
||||
Onboarding Details
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Information collected during user onboarding
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Gender */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900">
|
||||
<User className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Gender</p>
|
||||
<p className="font-medium">
|
||||
{profile.gender ? genderLabels[profile.gender] : 'Not set'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage Reason */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900">
|
||||
<Target className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Why using Sauci</p>
|
||||
<p className="font-medium">
|
||||
{profile.usage_reason
|
||||
? usageReasonLabels[profile.usage_reason]?.label
|
||||
: 'Not set'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Explicit Content */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`p-2 rounded-lg ${profile.show_explicit_content ? 'bg-amber-100 dark:bg-amber-900' : 'bg-gray-100 dark:bg-gray-800'}`}>
|
||||
{profile.show_explicit_content ? (
|
||||
<Eye className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||
) : (
|
||||
<EyeOff className="h-4 w-4 text-gray-500 dark:text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Explicit Content</p>
|
||||
<p className="font-medium">
|
||||
{profile.show_explicit_content === null
|
||||
? 'Not set'
|
||||
: profile.show_explicit_content
|
||||
? 'Enabled'
|
||||
: 'Disabled'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Onboarding Status */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`p-2 rounded-lg ${profile.onboarding_completed ? 'bg-green-100 dark:bg-green-900' : 'bg-red-100 dark:bg-red-900'}`}>
|
||||
<CheckCircle className={`h-4 w-4 ${profile.onboarding_completed ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Onboarding</p>
|
||||
<p className="font-medium">
|
||||
{profile.onboarding_completed ? 'Completed' : 'Not completed'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card><CardHeader className="pb-3"><CardTitle className="text-lg flex items-center gap-2"><CheckCircle className="h-5 w-5 text-primary" />Onboarding Details</CardTitle><CardDescription>Information collected during user onboarding</CardDescription></CardHeader><CardContent><div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900"><User className="h-4 w-4 text-blue-600 dark:text-blue-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Gender</p><p className="font-medium">{profile.gender ? genderLabels[profile.gender] : 'Not set'}</p></div></div>
|
||||
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900"><Target className="h-4 w-4 text-purple-600 dark:text-purple-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Why using Sauci</p><p className="font-medium">{profile.usage_reason ? usageReasonLabels[profile.usage_reason]?.label : 'Not set'}</p></div></div>
|
||||
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.show_explicit_content ? 'bg-amber-100 dark:bg-amber-900' : 'bg-gray-100 dark:bg-gray-800'}`}>{profile.show_explicit_content ? <Eye className="h-4 w-4 text-amber-600 dark:text-amber-400" /> : <EyeOff className="h-4 w-4 text-gray-500 dark:text-gray-400" />}</div><div><p className="text-sm font-medium text-muted-foreground">Explicit Content</p><p className="font-medium">{profile.show_explicit_content === null ? 'Not set' : profile.show_explicit_content ? 'Enabled' : 'Disabled'}</p></div></div>
|
||||
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.onboarding_completed ? 'bg-green-100 dark:bg-green-900' : 'bg-red-100 dark:bg-red-900'}`}><CheckCircle className={`h-4 w-4 ${profile.onboarding_completed ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}`} /></div><div><p className="text-sm font-medium text-muted-foreground">Onboarding</p><p className="font-medium">{profile.onboarding_completed ? 'Completed' : 'Not completed'}</p></div></div>
|
||||
</div></CardContent></Card>
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}>
|
||||
<TabsList>
|
||||
{canViewResponses && (
|
||||
<TabsTrigger value="responses">
|
||||
Responses ({responses.length})
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{canViewMatches && (
|
||||
<TabsTrigger value="matches" disabled={!profile.couple_id}>
|
||||
Matches ({matches.length})
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{canViewMedia && (
|
||||
<TabsTrigger value="media">
|
||||
Media ({media.length})
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{canViewResponses && <TabsTrigger value="responses">Responses ({responses.length})</TabsTrigger>}
|
||||
{canViewMatches && <TabsTrigger value="matches" disabled={!profile.couple_id}>Matches ({matches.length})</TabsTrigger>}
|
||||
{canViewMedia && <TabsTrigger value="media">Media ({mediaMessages.length})</TabsTrigger>}
|
||||
</TabsList>
|
||||
|
||||
{canViewResponses && (
|
||||
<TabsContent value="responses" className="mt-4">
|
||||
{responses.length === 0 ? (
|
||||
<Card className="flex flex-col items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">No responses yet</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Question</TableHead>
|
||||
<TableHead>Pack</TableHead>
|
||||
<TableHead className="w-24">Answer</TableHead>
|
||||
<TableHead className="w-32">Date</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{responses.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="max-w-md">
|
||||
<span className="line-clamp-2">{r.question.text}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{r.question.pack?.name || '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{answerIcons[r.answer]}
|
||||
<span className="capitalize">{r.answer}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{r.created_at
|
||||
? format(new Date(r.created_at), 'MMM d, yyyy')
|
||||
: '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{canViewMatches && (
|
||||
<TabsContent value="matches" className="mt-4">
|
||||
{matches.length === 0 ? (
|
||||
<Card className="flex flex-col items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">No matches yet</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{matches.map((match) => (
|
||||
<Card key={match.id} className="hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<Badge variant="secondary">
|
||||
{matchTypeLabels[match.match_type]}
|
||||
</Badge>
|
||||
{match.is_new && (
|
||||
<Badge variant="default" className="text-xs">New</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className="line-clamp-2 mt-2">
|
||||
{match.question?.text || 'Unknown question'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
{match.message_count} message{match.message_count !== 1 ? 's' : ''}
|
||||
</div>
|
||||
{canViewChats && (
|
||||
<Link to={`/users/${userId}/matches/${match.id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
View Chat
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{canViewMedia && (
|
||||
<TabsContent value="media" className="mt-4">
|
||||
{media.length === 0 ? (
|
||||
<Card className="flex flex-col items-center justify-center py-12">
|
||||
<Image className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">No media uploaded</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Storage Summary */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<HardDrive className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">Total Storage Used</p>
|
||||
<p className="text-2xl font-bold">{formatBytes(totalStorage)}</p>
|
||||
</div>
|
||||
<div className="ml-auto text-sm text-muted-foreground">
|
||||
{media.length} file{media.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Media Grid */}
|
||||
<div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{media.map((file) => (
|
||||
<Card key={file.id} className="overflow-hidden">
|
||||
<div className="aspect-square bg-muted relative">
|
||||
{file.signedUrl ? (
|
||||
<img
|
||||
src={file.signedUrl}
|
||||
alt={file.name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Image className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="truncate text-muted-foreground">
|
||||
{formatBytes(file.metadata?.size || 0)}
|
||||
</div>
|
||||
{file.signedUrl && (
|
||||
<a
|
||||
href={file.signedUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{file.created_at
|
||||
? format(new Date(file.created_at), 'MMM d, yyyy')
|
||||
: '—'}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md"><span className="line-clamp-2">{r.question.text}</span></TableCell><TableCell className="text-muted-foreground">{r.question.pack?.name || '—'}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div>}</TabsContent>}
|
||||
{canViewMatches && <TabsContent value="matches" className="mt-4">{matches.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No matches yet</p></Card> : <div className="grid gap-4 md:grid-cols-2">{matches.map((match) => (<Card key={match.id} className="hover:shadow-md transition-shadow"><CardHeader className="pb-2"><div className="flex items-start justify-between"><Badge variant="secondary">{matchTypeLabels[match.match_type]}</Badge>{match.is_new && <Badge variant="default" className="text-xs">New</Badge>}</div><CardDescription className="line-clamp-2 mt-2">{match.question?.text || 'Unknown question'}</CardDescription></CardHeader><CardContent><div className="flex items-center justify-between"><div className="flex items-center gap-1 text-sm text-muted-foreground"><MessageCircle className="h-4 w-4" />{match.message_count} message{match.message_count !== 1 ? 's' : ''}</div>{canViewChats && <Link to={`/users/${userId}/matches/${match.id}`}><Button variant="ghost" size="sm">View Chat<ChevronRight className="ml-1 h-4 w-4" /></Button></Link>}</div></CardContent></Card>))}</div>}</TabsContent>}
|
||||
{canViewMedia && <TabsContent value="media" className="mt-4">{mediaMessages.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><Image className="h-12 w-12 text-muted-foreground mb-4" /><p className="text-muted-foreground">No media uploaded</p></Card> : <div className="space-y-4"><div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">{mediaMessages.map((message) => (<Card key={message.id} className="overflow-hidden"><div className="aspect-square bg-muted relative">
|
||||
{message.media_type === 'video' ? (
|
||||
<AdminDecryptedVideo messageId={message.id} />
|
||||
) : (
|
||||
<AdminDecryptedImage messageId={message.id} alt={`Media from ${message.created_at}`} />
|
||||
)}
|
||||
</div><CardContent className="p-3"><div className="flex items-center justify-between text-sm"><div className="truncate text-muted-foreground">{message.media_type === 'video' ? 'Video' : 'Image'}</div>{canViewChats && message.match_id && <Link to={`/users/${userId}/matches/${message.match_id}`} className="text-primary hover:underline text-xs">View Chat</Link>}</div><div className="text-xs text-muted-foreground mt-1">{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}</div></CardContent></Card>))}</div></div>}</TabsContent>}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user