mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat: Implement admin dashboard with user management, statistics, and role-based access control.
This commit is contained in:
@@ -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 (
|
||||||
|
<div className="relative flex min-h-screen flex-col">
|
||||||
|
<Header user={session.user} />
|
||||||
|
<div className="container mx-auto px-4 md:px-8 flex-1 items-start md:grid md:grid-cols-[220px_minmax(0,1fr)] md:gap-6 lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-10">
|
||||||
|
{/* Mobile Nav */}
|
||||||
|
<div className="md:hidden py-4 border-b mb-4">
|
||||||
|
<nav className="flex items-center gap-4 overflow-x-auto">
|
||||||
|
<Link href="/admin" className="flex items-center gap-2 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50">
|
||||||
|
<LayoutDashboard className="h-4 w-4" />
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/users" className="flex items-center gap-2 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
Users
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{/* Desktop Nav */}
|
||||||
|
<aside className="fixed top-14 z-30 -ml-2 hidden h-[calc(100vh-3.5rem)] w-full shrink-0 md:sticky md:block">
|
||||||
|
<div className="h-full py-6 pr-6 lg:py-8">
|
||||||
|
<nav className="flex flex-col gap-2">
|
||||||
|
<Link
|
||||||
|
href="/admin"
|
||||||
|
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="h-4 w-4" />
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/admin/users"
|
||||||
|
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
Users
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main className="flex w-full flex-col overflow-hidden py-0 md:py-6 lg:py-8">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex-1 space-y-4 px-4 md:px-8">
|
||||||
|
<div className="flex items-center justify-between space-y-2">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Dashboard Overview</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
|
||||||
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{totalUsers}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Workspaces & Projects</CardTitle>
|
||||||
|
<Folder className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{totalProjects}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Total active projects on the platform
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Active Videos</CardTitle>
|
||||||
|
<Video className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{totalVideos}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total Comments</CardTitle>
|
||||||
|
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{totalComments}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Voice Recordings</CardTitle>
|
||||||
|
<Mic className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{totalVoiceComments}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Cloudflare R2 Storage</CardTitle>
|
||||||
|
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{formatBytes(totalStorageBytes)}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex-1 space-y-4 px-4 md:px-8">
|
||||||
|
<div className="flex items-center justify-between space-y-2">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Users</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>All Users</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
A comprehensive list of all {totalUsers} users registered on the platform.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>User</TableHead>
|
||||||
|
<TableHead>Joined Date</TableHead>
|
||||||
|
<TableHead className="text-center">Workspaces Owned</TableHead>
|
||||||
|
<TableHead className="text-center">Projects Owned</TableHead>
|
||||||
|
<TableHead className="text-center">Total Comments</TableHead>
|
||||||
|
<TableHead className="text-right">Voice Storage</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="h-24 text-center">
|
||||||
|
No users found.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
users.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{user.name || 'Anonymous'}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{user.email}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{format(new Date(user.createdAt), 'MMM dd, yyyy')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">{user._count.ownedWorkspaces}</TableCell>
|
||||||
|
<TableCell className="text-center">{user._count.projects}</TableCell>
|
||||||
|
<TableCell className="text-center">{user._count.comments}</TableCell>
|
||||||
|
<TableCell className="text-right text-muted-foreground text-sm">
|
||||||
|
{formatBytes(userStorage[user.id] || 0)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-end space-x-2 py-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page <= 1}
|
||||||
|
asChild={page > 1}
|
||||||
|
>
|
||||||
|
{page > 1 ? (
|
||||||
|
<Link href={`/admin/users?page=${page - 1}`}>Previous</Link>
|
||||||
|
) : (
|
||||||
|
"Previous"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
asChild={page < totalPages}
|
||||||
|
>
|
||||||
|
{page < totalPages ? (
|
||||||
|
<Link href={`/admin/users?page=${page + 1}`}>Next</Link>
|
||||||
|
) : (
|
||||||
|
"Next"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,8 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
User,
|
User,
|
||||||
Menu,
|
Menu,
|
||||||
Keyboard
|
Keyboard,
|
||||||
|
LayoutDashboard
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -49,6 +50,7 @@ interface HeaderProps {
|
|||||||
name?: string | null;
|
name?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
image?: string | null;
|
image?: string | null;
|
||||||
|
isAdmin?: boolean;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +92,20 @@ export function Header({ user }: HeaderProps) {
|
|||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
{user?.isAdmin && (
|
||||||
|
<Link
|
||||||
|
href="/admin"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-md transition-colors',
|
||||||
|
pathname.startsWith('/admin')
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'hover:bg-accent hover:text-accent-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="h-4 w-4" />
|
||||||
|
Admin Panel
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
@@ -117,6 +133,20 @@ export function Header({ user }: HeaderProps) {
|
|||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
{user?.isAdmin && (
|
||||||
|
<Link
|
||||||
|
href="/admin"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-md transition-colors',
|
||||||
|
pathname.startsWith('/admin')
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'hover:bg-accent/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="h-4 w-4" />
|
||||||
|
Admin Panel
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Right side */}
|
{/* Right side */}
|
||||||
@@ -149,6 +179,14 @@ export function Header({ user }: HeaderProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
{user.isAdmin && (
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/admin">
|
||||||
|
<LayoutDashboard className="h-4 w-4 mr-2" />
|
||||||
|
Admin Panel
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href="/settings">
|
<Link href="/settings">
|
||||||
<Settings className="h-4 w-4 mr-2" />
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div data-slot="table-container" className="relative w-full overflow-x-auto">
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn("hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn("text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn("p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("text-muted-foreground mt-4 text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
@@ -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<string, number> = {};
|
||||||
|
try {
|
||||||
|
const fileSizes = new Map<string, number>();
|
||||||
|
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 }
|
||||||
|
);
|
||||||
+11
@@ -63,6 +63,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
|||||||
if (token.sub && session.user) {
|
if (token.sub && session.user) {
|
||||||
session.user.id = token.sub;
|
session.user.id = token.sub;
|
||||||
session.user.name = token.name || null;
|
session.user.name = token.name || null;
|
||||||
|
session.user.isAdmin = token.isAdmin as boolean;
|
||||||
}
|
}
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
@@ -70,7 +71,17 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
|||||||
if (user) {
|
if (user) {
|
||||||
token.sub = user.id;
|
token.sub = user.id;
|
||||||
token.name = user.name;
|
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;
|
return token;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Vendored
+20
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user