Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
354 changes: 354 additions & 0 deletions app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,354 @@
'use client';

import React, { useState, useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { getFirestore, doc, getDoc } from 'firebase/firestore';
import { app } from '../lib/firebase-config';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
} from '@/components/ui/alert-dialog'; // Slightly condensed import lines
import { toast } from "sonner";


// Define interfaces for data structures
interface UserSearchResult {
uid: string;
email?: string;
displayName?: string;
photoURL?: string;
disabled?: boolean;
creationTime?: string;
lastSignInTime?: string;
}

interface UserVoice {
id: string;
name: string;
createdAt: string; // Assuming ISO string from backend
audioUrl: string;
// Add other relevant fields from your voice data structure
}

export default function AdminPage() {
const { user, loading: authLoading } = useAuth();
const [isAdmin, setIsAdmin] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);

const [searchQuery, setSearchQuery] = useState<string>('');
const [isSearching, setIsSearching] = useState<boolean>(false);
const [searchResults, setSearchResults] = useState<UserSearchResult[]>([]);
const [searchError, setSearchError] = useState<string | null>(null);

const [selectedUser, setSelectedUser] = useState<UserSearchResult | null>(null);
const [userVoices, setUserVoices] = useState<UserVoice[]>([]);
const [isLoadingVoices, setIsLoadingVoices] = useState<boolean>(false);
const [voicesError, setVoicesError] = useState<string | null>(null);

const [voiceToDelete, setVoiceToDelete] = useState<UserVoice | null>(null);
const [isDeleting, setIsDeleting] = useState<boolean>(false);


useEffect(() => {
const checkAdminStatus = async () => {
if (authLoading) return; // Wait for auth state to load
if (!user) {
setIsAdmin(false);
setIsLoading(false);
setError("Authentication required.");
return;
}

setIsLoading(true);
setError(null);
try {
const db = getFirestore(app);
const roleDocRef = doc(db, 'roles', user.uid);
const roleDocSnap = await getDoc(roleDocRef);

if (roleDocSnap.exists() && roleDocSnap.data()?.isAdmin === true) {
setIsAdmin(true);
} else {
setIsAdmin(false);
setError("Access Denied. Administrator privileges required.");
}
} catch (err: any) {
console.error("Error checking admin status:", err);
setError("Error checking admin status: " + err.message);
setIsAdmin(false);
} finally {
setIsLoading(false);
}
};

checkAdminStatus();
}, [user, authLoading]); // Rerun when user or authLoading changes

// 2. Search Users Handler
const handleSearch = async (e?: React.FormEvent<HTMLFormElement>) => {
e?.preventDefault(); // Prevent form submission if used in a form
if (searchQuery.length < 3) {
setSearchError("Search query must be at least 3 characters long.");
return;
}
setIsSearching(true);
setSearchError(null);
setSearchResults([]);
setSelectedUser(null); // Clear selection on new search
setUserVoices([]); // Clear voices on new search
setVoicesError(null);

try {
const response = await fetch('/api/admin/search-users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: searchQuery }),
});

const data = await response.json();

if (!response.ok) {
throw new Error(data.error || `Error ${response.status}`);
}

setSearchResults(data.users || []);
if (!data.users || data.users.length === 0) {
setSearchError("No users found matching your query.");
}
} catch (err: any) {
console.error("Error searching users:", err);
setSearchError("Failed to search users: " + err.message);
} finally {
setIsSearching(false);
}
};

// 3. Fetch User Voices Handler
const fetchUserVoices = async (targetUserId: string) => {
setIsLoadingVoices(true);
setVoicesError(null);
setUserVoices([]);

try {
const response = await fetch('/api/admin/get-user-voices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetUserId }),
});

const data = await response.json();

if (!response.ok) {
throw new Error(data.error || `Error ${response.status}`);
}
setUserVoices(data.voices || []);
if (!data.voices || data.voices.length === 0) {
setVoicesError("This user has no voice files.");
}
} catch (err: any) {
console.error("Error fetching user voices:", err);
setVoicesError("Failed to fetch voices: " + err.message);
} finally {
setIsLoadingVoices(false);
}
};

// 4. Delete User Voice Handler
const handleDeleteVoice = async () => {
if (!voiceToDelete || !selectedUser) return;

setIsDeleting(true);
try {
const response = await fetch('/api/admin/delete-user-voice', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
targetUserId: selectedUser.uid,
voiceId: voiceToDelete.id,
audioUrl: voiceToDelete.audioUrl // Pass URL for storage deletion
}),
});

