Files
Epicure/apps/web/components/admin/invites-manager.tsx
T
Arnaud c8f4b50ef3 rename: "Team" billing tier to "Family"
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
2026-07-18 00:25:51 +02:00

190 lines
6.9 KiB
TypeScript

"use client";
import { useState } from "react";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Copy, Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
type Invite = {
id: string;
token: string;
email: string | null;
role: "user" | "moderator" | "admin";
tier: "free" | "pro" | "family";
createdAt: string;
expiresAt: string | null;
};
export function InvitesManager({ invites, appUrl }: { invites: Invite[]; appUrl: string }) {
const router = useRouter();
const [email, setEmail] = useState("");
const [role, setRole] = useState<"user" | "moderator" | "admin">("user");
const [tier, setTier] = useState<"free" | "pro" | "family">("free");
const [creating, setCreating] = useState(false);
const [revokeId, setRevokeId] = useState<string | null>(null);
async function handleCreate() {
setCreating(true);
try {
const res = await fetch("/api/v1/admin/invites", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email || undefined, role, tier }),
});
if (!res.ok) throw new Error("Failed to create invite");
setEmail("");
toast.success("Invite created");
router.refresh();
} catch {
toast.error("Failed to create invite");
} finally {
setCreating(false);
}
}
async function handleRevoke(id: string) {
try {
const res = await fetch(`/api/v1/admin/invites/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error("Failed to revoke");
toast.success("Invite revoked");
router.refresh();
} catch {
toast.error("Failed to revoke invite");
}
}
function copyLink(token: string) {
const url = `${appUrl}/signup?invite=${token}`;
void navigator.clipboard.writeText(url);
toast.success("Link copied");
}
return (
<div className="space-y-6">
<section className="rounded-xl border p-6 space-y-4">
<h2 className="font-semibold text-lg">New invite</h2>
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4">
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="invite-email">Email (optional)</Label>
<Input
id="invite-email"
type="email"
placeholder="Leave blank for anyone with the link"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label>Role</Label>
<Select value={role} onValueChange={(v) => setRole(v as typeof role)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="moderator">Moderator</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Tier</Label>
<Select value={tier} onValueChange={(v) => setTier(v as typeof tier)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="free">Free</SelectItem>
<SelectItem value="pro">Pro</SelectItem>
<SelectItem value="family">Family</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Button onClick={() => { void handleCreate(); }} disabled={creating} size="sm">
{creating ? "Creating…" : "Create invite"}
</Button>
</section>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/50">
<tr>
<th className="px-4 py-3 text-left font-medium">Email</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">Expires</th>
<th className="px-4 py-3 text-left font-medium"></th>
</tr>
</thead>
<tbody>
{invites.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-6 text-center text-muted-foreground">
No active invites.
</td>
</tr>
)}
{invites.map((invite) => (
<tr key={invite.id} className="border-b last:border-0">
<td className="px-4 py-3">{invite.email ?? <span className="text-muted-foreground">Anyone</span>}</td>
<td className="px-4 py-3">{invite.role}</td>
<td className="px-4 py-3">{invite.tier}</td>
<td className="px-4 py-3 text-muted-foreground">
{invite.expiresAt ? new Date(invite.expiresAt).toLocaleDateString() : "Never"}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<Button variant="outline" size="icon-sm" onClick={() => copyLink(invite.token)}>
<Copy className="h-3.5 w-3.5" />
</Button>
<Button
variant="outline"
size="icon-sm"
className="text-destructive hover:text-destructive"
onClick={() => setRevokeId(invite.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<AlertDialog open={revokeId !== null} onOpenChange={(open) => !open && setRevokeId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revoke invite?</AlertDialogTitle>
<AlertDialogDescription>The link will stop working.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (revokeId) void handleRevoke(revokeId);
setRevokeId(null);
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Revoke
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}