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
190 lines
6.9 KiB
TypeScript
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>
|
|
);
|
|
}
|