mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
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:
+137
-3
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user