diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx index ba08109..ca5797b 100644 --- a/app/admin/users/page.tsx +++ b/app/admin/users/page.tsx @@ -1,8 +1,10 @@ import { Metadata } from 'next'; -import { Prisma } from '@prisma/client'; +import { Prisma, BillingSubscriptionStatus } from '@prisma/client'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; -import { isBunnyUploadsFeatureEnabled } from '@/lib/feature-flags'; +import { isBunnyUploadsFeatureEnabled, isStripeBillingEnabled } from '@/lib/feature-flags'; +import { getBillingStatusLabel, hasBillingAccess } from '@/lib/billing'; +import { hasCollaboratorBillingBackedAccess } from '@/lib/route-access'; import { redirect } from 'next/navigation'; import { getCachedBunnyStorageStats, @@ -13,6 +15,7 @@ import { import { Film, HardDrive } from 'lucide-react'; import Link from 'next/link'; import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; import { Table, TableBody, @@ -24,12 +27,62 @@ import { 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' @@ -43,6 +96,7 @@ type SortDirection = 'asc' | 'desc'; const SORTABLE_COLUMNS: SortBy[] = [ 'user', + 'subscription', 'joinedDate', 'workspacesOwned', 'invitedMembers', @@ -83,6 +137,7 @@ function getSortIndicator( function canSortInDb(sortBy: SortBy): boolean { return ( sortBy === 'user' || + sortBy === 'subscription' || sortBy === 'joinedDate' || sortBy === 'workspacesOwned' || sortBy === 'projectsOwned' || @@ -100,6 +155,10 @@ function getUsersOrderBy( return [{ name: sortDirection }, { email: sortDirection }, createdAtTieBreaker]; } + if (sortBy === 'subscription') { + return [{ subscriptionStatus: sortDirection }, createdAtTieBreaker]; + } + if (sortBy === 'joinedDate') { return [{ createdAt: sortDirection }]; } @@ -139,6 +198,8 @@ export default async function AdminUsersPage({ ? resolvedSearchParams.sortDirection : getDefaultSortDirection(sortBy); const pageSize = 20; + const stripeBillingEnabled = isStripeBillingEnabled(); + const now = new Date(); const [totalUsers, userStorage, userBunnyStorage, userDownloadEgress, bunnyStorageStats] = await Promise.all([ db.user.count(), @@ -156,6 +217,12 @@ export default async function AdminUsersPage({ name: true, email: true, createdAt: true, + subscriptionStatus: true, + trialEndsAt: true, + stripeCurrentPeriodEnd: true, + stripeCancelAtPeriodEnd: true, + stripeCancelAt: true, + billingAccessEndedAt: true, ownedWorkspaces: { select: { _count: { @@ -179,6 +246,12 @@ export default async function AdminUsersPage({ 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; @@ -242,6 +315,24 @@ 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). + 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, + }); + }) + ); + } + const buildUsersPageHref = ( targetPage: number, targetSortBy: SortBy = sortBy, @@ -323,6 +414,19 @@ export default async function AdminUsersPage({ + {stripeBillingEnabled && ( + + + Subscription + + {getSortIndicator('subscription', sortBy, sortDirection)} + + + + )} {paginatedUsers.length === 0 ? ( - + No users found. @@ -429,6 +533,36 @@ export default async function AdminUsersPage({ {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}