From f64c04b271f77aeb28457c93134ad53be9bb7788 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 25 Jul 2026 15:15:01 +0700 Subject: [PATCH] feat(admin): add search and status filters to the users table The users table could only be sorted, so finding a single account or reviewing everyone in a given billing state meant paging through the whole list. Add three filters that compose with each other and with sorting: - q: case-insensitive name/email search, submitted as a plain GET form - status: one button per BillingSubscriptionStatus (active, canceled, ...) - access: real in-app access, including collaborators on a paying owner's workspace or project Resolving that collaborator access per user meant two queries per row. Replace it with getCollaboratorAccessUserIds, which resolves every user in two queries total and now backs both the column and the new filter. --- app/admin/users/page.tsx | 275 ++++++++++++++++++++++++++++++++++----- 1 file changed, 245 insertions(+), 30 deletions(-) diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx index ca5797b..78576f9 100644 --- a/app/admin/users/page.tsx +++ b/app/admin/users/page.tsx @@ -3,8 +3,11 @@ import { Prisma, BillingSubscriptionStatus } from '@prisma/client'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; import { isBunnyUploadsFeatureEnabled, isStripeBillingEnabled } from '@/lib/feature-flags'; -import { getBillingStatusLabel, hasBillingAccess } from '@/lib/billing'; -import { hasCollaboratorBillingBackedAccess } from '@/lib/route-access'; +import { + buildBillingAccessWhereInput, + getBillingStatusLabel, + hasBillingAccess, +} from '@/lib/billing'; import { redirect } from 'next/navigation'; import { getCachedBunnyStorageStats, @@ -16,6 +19,7 @@ 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, @@ -94,6 +98,30 @@ type SortBy = 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', @@ -125,6 +153,60 @@ 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, @@ -181,7 +263,14 @@ function getUsersOrderBy( export default async function AdminUsersPage({ searchParams, }: { - searchParams: Promise<{ page?: string; sortBy?: string; sortDirection?: string }>; + searchParams: Promise<{ + page?: string; + sortBy?: string; + sortDirection?: string; + q?: string; + status?: string; + access?: string; + }>; }) { const session = await auth(); if (!session?.user?.isAdmin) { @@ -200,15 +289,67 @@ export default async function AdminUsersPage({ const pageSize = 20; const stripeBillingEnabled = isStripeBillingEnabled(); const now = new Date(); - const [totalUsers, userStorage, userBunnyStorage, userDownloadEgress, bunnyStorageStats] = - await Promise.all([ - db.user.count(), - getCachedUserMediaStorage(), - getCachedUserBunnyStorage(), - getCachedUserDownloadEgress(), - getCachedBunnyStorageStats(), - ]); - const totalPages = Math.max(1, Math.ceil(totalUsers / pageSize)); + + 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; @@ -262,6 +403,7 @@ export default async function AdminUsersPage({ if (canSortInDb(sortBy)) { const users = await db.user.findMany({ + where, skip, take: pageSize, orderBy: getUsersOrderBy(sortBy, sortDirection), @@ -279,7 +421,7 @@ export default async function AdminUsersPage({ mediaStorageBytes: userStorage[user.id]?.total || 0, })); } else { - const users = await db.user.findMany({ select }); + const users = await db.user.findMany({ where, select }); const usersWithMetrics = users.map((user) => ({ ...user, @@ -315,28 +457,26 @@ export default async function AdminUsersPage({ paginatedUsers = sortedUsers.slice(skip, skip + pageSize); } - // Resolve the real in-app access for each user on the current page. 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/project - // (mirrors hasAppNavigationAccess in lib/route-access.ts). + // Resolve the real in-app access for each user on the current page. const accessByUserId = new Map(); if (stripeBillingEnabled) { - await Promise.all( - paginatedUsers.map(async (user) => { - const { ownAccess } = getOwnBillingAccess(user, now); - const hasAppAccess = ownAccess || (await hasCollaboratorBillingBackedAccess(user.id)); - accessByUserId.set(user.id, { - hasAppAccess, - viaCollaboration: hasAppAccess && !ownAccess, - }); - }) - ); + 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 + targetSortDirection: SortDirection = sortDirection, + targetQuery: string = query, + targetStatus: StatusFilter = statusFilter, + targetAccess: AccessFilter = accessFilter ): string => { const params = new URLSearchParams({ page: String(targetPage), @@ -344,6 +484,10 @@ export default async function AdminUsersPage({ 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()}`; }; @@ -358,6 +502,14 @@ export default async function AdminUsersPage({ 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 (
@@ -391,11 +543,74 @@ export default async function AdminUsersPage({
+
+
+
+ + + {statusFilter !== 'ALL' && } + {accessFilter !== 'ALL' && } + + +
+ {hasActiveFilters && ( + + )} +
+ + {stripeBillingEnabled && ( + <> +
+ + {STATUS_FILTERS.map((status) => ( + + ))} +
+ +
+ {ACCESS_FILTERS.map((filter) => ( + + ))} +
+ + )} +
+ All Users - A comprehensive list of all {totalUsers} users registered on the platform. + {hasActiveFilters + ? `${matchingUsers} of ${totalUsers} users match the current filters.` + : `A comprehensive list of all ${totalUsers} users registered on the platform.`} @@ -521,7 +736,7 @@ export default async function AdminUsersPage({ {paginatedUsers.length === 0 ? ( - No users found. + {hasActiveFilters ? 'No users match these filters.' : 'No users found.'} ) : (