import { Metadata } from 'next'; import { Prisma, BillingSubscriptionStatus } from '@prisma/client'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; import { isBunnyUploadsFeatureEnabled, isStripeBillingEnabled } from '@/lib/feature-flags'; import { buildBillingAccessWhereInput, getBillingStatusLabel, hasBillingAccess, } from '@/lib/billing'; import { redirect } from 'next/navigation'; import { getCachedBunnyStorageStats, getCachedUserBunnyStorage, getCachedUserDownloadEgress, getCachedUserMediaStorage, } from '@/lib/admin-stats'; import { Film, HardDrive } from 'lucide-react'; import Link from 'next/link'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; 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'; function getBillingStatusVariant( status: BillingSubscriptionStatus ): 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' { switch (status) { case BillingSubscriptionStatus.ACTIVE: return 'default'; case BillingSubscriptionStatus.TRIALING: case BillingSubscriptionStatus.INCOMPLETE: return 'secondary'; case BillingSubscriptionStatus.PAST_DUE: case BillingSubscriptionStatus.UNPAID: return 'destructive'; case BillingSubscriptionStatus.CANCELED: case BillingSubscriptionStatus.INCOMPLETE_EXPIRED: return 'outline'; case BillingSubscriptionStatus.FREE: default: return 'ghost'; } } function getOwnBillingAccess( user: { subscriptionStatus: BillingSubscriptionStatus; trialEndsAt: Date | null; stripeCurrentPeriodEnd: Date | null; stripeCancelAtPeriodEnd: boolean; stripeCancelAt: Date | null; billingAccessEndedAt: Date | null; }, now: Date ): { ownAccess: boolean; endsAt: Date | null } { if (!hasBillingAccess(user, now)) { return { ownAccess: false, endsAt: null }; } const isEnding = user.stripeCancelAtPeriodEnd || user.subscriptionStatus === BillingSubscriptionStatus.CANCELED; let endsAt: Date | null = null; if (user.subscriptionStatus === BillingSubscriptionStatus.TRIALING) { endsAt = user.trialEndsAt; } else if (isEnding) { endsAt = user.stripeCurrentPeriodEnd ?? user.stripeCancelAt; } return { ownAccess: true, endsAt }; } export const metadata: Metadata = { title: 'Manage Users | Admin', }; type SortBy = | 'user' | 'subscription' | 'joinedDate' | 'workspacesOwned' | 'invitedMembers' | 'projectsOwned' | 'totalComments' | 'bunnyUpload' | 'downloadEgress' | 'mediaStorage'; type SortDirection = 'asc' | 'desc'; type StatusFilter = 'ALL' | BillingSubscriptionStatus; type AccessFilter = 'ALL' | 'ACTIVE' | 'NONE'; // Order the badges the way an admin scans them: paying first, problems next, // churned last. const STATUS_FILTERS: BillingSubscriptionStatus[] = [ BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.TRIALING, BillingSubscriptionStatus.PAST_DUE, BillingSubscriptionStatus.UNPAID, BillingSubscriptionStatus.CANCELED, BillingSubscriptionStatus.INCOMPLETE, BillingSubscriptionStatus.INCOMPLETE_EXPIRED, BillingSubscriptionStatus.FREE, ]; const ACCESS_FILTERS: Array<{ value: AccessFilter; label: string }> = [ { value: 'ALL', label: 'All Access' }, { value: 'ACTIVE', label: 'Has Access' }, { value: 'NONE', label: 'No Access' }, ]; const MAX_QUERY_LENGTH = 120; const SORTABLE_COLUMNS: SortBy[] = [ 'user', 'subscription', 'joinedDate', 'workspacesOwned', 'invitedMembers', 'projectsOwned', 'totalComments', 'bunnyUpload', 'downloadEgress', 'mediaStorage', ]; function formatBytes(bytes: number, decimals = 2) { if (bytes < 0) return 'Error Fetching'; if (!+bytes) return '0 Bytes'; const k = 1000; 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]}`; } function isSortBy(value: string | undefined): value is SortBy { return !!value && SORTABLE_COLUMNS.includes(value as SortBy); } function getDefaultSortDirection(sortBy: SortBy): SortDirection { return sortBy === 'user' ? 'asc' : 'desc'; } function parseQuery(value: string | undefined): string { return (value ?? '').trim().slice(0, MAX_QUERY_LENGTH); } function parseStatusFilter(value: string | undefined): StatusFilter { return STATUS_FILTERS.includes(value as BillingSubscriptionStatus) ? (value as BillingSubscriptionStatus) : 'ALL'; } function parseAccessFilter(value: string | undefined): AccessFilter { return value === 'ACTIVE' || value === 'NONE' ? value : 'ALL'; } /** * User ids that have in-app access through somebody else's paid workspace or * project. Mirrors hasCollaboratorBillingBackedAccess in lib/route-access.ts, * but resolves every user in two queries instead of two queries per user, so * the same set can back both the table column and the access filter. */ async function getCollaboratorAccessUserIds(now: Date): Promise> { const payingOwner = buildBillingAccessWhereInput(now); const [workspaces, projects] = await Promise.all([ db.workspace.findMany({ where: { owner: payingOwner }, select: { ownerId: true, members: { select: { userId: true } } }, }), db.project.findMany({ where: { workspace: { owner: payingOwner } }, select: { ownerId: true, members: { select: { userId: true } }, workspace: { select: { members: { select: { userId: true } } } }, }, }), ]); const userIds = new Set(); for (const workspace of workspaces) { userIds.add(workspace.ownerId); for (const member of workspace.members) userIds.add(member.userId); } for (const project of projects) { userIds.add(project.ownerId); for (const member of project.members) userIds.add(member.userId); for (const member of project.workspace.members) userIds.add(member.userId); } return userIds; } function getSortIndicator( column: SortBy, activeSortBy: SortBy, activeSortDirection: SortDirection ): string { if (column !== activeSortBy) return '↕'; return activeSortDirection === 'asc' ? '↑' : '↓'; } function canSortInDb(sortBy: SortBy): boolean { return ( sortBy === 'user' || sortBy === 'subscription' || sortBy === 'joinedDate' || sortBy === 'workspacesOwned' || sortBy === 'projectsOwned' || sortBy === 'totalComments' ); } function getUsersOrderBy( sortBy: SortBy, sortDirection: SortDirection ): Prisma.UserOrderByWithRelationInput[] { const createdAtTieBreaker: Prisma.UserOrderByWithRelationInput = { createdAt: 'desc' }; if (sortBy === 'user') { return [{ name: sortDirection }, { email: sortDirection }, createdAtTieBreaker]; } if (sortBy === 'subscription') { return [{ subscriptionStatus: sortDirection }, createdAtTieBreaker]; } if (sortBy === 'joinedDate') { return [{ createdAt: sortDirection }]; } if (sortBy === 'workspacesOwned') { return [{ ownedWorkspaces: { _count: sortDirection } }, createdAtTieBreaker]; } if (sortBy === 'projectsOwned') { return [{ projects: { _count: sortDirection } }, createdAtTieBreaker]; } if (sortBy === 'totalComments') { return [{ comments: { _count: sortDirection } }, createdAtTieBreaker]; } return [createdAtTieBreaker]; } export default async function AdminUsersPage({ searchParams, }: { searchParams: Promise<{ page?: string; sortBy?: string; sortDirection?: string; q?: string; status?: string; access?: string; }>; }) { const session = await auth(); if (!session?.user?.isAdmin) { redirect('/'); } const resolvedSearchParams = await searchParams; const requestedPage = Number(resolvedSearchParams?.page) || 1; const sortBy: SortBy = isSortBy(resolvedSearchParams?.sortBy) ? resolvedSearchParams.sortBy : 'joinedDate'; const sortDirection: SortDirection = resolvedSearchParams?.sortDirection === 'asc' || resolvedSearchParams?.sortDirection === 'desc' ? resolvedSearchParams.sortDirection : getDefaultSortDirection(sortBy); const pageSize = 20; const stripeBillingEnabled = isStripeBillingEnabled(); const now = new Date(); const query = parseQuery(resolvedSearchParams?.q); // Subscription/access are only meaningful (and only rendered) when billing is on. const statusFilter: StatusFilter = stripeBillingEnabled ? parseStatusFilter(resolvedSearchParams?.status) : 'ALL'; const accessFilter: AccessFilter = stripeBillingEnabled ? parseAccessFilter(resolvedSearchParams?.access) : 'ALL'; const hasActiveFilters = Boolean(query) || statusFilter !== 'ALL' || accessFilter !== 'ALL'; // Access is not just the user's own subscription: a user with no billing of // their own still has access as a collaborator on a paying owner's workspace // or project (mirrors hasAppNavigationAccess in lib/route-access.ts). const collaboratorAccessUserIds = stripeBillingEnabled ? await getCollaboratorAccessUserIds(now) : new Set(); const filters: Prisma.UserWhereInput[] = []; if (query) { filters.push({ OR: [ { name: { contains: query, mode: 'insensitive' } }, { email: { contains: query, mode: 'insensitive' } }, ], }); } if (statusFilter !== 'ALL') { filters.push({ subscriptionStatus: statusFilter }); } if (accessFilter !== 'ALL') { const hasAccess: Prisma.UserWhereInput = { OR: [ buildBillingAccessWhereInput(now), { id: { in: Array.from(collaboratorAccessUserIds) } }, ], }; filters.push(accessFilter === 'ACTIVE' ? hasAccess : { NOT: hasAccess }); } const where: Prisma.UserWhereInput = filters.length > 0 ? { AND: filters } : {}; const [ totalUsers, matchingUsers, userStorage, userBunnyStorage, userDownloadEgress, bunnyStorageStats, ] = await Promise.all([ db.user.count(), db.user.count({ where }), getCachedUserMediaStorage(), getCachedUserBunnyStorage(), getCachedUserDownloadEgress(), getCachedBunnyStorageStats(), ]); const totalPages = Math.max(1, Math.ceil(matchingUsers / pageSize)); const page = Math.min(Math.max(1, requestedPage), totalPages); const skip = (page - 1) * pageSize; const select = { id: true, name: true, email: true, createdAt: true, subscriptionStatus: true, trialEndsAt: true, stripeCurrentPeriodEnd: true, stripeCancelAtPeriodEnd: true, stripeCancelAt: true, billingAccessEndedAt: true, ownedWorkspaces: { select: { _count: { select: { members: true, }, }, }, }, _count: { select: { ownedWorkspaces: true, projects: true, comments: true, }, }, } satisfies Prisma.UserSelect; let paginatedUsers: Array<{ id: string; name: string | null; email: string | null; createdAt: Date; subscriptionStatus: BillingSubscriptionStatus; trialEndsAt: Date | null; stripeCurrentPeriodEnd: Date | null; stripeCancelAtPeriodEnd: boolean; stripeCancelAt: Date | null; billingAccessEndedAt: Date | null; ownedWorkspaces: Array<{ _count: { members: number } }>; _count: { ownedWorkspaces: number; projects: number; comments: number }; invitedMembersCount: number; bunnyUploadBytes: number; downloadEgressBytes: number; mediaStorageBytes: number; }> = []; if (canSortInDb(sortBy)) { const users = await db.user.findMany({ where, skip, take: pageSize, orderBy: getUsersOrderBy(sortBy, sortDirection), select, }); paginatedUsers = users.map((user) => ({ ...user, invitedMembersCount: user.ownedWorkspaces.reduce( (total, workspace) => total + workspace._count.members, 0 ), bunnyUploadBytes: userBunnyStorage[user.id] || 0, downloadEgressBytes: userDownloadEgress[user.id] || 0, mediaStorageBytes: userStorage[user.id]?.total || 0, })); } else { const users = await db.user.findMany({ where, select }); const usersWithMetrics = users.map((user) => ({ ...user, invitedMembersCount: user.ownedWorkspaces.reduce( (total, workspace) => total + workspace._count.members, 0 ), bunnyUploadBytes: userBunnyStorage[user.id] || 0, downloadEgressBytes: userDownloadEgress[user.id] || 0, mediaStorageBytes: userStorage[user.id]?.total || 0, })); const sortedUsers = usersWithMetrics.sort((a, b) => { let comparison = 0; if (sortBy === 'invitedMembers') { comparison = a.invitedMembersCount - b.invitedMembersCount; } else if (sortBy === 'bunnyUpload') { comparison = a.bunnyUploadBytes - b.bunnyUploadBytes; } else if (sortBy === 'downloadEgress') { comparison = a.downloadEgressBytes - b.downloadEgressBytes; } else if (sortBy === 'mediaStorage') { comparison = a.mediaStorageBytes - b.mediaStorageBytes; } if (comparison === 0) { comparison = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); } return sortDirection === 'asc' ? comparison : -comparison; }); paginatedUsers = sortedUsers.slice(skip, skip + pageSize); } // Resolve the real in-app access for each user on the current page. const accessByUserId = new Map(); if (stripeBillingEnabled) { for (const user of paginatedUsers) { const { ownAccess } = getOwnBillingAccess(user, now); const hasAppAccess = ownAccess || collaboratorAccessUserIds.has(user.id); accessByUserId.set(user.id, { hasAppAccess, viaCollaboration: hasAppAccess && !ownAccess, }); } } const buildUsersPageHref = ( targetPage: number, targetSortBy: SortBy = sortBy, targetSortDirection: SortDirection = sortDirection, targetQuery: string = query, targetStatus: StatusFilter = statusFilter, targetAccess: AccessFilter = accessFilter ): string => { const params = new URLSearchParams({ page: String(targetPage), sortBy: targetSortBy, sortDirection: targetSortDirection, }); if (targetQuery) params.set('q', targetQuery); if (targetStatus !== 'ALL') params.set('status', targetStatus); if (targetAccess !== 'ALL') params.set('access', targetAccess); return `/admin/users?${params.toString()}`; }; const buildSortHref = (column: SortBy): string => { const nextDirection: SortDirection = column === sortBy ? sortDirection === 'asc' ? 'desc' : 'asc' : getDefaultSortDirection(column); return buildUsersPageHref(1, column, nextDirection); }; const buildStatusHref = (targetStatus: StatusFilter): string => buildUsersPageHref(1, sortBy, sortDirection, query, targetStatus, accessFilter); const buildAccessHref = (targetAccess: AccessFilter): string => buildUsersPageHref(1, sortBy, sortDirection, query, statusFilter, targetAccess); const clearFiltersHref = buildUsersPageHref(1, sortBy, sortDirection, '', 'ALL', 'ALL'); return (

