diff --git a/backend/main.py b/backend/main.py index fea8a2b..7b690b6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,5 +1,5 @@ from fastapi import FastAPI -from routers import auth, shows, venues, songs, social, tours, artists, preferences, reviews, badges, nicknames, moderation, attendance, groups, users, search, performances, notifications, feed, leaderboards, stats +from routers import auth, shows, venues, songs, social, tours, artists, preferences, reviews, badges, nicknames, moderation, attendance, groups, users, search, performances, notifications, feed, leaderboards, stats, admin from fastapi.middleware.cors import CORSMiddleware @@ -34,6 +34,7 @@ app.include_router(notifications.router) app.include_router(feed.router) app.include_router(leaderboards.router) app.include_router(stats.router) +app.include_router(admin.router) @app.get("/") def read_root(): diff --git a/backend/routers/admin.py b/backend/routers/admin.py new file mode 100644 index 0000000..dc45d92 --- /dev/null +++ b/backend/routers/admin.py @@ -0,0 +1,434 @@ +""" +Admin Router - Protected endpoints for admin users only. +User management, content CRUD, platform stats. +""" +from typing import List, Optional +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlmodel import Session, select, func +from pydantic import BaseModel +from database import get_session +from models import User, Profile, Show, Song, Venue, Tour, Rating, Comment, Review, Attendance +from dependencies import RoleChecker +from auth import get_password_hash + +router = APIRouter(prefix="/admin", tags=["admin"]) + +# Only admins can access these endpoints +allow_admin = RoleChecker(["admin"]) + + +# ============ SCHEMAS ============ + +class UserUpdate(BaseModel): + role: Optional[str] = None + is_active: Optional[bool] = None + email_verified: Optional[bool] = None + +class UserListItem(BaseModel): + id: int + email: str + role: str + is_active: bool + email_verified: bool + created_at: Optional[datetime] = None + + class Config: + from_attributes = True + +class ShowCreate(BaseModel): + date: datetime + vertical_id: int + venue_id: Optional[int] = None + tour_id: Optional[int] = None + notes: Optional[str] = None + bandcamp_link: Optional[str] = None + nugs_link: Optional[str] = None + youtube_link: Optional[str] = None + +class ShowUpdate(BaseModel): + date: Optional[datetime] = None + venue_id: Optional[int] = None + tour_id: Optional[int] = None + notes: Optional[str] = None + bandcamp_link: Optional[str] = None + nugs_link: Optional[str] = None + youtube_link: Optional[str] = None + +class SongCreate(BaseModel): + title: str + vertical_id: int + original_artist: Optional[str] = None + notes: Optional[str] = None + youtube_link: Optional[str] = None + +class SongUpdate(BaseModel): + title: Optional[str] = None + original_artist: Optional[str] = None + notes: Optional[str] = None + youtube_link: Optional[str] = None + +class VenueCreate(BaseModel): + name: str + city: str + state: Optional[str] = None + country: str = "USA" + capacity: Optional[int] = None + +class VenueUpdate(BaseModel): + name: Optional[str] = None + city: Optional[str] = None + state: Optional[str] = None + country: Optional[str] = None + capacity: Optional[int] = None + +class TourCreate(BaseModel): + name: str + vertical_id: int + start_date: Optional[datetime] = None + end_date: Optional[datetime] = None + +class TourUpdate(BaseModel): + name: Optional[str] = None + start_date: Optional[datetime] = None + end_date: Optional[datetime] = None + +class PlatformStats(BaseModel): + total_users: int + verified_users: int + total_shows: int + total_songs: int + total_venues: int + total_ratings: int + total_reviews: int + total_comments: int + + +# ============ STATS ============ + +@router.get("/stats", response_model=PlatformStats) +def get_platform_stats( + session: Session = Depends(get_session), + _: User = Depends(allow_admin) +): + """Get platform-wide statistics""" + return PlatformStats( + total_users=session.exec(select(func.count(User.id))).one(), + verified_users=session.exec(select(func.count(User.id)).where(User.email_verified == True)).one(), + total_shows=session.exec(select(func.count(Show.id))).one(), + total_songs=session.exec(select(func.count(Song.id))).one(), + total_venues=session.exec(select(func.count(Venue.id))).one(), + total_ratings=session.exec(select(func.count(Rating.id))).one(), + total_reviews=session.exec(select(func.count(Review.id))).one(), + total_comments=session.exec(select(func.count(Comment.id))).one(), + ) + + +# ============ USERS ============ + +@router.get("/users") +def list_users( + skip: int = 0, + limit: int = 50, + search: Optional[str] = None, + role: Optional[str] = None, + session: Session = Depends(get_session), + _: User = Depends(allow_admin) +): + """List all users with optional filtering""" + query = select(User) + + if search: + query = query.where(User.email.contains(search)) + if role: + query = query.where(User.role == role) + + query = query.offset(skip).limit(limit) + users = session.exec(query).all() + + # Get profiles for usernames + result = [] + for user in users: + profile = session.exec(select(Profile).where(Profile.user_id == user.id)).first() + result.append({ + "id": user.id, + "email": user.email, + "username": profile.username if profile else None, + "role": user.role, + "is_active": user.is_active, + "email_verified": user.email_verified, + }) + + return result + + +@router.get("/users/{user_id}") +def get_user( + user_id: int, + session: Session = Depends(get_session), + _: User = Depends(allow_admin) +): + """Get user details with activity stats""" + user = session.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + profile = session.exec(select(Profile).where(Profile.user_id == user.id)).first() + + return { + "id": user.id, + "email": user.email, + "username": profile.username if profile else None, + "role": user.role, + "is_active": user.is_active, + "email_verified": user.email_verified, + "bio": user.bio, + "stats": { + "ratings": session.exec(select(func.count(Rating.id)).where(Rating.user_id == user.id)).one(), + "reviews": session.exec(select(func.count(Review.id)).where(Review.user_id == user.id)).one(), + "comments": session.exec(select(func.count(Comment.id)).where(Comment.user_id == user.id)).one(), + "attendances": session.exec(select(func.count(Attendance.id)).where(Attendance.user_id == user.id)).one(), + } + } + + +@router.patch("/users/{user_id}") +def update_user( + user_id: int, + update: UserUpdate, + session: Session = Depends(get_session), + admin: User = Depends(allow_admin) +): + """Update user role, status, or verification""" + user = session.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Prevent admin from demoting themselves + if user.id == admin.id and update.role and update.role != "admin": + raise HTTPException(status_code=400, detail="Cannot demote yourself") + + if update.role is not None: + user.role = update.role + if update.is_active is not None: + user.is_active = update.is_active + if update.email_verified is not None: + user.email_verified = update.email_verified + + session.add(user) + session.commit() + session.refresh(user) + + return {"message": "User updated", "user_id": user.id} + + +# ============ SHOWS ============ + +@router.post("/shows") +def create_show( + show_data: ShowCreate, + session: Session = Depends(get_session), + _: User = Depends(allow_admin) +): + """Create a new show""" + show = Show(**show_data.model_dump()) + session.add(show) + session.commit() + session.refresh(show) + return show + + +@router.patch("/shows/{show_id}") +def update_show( + show_id: int, + update: ShowUpdate, + session: Session = Depends(get_session), + _: User = Depends(allow_admin) +): + """Update show details""" + show = session.get(Show, show_id) + if not show: + raise HTTPException(status_code=404, detail="Show not found") + + for key, value in update.model_dump(exclude_unset=True).items(): + setattr(show, key, value) + + session.add(show) + session.commit() + session.refresh(show) + return show + + +@router.delete("/shows/{show_id}") +def delete_show( + show_id: int, + session: Session = Depends(get_session), + _: User = Depends(allow_admin) +): + """Delete a show""" + show = session.get(Show, show_id) + if not show: + raise HTTPException(status_code=404, detail="Show not found") + + session.delete(show) + session.commit() + return {"message": "Show deleted", "show_id": show_id} + + +# ============ SONGS ============ + +@router.post("/songs") +def create_song( + song_data: SongCreate, + session: Session = Depends(get_session), + _: User = Depends(allow_admin) +): + """Create a new song""" + song = Song(**song_data.model_dump()) + session.add(song) + session.commit() + session.refresh(song) + return song + + +@router.patch("/songs/{song_id}") +def update_song( + song_id: int, + update: SongUpdate, + session: Session = Depends(get_session), + _: User = Depends(allow_admin) +): + """Update song details""" + song = session.get(Song, song_id) + if not song: + raise HTTPException(status_code=404, detail="Song not found") + + for key, value in update.model_dump(exclude_unset=True).items(): + setattr(song, key, value) + + session.add(song) + session.commit() + session.refresh(song) + return song + + +@router.delete("/songs/{song_id}") +def delete_song( + song_id: int, + session: Session = Depends(get_session), + _: User = Depends(allow_admin) +): + """Delete a song""" + song = session.get(Song, song_id) + if not song: + raise HTTPException(status_code=404, detail="Song not found") + + session.delete(song) + session.commit() + return {"message": "Song deleted", "song_id": song_id} + + +# ============ VENUES ============ + +@router.post("/venues") +def create_venue( + venue_data: VenueCreate, + session: Session = Depends(get_session), + _: User = Depends(allow_admin) +): + """Create a new venue""" + venue = Venue(**venue_data.model_dump()) + session.add(venue) + session.commit() + session.refresh(venue) + return venue + + +@router.patch("/venues/{venue_id}") +def update_venue( + venue_id: int, + update: VenueUpdate, + session: Session = Depends(get_session), + _: User = Depends(allow_admin) +): + """Update venue details""" + venue = session.get(Venue, venue_id) + if not venue: + raise HTTPException(status_code=404, detail="Venue not found") + + for key, value in update.model_dump(exclude_unset=True).items(): + setattr(venue, key, value) + + session.add(venue) + session.commit() + session.refresh(venue) + return venue + + +@router.delete("/venues/{venue_id}") +def delete_venue( + venue_id: int, + session: Session = Depends(get_session), + _: User = Depends(allow_admin) +): + """Delete a venue""" + venue = session.get(Venue, venue_id) + if not venue: + raise HTTPException(status_code=404, detail="Venue not found") + + session.delete(venue) + session.commit() + return {"message": "Venue deleted", "venue_id": venue_id} + + +# ============ TOURS ============ + +@router.post("/tours") +def create_tour( + tour_data: TourCreate, + session: Session = Depends(get_session), + _: User = Depends(allow_admin) +): + """Create a new tour""" + tour = Tour(**tour_data.model_dump()) + session.add(tour) + session.commit() + session.refresh(tour) + return tour + + +@router.patch("/tours/{tour_id}") +def update_tour( + tour_id: int, + update: TourUpdate, + session: Session = Depends(get_session), + _: User = Depends(allow_admin) +): + """Update tour details""" + tour = session.get(Tour, tour_id) + if not tour: + raise HTTPException(status_code=404, detail="Tour not found") + + for key, value in update.model_dump(exclude_unset=True).items(): + setattr(tour, key, value) + + session.add(tour) + session.commit() + session.refresh(tour) + return tour + + +@router.delete("/tours/{tour_id}") +def delete_tour( + tour_id: int, + session: Session = Depends(get_session), + _: User = Depends(allow_admin) +): + """Delete a tour""" + tour = session.get(Tour, tour_id) + if not tour: + raise HTTPException(status_code=404, detail="Tour not found") + + session.delete(tour) + session.commit() + return {"message": "Tour deleted", "tour_id": tour_id} diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx index 92a8ab0..caf6120 100644 --- a/frontend/app/admin/page.tsx +++ b/frontend/app/admin/page.tsx @@ -1,30 +1,410 @@ "use client" +import { useEffect, useState } from "react" +import { useAuth } from "@/contexts/auth-context" +import { useRouter } from "next/navigation" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Badge } from "@/components/ui/badge" +import { + Users, Music2, MapPin, Calendar, BarChart3, + Shield, ShieldCheck, Search, Check, X, Edit +} from "lucide-react" +import { getApiUrl } from "@/lib/api-config" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog" +import { Label } from "@/components/ui/label" -export default function AdminDashboard() { - return ( -
-

