c8f4b50ef3
All literal "team" tier-value references renamed to "family" across API routes, admin UI, OpenAPI schemas, and lib/tiers.ts. The DB enum value itself is renamed in place via ALTER TYPE ... RENAME VALUE (migration 0044) rather than drizzle-kit's auto-generated drop-and-recreate-the-enum migration, which would have failed against any existing row still holding 'team' — RENAME VALUE preserves existing data with no cast/backfill needed. Also adds STRIPE_PLAN.md — a full Stripe billing integration plan (Checkout+Portal, tier→Price mapping, admin billing dashboard, and a multi-user Family-group design since Family is meant to cover several accounts under one subscription, not one payer). Planning only, no Stripe code yet. v0.47.0
115 lines
3.8 KiB
TypeScript
115 lines
3.8 KiB
TypeScript
import type { Metadata } from "next";
|
|
import { db, count } from "@epicure/db";
|
|
import { users } from "@epicure/db";
|
|
import { desc } from "@epicure/db";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
import { CreateUserDialog } from "@/components/admin/create-user-dialog";
|
|
import Link from "next/link";
|
|
|
|
export const metadata: Metadata = {};
|
|
|
|
const PAGE_SIZE = 100;
|
|
|
|
interface PageProps {
|
|
searchParams: Promise<{ page?: string }>;
|
|
}
|
|
|
|
const ROLE_COLORS = {
|
|
user: "secondary",
|
|
moderator: "outline",
|
|
admin: "destructive",
|
|
} as const;
|
|
|
|
const TIER_COLORS = {
|
|
free: "secondary",
|
|
pro: "default",
|
|
family: "default",
|
|
} as const;
|
|
|
|
export default async function AdminUsersPage({ searchParams }: PageProps) {
|
|
const { page: pageParam } = await searchParams;
|
|
const page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1);
|
|
const offset = (page - 1) * PAGE_SIZE;
|
|
|
|
const [allUsers, totalRow] = await Promise.all([
|
|
db
|
|
.select()
|
|
.from(users)
|
|
.orderBy(desc(users.createdAt))
|
|
.limit(PAGE_SIZE)
|
|
.offset(offset),
|
|
db.select({ count: count() }).from(users),
|
|
]);
|
|
const total = totalRow[0]?.count ?? 0;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-2xl font-bold tracking-tight">Users</h1>
|
|
<CreateUserDialog />
|
|
</div>
|
|
<div className="rounded-md border overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="border-b bg-muted/50">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left font-medium">User</th>
|
|
<th className="px-4 py-3 text-left font-medium">Role</th>
|
|
<th className="px-4 py-3 text-left font-medium">Tier</th>
|
|
<th className="px-4 py-3 text-left font-medium">Joined</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{allUsers.map((user) => (
|
|
<tr key={user.id} className="border-b last:border-0 hover:bg-muted/30">
|
|
<td className="px-4 py-3">
|
|
<Link href={`/admin/users/${user.id}`} className="flex items-center gap-3 group">
|
|
<Avatar className="h-8 w-8">
|
|
<AvatarImage src={user.avatarUrl ?? ""} alt={user.name} />
|
|
<AvatarFallback className="text-xs">
|
|
{user.name.slice(0, 2).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div>
|
|
<div className="font-medium group-hover:underline underline-offset-4">{user.name}</div>
|
|
<div className="text-muted-foreground text-xs">{user.email}</div>
|
|
</div>
|
|
</Link>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<Badge variant={ROLE_COLORS[user.role]}>{user.role}</Badge>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<Badge variant={TIER_COLORS[user.tier]}>{user.tier}</Badge>
|
|
</td>
|
|
<td className="px-4 py-3 text-muted-foreground">
|
|
{user.createdAt.toLocaleDateString()}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
{page > 1 && (
|
|
<Link
|
|
href={`/admin/users?page=${page - 1}`}
|
|
className="rounded-md border px-3 py-1 text-sm hover:bg-accent"
|
|
>
|
|
Previous
|
|
</Link>
|
|
)}
|
|
{offset + allUsers.length < total && (
|
|
<Link
|
|
href={`/admin/users?page=${page + 1}`}
|
|
className="rounded-md border px-3 py-1 text-sm hover:bg-accent"
|
|
>
|
|
Next
|
|
</Link>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|