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;
+ }
+}