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,100 @@
"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">("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>
</SelectContent>
</Select>
</div>
</div>
<Button onClick={() => { void handleCreate(); }} disabled={saving} className="w-full">
{saving ? "Creating…" : "Create user"}
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}
@@ -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>
);
}
@@ -0,0 +1,55 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
export function SignupsToggle({ initialDisabled }: { initialDisabled: boolean }) {
const [disabled, setDisabled] = useState(initialDisabled);
const [saving, setSaving] = useState(false);
async function handleChange(checked: boolean) {
setSaving(true);
const previous = disabled;
setDisabled(checked);
try {
const res = await fetch("/api/v1/admin/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ SIGNUPS_DISABLED: checked ? "true" : null }),
});
if (!res.ok) throw new Error("Save failed");
toast.success(checked ? "Signups disabled" : "Signups enabled");
} catch {
setDisabled(previous);
toast.error("Failed to update");
} finally {
setSaving(false);
}
}
return (
<section className="rounded-xl border p-6 space-y-1">
<div className="flex items-center justify-between">
<div>
<h2 className="font-semibold text-lg">Signups</h2>
<p className="text-sm text-muted-foreground mt-1">
When disabled, only people with a valid invite link can create an account.
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Label htmlFor="signups-disabled" className="text-sm">
{disabled ? "Disabled" : "Open"}
</Label>
<Switch
id="signups-disabled"
checked={disabled}
disabled={saving}
onCheckedChange={(checked) => { void handleChange(checked); }}
/>
</div>
</div>
</section>
);
}