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,
|
||||
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}
|
||||
</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>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
@@ -117,6 +133,20 @@ export function Header({ user }: HeaderProps) {
|
||||
{item.label}
|
||||
</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>
|
||||
|
||||
{/* Right side */}
|
||||
@@ -149,6 +179,14 @@ export function Header({ user }: HeaderProps) {
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
{user.isAdmin && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/admin">
|
||||
<LayoutDashboard className="h-4 w-4 mr-2" />
|
||||
Admin Panel
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings">
|
||||
<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) {
|
||||
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;
|
||||
},
|
||||
},
|
||||
|
||||
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