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
102 lines
3.9 KiB
TypeScript
102 lines
3.9 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { toast } from "sonner";
|
|
import { Plus } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
|
|
export function CreateUserDialog() {
|
|
const router = useRouter();
|
|
const [open, setOpen] = useState(false);
|
|
const [email, setEmail] = useState("");
|
|
const [name, setName] = useState("");
|
|
const [role, setRole] = useState<"user" | "moderator" | "admin">("user");
|
|
const [tier, setTier] = useState<"free" | "pro" | "family">("free");
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
async function handleCreate() {
|
|
if (!email.trim() || !name.trim()) {
|
|
toast.error("Email and name are required");
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
const res = await fetch("/api/v1/admin/users", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email: email.trim(), name: name.trim(), role, tier }),
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}));
|
|
throw new Error((data as { error?: string }).error ?? "Failed to create user");
|
|
}
|
|
toast.success("User created — they'll receive an email to set their password");
|
|
setOpen(false);
|
|
setEmail("");
|
|
setName("");
|
|
setRole("user");
|
|
setTier("free");
|
|
router.refresh();
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : "Failed to create user");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Button size="sm" onClick={() => setOpen(true)}>
|
|
<Plus className="h-4 w-4" /> Create user
|
|
</Button>
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader><DialogTitle>Create user</DialogTitle></DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="new-user-email">Email</Label>
|
|
<Input id="new-user-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="new-user-name">Name</Label>
|
|
<Input id="new-user-name" value={name} onChange={(e) => setName(e.target.value)} />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<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-2">
|
|
<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={saving} className="w-full">
|
|
{saving ? "Creating…" : "Create user"}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|