Users

Bunny Stream Storage
{isBunnyUploadsFeatureEnabled() ? formatBytes(bunnyStorageStats.totalBytes) : 'Disabled'}
Cloudflare R2 Media Storage
{formatBytes(Object.values(userStorage).reduce((sum, item) => sum + item.total, 0))}
{statusFilter !== 'ALL' && } {accessFilter !== 'ALL' && }
{hasActiveFilters && ( )}
{stripeBillingEnabled && ( <>
{STATUS_FILTERS.map((status) => ( ))}
{ACCESS_FILTERS.map((filter) => ( ))}
)}
All Users {hasActiveFilters ? `${matchingUsers} of ${totalUsers} users match the current filters.` : `A comprehensive list of all ${totalUsers} users registered on the platform.`}
User {getSortIndicator('user', sortBy, sortDirection)} {stripeBillingEnabled && ( Subscription {getSortIndicator('subscription', sortBy, sortDirection)} )} Joined Date {getSortIndicator('joinedDate', sortBy, sortDirection)} Workspaces Owned {getSortIndicator('workspacesOwned', sortBy, sortDirection)} Invited Members {getSortIndicator('invitedMembers', sortBy, sortDirection)} Projects Owned {getSortIndicator('projectsOwned', sortBy, sortDirection)} Total Comments {getSortIndicator('totalComments', sortBy, sortDirection)} Bunny Upload {getSortIndicator('bunnyUpload', sortBy, sortDirection)} Download Egress (Est.) {getSortIndicator('downloadEgress', sortBy, sortDirection)} Media Storage {getSortIndicator('mediaStorage', sortBy, sortDirection)} {paginatedUsers.length === 0 ? ( {hasActiveFilters ? 'No users match these filters.' : 'No users found.'} ) : ( paginatedUsers.map((user) => (
{user.name || 'Anonymous'} {user.email}
{stripeBillingEnabled && (() => { const { endsAt } = getOwnBillingAccess(user, now); const access = accessByUserId.get(user.id) ?? { hasAppAccess: false, viaCollaboration: false, }; return (
{getBillingStatusLabel(user.subscriptionStatus)} {access.hasAppAccess ? ( Active access {access.viaCollaboration ? ' · via team' : endsAt ? ` · until ${format(new Date(endsAt), 'MMM dd')}` : ''} ) : ( No access )}
); })()} {format(new Date(user.createdAt), 'MMM dd, yyyy')} {user._count.ownedWorkspaces} {user.invitedMembersCount} {user._count.projects} {user._count.comments} {formatBytes(user.bunnyUploadBytes)} {formatBytes(user.downloadEgressBytes)}
{formatBytes(user.mediaStorageBytes)} {(userStorage[user.id]?.voice > 0 || userStorage[user.id]?.image > 0) && ( {userStorage[user.id]?.voice > 0 && ( 🎤 {formatBytes(userStorage[user.id]?.voice)} )} {userStorage[user.id]?.voice > 0 && userStorage[user.id]?.image > 0 && } {userStorage[user.id]?.image > 0 && ( 🖼️ {formatBytes(userStorage[user.id]?.image)} )} )}
)) )}
{/* Pagination */} {totalPages > 1 && (
Page {page} of {totalPages}
)}
); }