Admin Dashboard

-
- - - Pending Nicknames - - -
--
-
-
- - - Pending Reports - - -
--
-
-
+interface PlatformStats { + total_users: number + verified_users: number + total_shows: number + total_songs: number + total_venues: number + total_ratings: number + total_reviews: number + total_comments: number +} + +interface UserItem { + id: number + email: string + username: string | null + role: string + is_active: boolean + email_verified: boolean +} + +export default function AdminPage() { + const { user, token } = useAuth() + const router = useRouter() + const [stats, setStats] = useState(null) + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + const [search, setSearch] = useState("") + const [editingUser, setEditingUser] = useState(null) + + useEffect(() => { + if (!user) { + router.push("/login") + return + } + if (user.role !== "admin") { + router.push("/") + return + } + fetchData() + }, [user, router]) + + const fetchData = async () => { + if (!token) return + + try { + const [statsRes, usersRes] = await Promise.all([ + fetch(`${getApiUrl()}/admin/stats`, { + headers: { Authorization: `Bearer ${token}` } + }), + fetch(`${getApiUrl()}/admin/users`, { + headers: { Authorization: `Bearer ${token}` } + }) + ]) + + if (statsRes.ok) setStats(await statsRes.json()) + if (usersRes.ok) setUsers(await usersRes.json()) + } catch (e) { + console.error("Failed to fetch admin data", e) + } finally { + setLoading(false) + } + } + + const updateUser = async (userId: number, updates: Partial) => { + if (!token) return + + try { + const res = await fetch(`${getApiUrl()}/admin/users/${userId}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }, + body: JSON.stringify(updates) + }) + + if (res.ok) { + fetchData() + setEditingUser(null) + } + } catch (e) { + console.error("Failed to update user", e) + } + } + + const filteredUsers = users.filter(u => + u.email.toLowerCase().includes(search.toLowerCase()) || + (u.username && u.username.toLowerCase().includes(search.toLowerCase())) + ) + + if (loading) { + return ( +
+
+
+
+ {[1, 2, 3, 4].map(i =>
)} +
+
-

Select a category from the sidebar to manage content.

+ ) + } + + if (!user || user.role !== "admin") { + return null + } + + return ( +
+
+

+ + Admin Dashboard +

+
+ + {stats && ( +
+ + +
+ +
+

{stats.total_users}

+

Total Users

+
+
+
+
+ + +
+ +
+

{stats.verified_users}

+

Verified

+
+
+
+
+ + +
+ +
+

{stats.total_shows}

+

Shows

+
+
+
+
+ + +
+ +
+

{stats.total_songs}

+

Songs

+
+
+
+
+
+ )} + + + + Users + Content + + + +
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+
+ + + + + + + + + + + + + + + {filteredUsers.map(u => ( + + + + + + + + ))} + +
UserRoleStatusVerifiedActions
+
+

{u.username || "No username"}

+

{u.email}

+
+
+ + {u.role} + + + {u.is_active ? ( + Active + ) : ( + Banned + )} + + {u.email_verified ? ( + + ) : ( + + )} + + +
+
+
+
+ + +
+ + + + + Shows + + + +

+ {stats?.total_shows || 0} shows in database +

+ +
+
+ + + + + Songs + + + +

+ {stats?.total_songs || 0} songs in database +

+ +
+
+ + + + + Venues + + + +

+ {stats?.total_venues || 0} venues in database +

+ +
+
+ + + + + Activity + + + +
+

{stats?.total_ratings || 0} ratings

+

{stats?.total_reviews || 0} reviews

+

{stats?.total_comments || 0} comments

+
+
+
+
+
+
+ + setEditingUser(null)}> + + + Edit User + + {editingUser && ( +
+
+

{editingUser.email}

+

{editingUser.username || "No username"}

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ )} + + + + +
+
) }