e0e1ac49d9
- New invites table: token-gated signup, optional email lock, role/tier override, single-use, expiry. - SIGNUPS_DISABLED site setting toggle at /admin/settings. - databaseHooks.user.create gate in auth/server.ts blocks new account creation (email + Google OAuth) when disabled unless a valid invite cookie is present; applies invite role/tier and marks it consumed. - /admin/invites: create/list/revoke shareable invite links. - /admin/users: "Create user" dialog — admin sets email/role/tier, account is pre-verified, user gets a set-password email (admin never sees a password). - Signup page reads ?invite=, validates via public /api/v1/invites/[token], locks the form when signups are closed and no valid invite is present. - proxy.ts: allowlist /api/v1/invites/ for anonymous invite checks. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
158 lines
5.8 KiB
TypeScript
158 lines
5.8 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";
|
|
|
|
type Invite = {
|
|
id: string;
|
|
token: string;
|
|
email: string | null;
|
|
role: "user" | "moderator" | "admin";
|
|
tier: "free" | "pro";
|
|
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">("free");
|
|
const [creating, setCreating] = useState(false);
|
|
|
|
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) {
|
|
if (!confirm("Revoke this invite? The link will stop working.")) return;
|
|
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>
|
|
</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={() => { void handleRevoke(invite.id); }}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|