feat: signup toggle, invite links, admin-created users

- 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>
This commit is contained in:
Arnaud
2026-07-03 21:36:40 +02:00
parent c5bc2e1470
commit e0e1ac49d9
22 changed files with 4483 additions and 90 deletions
@@ -0,0 +1,157 @@
"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>
);
}