feat(admin): show subscription status and real access in user list

Add a Subscription column to the admin user listing showing each user's
billing status (Active, Trialing, Past due, Canceled, etc.) as a badge,
plus an effective-access indicator. Access reflects real in-app access,
not just the user's own subscription: collaborators on a paying owner's
workspace/project are shown as having access 'via team' (mirrors
hasAppNavigationAccess in lib/route-access.ts). Canceled-but-not-yet-
expired and trialing users are surfaced with their access-until date.
Column is DB-sortable and gated behind isStripeBillingEnabled().
This commit is contained in:
yusufipk
2026-07-25 13:24:55 +07:00
parent ae78e97fde
commit b5fd73dcf2
+137 -3
View File
@@ -1,8 +1,10 @@
import { Metadata } from 'next'; import { Metadata } from 'next';
import { Prisma } from '@prisma/client'; import { Prisma, BillingSubscriptionStatus } from '@prisma/client';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; 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 { redirect } from 'next/navigation';
import { import {
getCachedBunnyStorageStats, getCachedBunnyStorageStats,
@@ -13,6 +15,7 @@ import {
import { Film, HardDrive } from 'lucide-react'; import { Film, HardDrive } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { import {
Table, Table,
TableBody, TableBody,
@@ -24,12 +27,62 @@ import {
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { format } from 'date-fns'; 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 = { export const metadata: Metadata = {
title: 'Manage Users | Admin', title: 'Manage Users | Admin',
}; };
type SortBy = type SortBy =
| 'user' | 'user'
| 'subscription'
| 'joinedDate' | 'joinedDate'
| 'workspacesOwned' | 'workspacesOwned'
| 'invitedMembers' | 'invitedMembers'
@@ -43,6 +96,7 @@ type SortDirection = 'asc' | 'desc';
const SORTABLE_COLUMNS: SortBy[] = [ const SORTABLE_COLUMNS: SortBy[] = [
'user', 'user',
'subscription',
'joinedDate', 'joinedDate',
'workspacesOwned', 'workspacesOwned',
'invitedMembers', 'invitedMembers',
@@ -83,6 +137,7 @@ function getSortIndicator(
function canSortInDb(sortBy: SortBy): boolean { function canSortInDb(sortBy: SortBy): boolean {
return ( return (
sortBy === 'user' || sortBy === 'user' ||
sortBy === 'subscription' ||
sortBy === 'joinedDate' || sortBy === 'joinedDate' ||
sortBy === 'workspacesOwned' || sortBy === 'workspacesOwned' ||
sortBy === 'projectsOwned' || sortBy === 'projectsOwned' ||
@@ -100,6 +155,10 @@ function getUsersOrderBy(
return [{ name: sortDirection }, { email: sortDirection }, createdAtTieBreaker]; return [{ name: sortDirection }, { email: sortDirection }, createdAtTieBreaker];
} }
if (sortBy === 'subscription') {
return [{ subscriptionStatus: sortDirection }, createdAtTieBreaker];
}
if (sortBy === 'joinedDate') { if (sortBy === 'joinedDate') {
return [{ createdAt: sortDirection }]; return [{ createdAt: sortDirection }];
} }
@@ -139,6 +198,8 @@ export default async function AdminUsersPage({
? resolvedSearchParams.sortDirection ? resolvedSearchParams.sortDirection
: getDefaultSortDirection(sortBy); : getDefaultSortDirection(sortBy);
const pageSize = 20; const pageSize = 20;
const stripeBillingEnabled = isStripeBillingEnabled();
const now = new Date();
const [totalUsers, userStorage, userBunnyStorage, userDownloadEgress, bunnyStorageStats] = const [totalUsers, userStorage, userBunnyStorage, userDownloadEgress, bunnyStorageStats] =
await Promise.all([ await Promise.all([
db.user.count(), db.user.count(),
@@ -156,6 +217,12 @@ export default async function AdminUsersPage({
name: true, name: true,
email: true, email: true,
createdAt: true, createdAt: true,
subscriptionStatus: true,
trialEndsAt: true,
stripeCurrentPeriodEnd: true,
stripeCancelAtPeriodEnd: true,
stripeCancelAt: true,
billingAccessEndedAt: true,
ownedWorkspaces: { ownedWorkspaces: {
select: { select: {
_count: { _count: {
@@ -179,6 +246,12 @@ export default async function AdminUsersPage({
name: string | null; name: string | null;
email: string | null; email: string | null;
createdAt: Date; createdAt: Date;
subscriptionStatus: BillingSubscriptionStatus;
trialEndsAt: Date | null;
stripeCurrentPeriodEnd: Date | null;
stripeCancelAtPeriodEnd: boolean;
stripeCancelAt: Date | null;
billingAccessEndedAt: Date | null;
ownedWorkspaces: Array<{ _count: { members: number } }>; ownedWorkspaces: Array<{ _count: { members: number } }>;
_count: { ownedWorkspaces: number; projects: number; comments: number }; _count: { ownedWorkspaces: number; projects: number; comments: number };
invitedMembersCount: number; invitedMembersCount: number;
@@ -242,6 +315,24 @@ export default async function AdminUsersPage({
paginatedUsers = sortedUsers.slice(skip, skip + pageSize); 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<string, { hasAppAccess: boolean; viaCollaboration: boolean }>();
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 = ( const buildUsersPageHref = (
targetPage: number, targetPage: number,
targetSortBy: SortBy = sortBy, targetSortBy: SortBy = sortBy,
@@ -323,6 +414,19 @@ export default async function AdminUsersPage({
</span> </span>
</Link> </Link>
</TableHead> </TableHead>
{stripeBillingEnabled && (
<TableHead>
<Link
href={buildSortHref('subscription')}
className="inline-flex items-center gap-1 hover:underline"
>
Subscription
<span className="text-xs">
{getSortIndicator('subscription', sortBy, sortDirection)}
</span>
</Link>
</TableHead>
)}
<TableHead> <TableHead>
<Link <Link
href={buildSortHref('joinedDate')} href={buildSortHref('joinedDate')}
@@ -416,7 +520,7 @@ export default async function AdminUsersPage({
<TableBody> <TableBody>
{paginatedUsers.length === 0 ? ( {paginatedUsers.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={9} className="h-24 text-center"> <TableCell colSpan={stripeBillingEnabled ? 10 : 9} className="h-24 text-center">
No users found. No users found.
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -429,6 +533,36 @@ export default async function AdminUsersPage({
<span className="text-xs text-muted-foreground">{user.email}</span> <span className="text-xs text-muted-foreground">{user.email}</span>
</div> </div>
</TableCell> </TableCell>
{stripeBillingEnabled &&
(() => {
const { endsAt } = getOwnBillingAccess(user, now);
const access = accessByUserId.get(user.id) ?? {
hasAppAccess: false,
viaCollaboration: false,
};
return (
<TableCell>
<div className="flex flex-col items-start gap-1">
<Badge variant={getBillingStatusVariant(user.subscriptionStatus)}>
{getBillingStatusLabel(user.subscriptionStatus)}
</Badge>
{access.hasAppAccess ? (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground whitespace-nowrap">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-500" />
Active access
{access.viaCollaboration
? ' · via team'
: endsAt
? ` · until ${format(new Date(endsAt), 'MMM dd')}`
: ''}
</span>
) : (
<span className="text-xs text-muted-foreground">No access</span>
)}
</div>
</TableCell>
);
})()}
<TableCell>{format(new Date(user.createdAt), 'MMM dd, yyyy')}</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.ownedWorkspaces}</TableCell>
<TableCell className="text-center">{user.invitedMembersCount}</TableCell> <TableCell className="text-center">{user.invitedMembersCount}</TableCell>