const data = await response.json();

if (!response.ok) {
throw new Error(data.error || `Error ${response.status}`);
}

toast.success(`Voice "${voiceToDelete.name}" deleted successfully.`);
// Refresh voices list
setUserVoices(currentVoices => currentVoices.filter(v => v.id !== voiceToDelete.id));
setVoiceToDelete(null); // Close dialog

} catch (err: any) {
console.error("Error deleting voice:", err);
toast.error(`Failed to delete voice: ${err.message}`);
} finally {
setIsDeleting(false);
}
};


// Render Loading/Error/Access Denied States
if (isLoading || authLoading) {
return <div className="container mx-auto p-4 text-center">Loading Admin Panel...</div>;
}

if (error) {
return <div className="container mx-auto p-4 text-center text-red-600">{error}</div>;
}

if (!isAdmin) {
// Should generally be caught by the error state above, but as a fallback
return <div className="container mx-auto p-4 text-center text-red-600">Access Denied.</div>;
}

// Render Admin UI
return (
<div className="container mx-auto p-4 space-y-6">
<h1 className="text-3xl font-bold">Admin Panel</h1>

{/* User Search Section */}
<Card>
<CardHeader>
<CardTitle>Search Users</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSearch} className="flex gap-2">
<Input
type="email"
placeholder="Search by email..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="flex-grow"
/>
<Button type="submit" disabled={isSearching || searchQuery.length < 3}>
{isSearching ? 'Searching...' : 'Search'}
</Button>
</form>
{searchError && <p className="text-red-500 mt-2 text-sm">{searchError}</p>}
</CardContent>
{searchResults.length > 0 && (
<CardFooter className="flex flex-col items-start">
<h3 className="text-lg font-semibold mb-2">Search Results:</h3>
<div className="w-full border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Display Name</TableHead>
<TableHead>UID</TableHead>
<TableHead>Created</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{searchResults.map((userResult) => (
<TableRow key={userResult.uid} className={selectedUser?.uid === userResult.uid ? 'bg-muted/50' : ''}>
<TableCell>{userResult.email || 'N/A'}</TableCell>
<TableCell>{userResult.displayName || 'N/A'}</TableCell>
<TableCell className="text-xs">{userResult.uid}</TableCell>
<TableCell>{userResult.creationTime ? new Date(userResult.creationTime).toLocaleDateString() : 'N/A'}</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
onClick={() => {
setSelectedUser(userResult);
fetchUserVoices(userResult.uid);
}}
disabled={isLoadingVoices || selectedUser?.uid === userResult.uid}
>
{isLoadingVoices && selectedUser?.uid === userResult.uid ? 'Loading...' : 'View Voices'}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardFooter>
)}
</Card>

{/* User Voices Section */}
{selectedUser && (
<Card>
<CardHeader>
<CardTitle>Voices for {selectedUser.email || selectedUser.displayName || selectedUser.uid}</CardTitle>
</CardHeader>
<CardContent>
{isLoadingVoices && <p>Loading voices...</p>}
{voicesError && <p className="text-red-500">{voicesError}</p>}
{!isLoadingVoices && !voicesError && userVoices.length > 0 && (
<div className="w-full border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Created At</TableHead>
<TableHead>Audio URL</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{userVoices.map((voice) => (
<TableRow key={voice.id}>
<TableCell>{voice.name}</TableCell>
<TableCell>{new Date(voice.createdAt).toLocaleString()}</TableCell>
<TableCell>
{voice.audioUrl ? (
<a href={voice.audioUrl} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline text-xs break-all">
Link
</a>
) : 'N/A'}
</TableCell>
<TableCell className="text-right">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" onClick={() => setVoiceToDelete(voice)}>
Delete
</Button>
</AlertDialogTrigger>
{/* Content is separate to avoid rendering many dialogs */}
</AlertDialog>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{!isLoadingVoices && !voicesError && userVoices.length === 0 && !voicesError && <p>No voices found for this user.</p> }
</CardContent>
</Card>
)}

{/* Confirmation Dialog - Rendered once outside the map */}
<AlertDialog open={!!voiceToDelete} onOpenChange={(open: boolean) => !open && setVoiceToDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the voice
<span className="font-medium"> "{voiceToDelete?.name}" </span>
and its associated audio file from storage.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteVoice} disabled={isDeleting} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{isDeleting ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

</div>
);
}
Loading