diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx new file mode 100644 index 0000000..f68372d --- /dev/null +++ b/app/admin/layout.tsx @@ -0,0 +1,62 @@ +import { redirect } from 'next/navigation'; +import { auth } from '@/lib/auth'; +import { Header } from '@/components/layout'; +import Link from 'next/link'; +import { LayoutDashboard, Users } from 'lucide-react'; + +export default async function AdminLayout({ + children, +}: { + children: React.ReactNode; +}) { + const session = await auth(); + + if (!session?.user?.isAdmin) { + redirect('/'); + } + + return ( +
+
+
+ {/* Mobile Nav */} +
+ +
+ {/* Desktop Nav */} + +
+ {children} +
+
+
+ ); +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..da936e8 --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,116 @@ +import { Metadata } from 'next'; +import { db } from '@/lib/db'; +import { auth } from '@/lib/auth'; +import { redirect } from 'next/navigation'; +import { getCachedTotalStorage } from '@/lib/admin-stats'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Users, Folder, Video, MessageSquare, Mic, HardDrive } from 'lucide-react'; + +export const metadata: Metadata = { + title: 'Admin Dashboard | OpenFrame', + description: 'Admin overview dashboard', +}; + +function formatBytes(bytes: number, decimals = 2) { + if (bytes < 0) return 'Error Fetching'; + if (!+bytes) return '0 Bytes'; + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; +} + +export default async function AdminDashboardPage() { + const session = await auth(); + if (!session?.user?.isAdmin) { + redirect('/'); + } + + // 1. Database Stats + const [ + totalUsers, + totalProjects, + totalVideos, + totalComments, + totalVoiceComments, + ] = await Promise.all([ + db.user.count(), + db.project.count(), + db.video.count(), + db.comment.count(), + db.comment.count({ + where: { voiceUrl: { not: null } }, + }), + ]); + + // 2. Storage Stats (Cached) + const totalStorageBytes = await getCachedTotalStorage(); + + return ( +
+
+

Dashboard Overview

+
+
+ + + Total Users + + + +
{totalUsers}
+
+
+ + + Workspaces & Projects + + + +
{totalProjects}
+

+ Total active projects on the platform +

+
+
+ + + Active Videos + + +
{totalVideos}
+
+
+ + + Total Comments + + + +
{totalComments}
+
+
+ + + Voice Recordings + + + +
{totalVoiceComments}
+
+
+ + + Cloudflare R2 Storage + + + +
{formatBytes(totalStorageBytes)}
+
+
+
+
+ ); +} diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx new file mode 100644 index 0000000..abc3889 --- /dev/null +++ b/app/admin/users/page.tsx @@ -0,0 +1,168 @@ +import { Metadata } from 'next'; +import { db } from '@/lib/db'; +import { auth } from '@/lib/auth'; +import { redirect } from 'next/navigation'; +import { getCachedUserVoiceStorage } from '@/lib/admin-stats'; +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { format } from 'date-fns'; + +export const metadata: Metadata = { + title: 'Manage Users | Admin', +}; + +function formatBytes(bytes: number, decimals = 2) { + if (bytes < 0) return 'Error Fetching'; + if (!+bytes) return '0 Bytes'; + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; +} + +export default async function AdminUsersPage({ + searchParams +}: { + searchParams: { page?: string } +}) { + const session = await auth(); + if (!session?.user?.isAdmin) { + redirect('/'); + } + + const page = Number(searchParams?.page) || 1; + const pageSize = 20; + const skip = (page - 1) * pageSize; + + // Fetch users with their counts and total count + const [users, totalUsers] = await Promise.all([ + db.user.findMany({ + skip, + take: pageSize, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + name: true, + email: true, + createdAt: true, + _count: { + select: { + ownedWorkspaces: true, + projects: true, + comments: true, + } + } + } + }), + db.user.count() + ]); + + const totalPages = Math.ceil(totalUsers / pageSize); + + // Determine voice storage per user (Cached) + const userStorage = await getCachedUserVoiceStorage(); + + return ( +
+
+

Users

+
+ + + + All Users + + A comprehensive list of all {totalUsers} users registered on the platform. + + + +
+ + + + User + Joined Date + Workspaces Owned + Projects Owned + Total Comments + Voice Storage + + + + {users.length === 0 ? ( + + + No users found. + + + ) : ( + users.map((user) => ( + + +
+ {user.name || 'Anonymous'} + {user.email} +
+
+ + {format(new Date(user.createdAt), 'MMM dd, yyyy')} + + {user._count.ownedWorkspaces} + {user._count.projects} + {user._count.comments} + + {formatBytes(userStorage[user.id] || 0)} + +
+ )) + )} +
+
+
+ {/* Pagination */} + {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} + + +
+ )} +
+
+
+ ); +} diff --git a/app/api/admin/stats/route.ts b/app/api/admin/stats/route.ts new file mode 100644 index 0000000..654bc0c --- /dev/null +++ b/app/api/admin/stats/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { auth } from '@/lib/auth'; +import { getCachedTotalStorage } from '@/lib/admin-stats'; + +export async function GET() { + try { + const session = await auth(); + if (!session?.user?.isAdmin) { + return new NextResponse('Unauthorized', { status: 403 }); + } + + // 1. Database Stats + const [ + totalUsers, + totalProjects, + totalVideos, + totalComments, + totalVoiceComments, + ] = await Promise.all([ + db.user.count(), + db.project.count(), + db.video.count(), + db.comment.count(), + db.comment.count({ + where: { + voiceUrl: { + not: null, + } + } + }) + ]); + + // 2. Storage Stats (Cached) + const totalStorageBytes = await getCachedTotalStorage(); + + return NextResponse.json({ + totalUsers, + totalProjects, + totalVideos, + totalComments, + totalVoiceComments, + totalStorageBytes, + }); + } catch (error) { + console.error('[ADMIN_STATS_GET]', error); + return new NextResponse('Internal Error', { status: 500 }); + } +} diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts new file mode 100644 index 0000000..fa69863 --- /dev/null +++ b/app/api/admin/users/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { auth } from '@/lib/auth'; + +export async function GET() { + try { + const session = await auth(); + if (!session?.user?.isAdmin) { + return new NextResponse('Unauthorized', { status: 403 }); + } + + const users = await db.user.findMany({ + orderBy: { createdAt: 'desc' }, + select: { + id: true, + name: true, + email: true, + createdAt: true, + _count: { + select: { + ownedWorkspaces: true, + projects: true, + comments: true, + } + } + } + }); + + return NextResponse.json({ users }); + } catch (error) { + console.error('[ADMIN_USERS_GET]', error); + return new NextResponse('Internal Error', { status: 500 }); + } +} diff --git a/components/layout/header.tsx b/components/layout/header.tsx index e7c9d2e..eb53fb8 100644 --- a/components/layout/header.tsx +++ b/components/layout/header.tsx @@ -13,7 +13,8 @@ import { LogOut, User, Menu, - Keyboard + Keyboard, + LayoutDashboard } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { @@ -49,6 +50,7 @@ interface HeaderProps { name?: string | null; email?: string | null; image?: string | null; + isAdmin?: boolean; } | null; } @@ -90,6 +92,20 @@ export function Header({ user }: HeaderProps) { {item.label} ))} + {user?.isAdmin && ( + + + Admin Panel + + )} @@ -117,6 +133,20 @@ export function Header({ user }: HeaderProps) { {item.label} ))} + {user?.isAdmin && ( + + + Admin Panel + + )} {/* Right side */} @@ -149,6 +179,14 @@ export function Header({ user }: HeaderProps) { + {user.isAdmin && ( + + + + Admin Panel + + + )} diff --git a/components/ui/table.tsx b/components/ui/table.tsx new file mode 100644 index 0000000..a8d901b --- /dev/null +++ b/components/ui/table.tsx @@ -0,0 +1,101 @@ +"use client" + +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ) +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", className)} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
+ ) +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + + ) +} + +function TableCaption({ + className, + ...props +}: React.ComponentProps<"caption">) { + return ( +
+ ) +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/lib/admin-stats.ts b/lib/admin-stats.ts new file mode 100644 index 0000000..07fbe1a --- /dev/null +++ b/lib/admin-stats.ts @@ -0,0 +1,81 @@ +import { unstable_cache } from 'next/cache'; +import { db } from '@/lib/db'; +import { r2Client, R2_BUCKET_NAME } from '@/lib/r2'; +import { ListObjectsV2Command } from '@aws-sdk/client-s3'; + +// Cache for 10 minutes (600 seconds) +export const getCachedTotalStorage = unstable_cache( + async () => { + let totalStorageBytes = 0; + try { + let isTruncated = true; + let continuationToken: string | undefined = undefined; + + while (isTruncated) { + const commandParams: any = { Bucket: R2_BUCKET_NAME }; + if (continuationToken) commandParams.ContinuationToken = continuationToken; + + const data = await r2Client.send(new ListObjectsV2Command(commandParams)); + if (data.Contents) { + for (const item of data.Contents) { + totalStorageBytes += item.Size || 0; + } + } + isTruncated = data.IsTruncated ?? false; + continuationToken = data.NextContinuationToken; + } + } catch (err) { + console.error('Failed to fetch total storage stats:', err); + return -1; + } + return totalStorageBytes; + }, + ['admin-total-storage'], + { revalidate: 600 } +); + +export const getCachedUserVoiceStorage = unstable_cache( + async () => { + // Return a plain object so it maps cleanly out of unstable_cache across requests + const userStorage: Record = {}; + try { + const fileSizes = new Map(); + let isTruncated = true; + let continuationToken: string | undefined = undefined; + + while (isTruncated) { + const commandParams: any = { Bucket: R2_BUCKET_NAME }; + if (continuationToken) commandParams.ContinuationToken = continuationToken; + + const data = await r2Client.send(new ListObjectsV2Command(commandParams)); + if (data.Contents) { + for (const item of data.Contents) { + if (item.Key) fileSizes.set(item.Key, item.Size || 0); + } + } + isTruncated = data.IsTruncated ?? false; + continuationToken = data.NextContinuationToken; + } + + const voiceComments = await db.comment.findMany({ + where: { voiceUrl: { not: null }, authorId: { not: null } }, + select: { authorId: true, voiceUrl: true } + }); + + for (const comment of voiceComments) { + if (!comment.authorId || !comment.voiceUrl) continue; + const keyParts = comment.voiceUrl.split('/'); + const filename = keyParts[keyParts.length - 1]; + const r2Key = `voice/${filename}`; + const size = fileSizes.get(r2Key) || 0; + + userStorage[comment.authorId] = (userStorage[comment.authorId] || 0) + size; + } + } catch (err) { + console.error('Failed to parse user storage:', err); + } + return userStorage; + }, + ['admin-user-voice-storage'], + { revalidate: 600 } +); diff --git a/lib/auth.ts b/lib/auth.ts index ed7e232..c358b03 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -63,6 +63,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ if (token.sub && session.user) { session.user.id = token.sub; session.user.name = token.name || null; + session.user.isAdmin = token.isAdmin as boolean; } return session; }, @@ -70,7 +71,17 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ if (user) { token.sub = user.id; token.name = user.name; + token.email = user.email; // explicitly ensure email is in the token } + + // Check if user is admin based on emails list on EVERY request to ensure env changes are picked up + if (token.email) { + const adminEmails = process.env.ADMIN_EMAILS + ? process.env.ADMIN_EMAILS.split(',').map((e: string) => e.trim().toLowerCase()) + : []; + token.isAdmin = adminEmails.includes((token.email as string).toLowerCase()); + } + return token; }, }, @@ -87,8 +98,8 @@ export async function checkProjectAccess( // Get project membership const projectMember = userId ? await db.projectMember.findUnique({ - where: { projectId_userId: { projectId: project.id, userId } }, - }) + where: { projectId_userId: { projectId: project.id, userId } }, + }) : null; const isProjectMember = !!projectMember; const isProjectAdmin = projectMember?.role === ProjectMemberRole.ADMIN; @@ -138,8 +149,8 @@ export async function checkWorkspaceAccess( // Get workspace membership const workspaceMember = userId ? await db.workspaceMember.findUnique({ - where: { workspaceId_userId: { workspaceId: workspace.id, userId } }, - }) + where: { workspaceId_userId: { workspaceId: workspace.id, userId } }, + }) : null; const isMember = !!workspaceMember; const isAdmin = workspaceMember?.role === WorkspaceMemberRole.ADMIN; diff --git a/types/next-auth.d.ts b/types/next-auth.d.ts new file mode 100644 index 0000000..e418902 --- /dev/null +++ b/types/next-auth.d.ts @@ -0,0 +1,20 @@ +import NextAuth, { type DefaultSession } from 'next-auth'; + +declare module 'next-auth' { + interface Session { + user: { + id: string; + isAdmin: boolean; + } & DefaultSession['user']; + } + + interface User { + isAdmin?: boolean; + } +} + +declare module 'next-auth/jwt' { + interface JWT { + isAdmin?: boolean; + } +}