Add the owner admin area: invites, people, reset links

Creating an invite previously meant writing SQL by hand, which made the
whole feature unusable in practice. The owner can now create invites
with a use count and expiry, copy the link, show a QR code, and revoke.

QR codes are generated locally by node-qrcode as inline SVG. The CSP
forbids loading from any other origin, so an external generator was not
an option, and img-src 'self' already covers a same-origin SVG.

People lists everyone with what they have added and what their AI use
has cost over 30 days, with suspend, reactivate, per-user AI toggle and
delete. Deleting collects image keys and the minted gateway key id
before the cascade removes the rows, then cleans both up best-effort -
storage or the gateway being unavailable must not leave an account
half-deleted. Guards refuse to suspend or delete yourself or the last
owner.

Password reset links close the gap that came with keeping email and
password sign-in: with no email infrastructure, a member who forgets
their password had no way back in and the owner had no way to help. The
owner generates a single-use 24 hour link and delivers it the same way
as an invite. Issuing one invalidates any earlier unused reset, and the
reset endpoint answers identically for unknown, used and expired tokens
so it cannot be used to probe which exist.

The admin area 404s for members rather than 403ing, so its existence is
not advertised, and every /api/admin handler independently requires the
owner role.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JP
2026-08-08 21:26:33 +00:00
parent 34afc497f4
commit 0ead1385f7
21 changed files with 1491 additions and 25 deletions

View File

@@ -0,0 +1,271 @@
"use client"
import { useState } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { Check, Copy, Loader2, QrCode, Plus, Ban } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectOption } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
interface Redemption {
id: string
email: string | null
createdAt: string
}
interface Invite {
id: string
token: string
label: string | null
maxUses: number
usedCount: number
expiresAt: string | null
revokedAt: string | null
createdAt: string
redemptions: Redemption[]
}
const EXPIRY_OPTIONS = [
{ value: "24", label: "24 hours" },
{ value: "168", label: "7 days" },
{ value: "720", label: "30 days" },
{ value: "", label: "Never" },
]
function statusOf(invite: Invite): { label: string; tone: string } {
if (invite.revokedAt) return { label: "Revoked", tone: "text-muted-foreground" }
if (invite.expiresAt && new Date(invite.expiresAt) <= new Date())
return { label: "Expired", tone: "text-muted-foreground" }
if (invite.usedCount >= invite.maxUses)
return { label: "Used", tone: "text-muted-foreground" }
return { label: "Active", tone: "text-green-600" }
}
export function InvitesClient() {
const queryClient = useQueryClient()
const [label, setLabel] = useState("")
const [maxUses, setMaxUses] = useState("1")
const [expiry, setExpiry] = useState("168")
const [copied, setCopied] = useState<string | null>(null)
const [showQr, setShowQr] = useState<string | null>(null)
const { data, isLoading } = useQuery<{ invites: Invite[] }>({
queryKey: ["admin-invites"],
queryFn: async () => {
const res = await fetch("/api/admin/invites")
if (!res.ok) throw new Error("Failed to load invites")
return res.json()
},
})
const create = useMutation({
mutationFn: async () => {
const res = await fetch("/api/admin/invites", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
label: label.trim() || undefined,
maxUses: Number(maxUses) || 1,
expiresInHours: expiry ? Number(expiry) : null,
}),
})
if (!res.ok) throw new Error("Failed to create invite")
return res.json()
},
onSuccess: () => {
setLabel("")
queryClient.invalidateQueries({ queryKey: ["admin-invites"] })
},
})
const revoke = useMutation({
mutationFn: async (id: string) => {
const res = await fetch(`/api/admin/invites/${id}`, { method: "DELETE" })
if (!res.ok) throw new Error("Failed to revoke invite")
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["admin-invites"] }),
})
function linkFor(invite: Invite) {
return `${window.location.origin}/invite/${invite.token}`
}
async function copy(invite: Invite) {
await navigator.clipboard.writeText(linkFor(invite))
setCopied(invite.id)
setTimeout(() => setCopied(null), 2000)
}
const invites = data?.invites ?? []
return (
<>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Plus className="h-5 w-5" />
New invite
</CardTitle>
<CardDescription>
Anyone holding the link can sign up, so prefer a single use and a short
expiry.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2 sm:col-span-1">
<Label htmlFor="invite-label">Who is it for?</Label>
<Input
id="invite-label"
placeholder="e.g. Dave"
value={label}
onChange={(e) => setLabel(e.target.value)}
maxLength={100}
/>
</div>
<div className="space-y-2">
<Label htmlFor="invite-uses">Uses</Label>
<Input
id="invite-uses"
type="number"
min={1}
max={50}
value={maxUses}
onChange={(e) => setMaxUses(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="invite-expiry">Expires</Label>
<Select
id="invite-expiry"
value={expiry}
onChange={(e) => setExpiry(e.target.value)}
>
{EXPIRY_OPTIONS.map((o) => (
<SelectOption key={o.label} value={o.value}>
{o.label}
</SelectOption>
))}
</Select>
</div>
</div>
<Button onClick={() => create.mutate()} disabled={create.isPending}>
{create.isPending ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : null}
Create invite
</Button>
{create.isError && (
<p className="text-sm text-destructive">Could not create that invite.</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Existing invites</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{isLoading && (
<p className="text-sm text-muted-foreground">Loading...</p>
)}
{!isLoading && invites.length === 0 && (
<p className="text-sm text-muted-foreground">
No invites yet. Create one above to let someone join.
</p>
)}
{invites.map((invite) => {
const status = statusOf(invite)
const active = status.label === "Active"
return (
<div key={invite.id} className="rounded-md border p-3 space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-medium truncate">
{invite.label || "Untitled invite"}
</p>
<div className="flex flex-wrap items-center gap-2 mt-1">
<Badge variant="outline" className={`text-xs ${status.tone}`}>
{status.label}
</Badge>
<span className="text-xs text-muted-foreground">
{invite.usedCount}/{invite.maxUses} used
</span>
{invite.expiresAt && (
<span className="text-xs text-muted-foreground">
expires {new Date(invite.expiresAt).toLocaleDateString()}
</span>
)}
</div>
{invite.redemptions.length > 0 && (
<p className="text-xs text-muted-foreground mt-1">
Used by {invite.redemptions.map((r) => r.email).join(", ")}
</p>
)}
</div>
{active && (
<Button
variant="ghost"
size="sm"
onClick={() => revoke.mutate(invite.id)}
disabled={revoke.isPending}
>
<Ban className="h-4 w-4 mr-1 text-destructive" />
Revoke
</Button>
)}
</div>
{active && (
<>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => copy(invite)}>
{copied === invite.id ? (
<Check className="h-4 w-4 mr-1 text-green-600" />
) : (
<Copy className="h-4 w-4 mr-1" />
)}
{copied === invite.id ? "Copied" : "Copy link"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
setShowQr(showQr === invite.id ? null : invite.id)
}
>
<QrCode className="h-4 w-4 mr-1" />
{showQr === invite.id ? "Hide QR" : "QR code"}
</Button>
</div>
{showQr === invite.id && (
<div className="flex justify-center bg-white rounded-md p-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`/api/admin/invites/${invite.id}/qr`}
alt="Invite QR code"
className="h-56 w-56"
/>
</div>
)}
</>
)}
</div>
)
})}
</CardContent>
</Card>
</>
)
}

View File

@@ -0,0 +1,264 @@
"use client"
import { useState } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { Check, Copy, KeyRound, Loader2, Trash2, UserX, UserCheck } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card"
interface AdminUser {
id: string
name: string | null
email: string | null
role: "OWNER" | "MEMBER"
status: "ACTIVE" | "SUSPENDED"
aiEnabled: boolean
aiDailyBudgetUsd: number | null
createdAt: string
_count: { drinks: number; ratings: number; menuScans: number }
aiSpend30d: { usd: number; calls: number }
}
export function UsersClient() {
const queryClient = useQueryClient()
const [resetLink, setResetLink] = useState<{ id: string; url: string } | null>(null)
const [copied, setCopied] = useState(false)
const [error, setError] = useState<string | null>(null)
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
const { data, isLoading } = useQuery<{ users: AdminUser[] }>({
queryKey: ["admin-users"],
queryFn: async () => {
const res = await fetch("/api/admin/users")
if (!res.ok) throw new Error("Failed to load users")
return res.json()
},
})
const patch = useMutation({
mutationFn: async ({ id, body }: { id: string; body: Record<string, unknown> }) => {
const res = await fetch(`/api/admin/users/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
const json = await res.json()
if (!res.ok) throw new Error(json.error || "Update failed")
return json
},
onSuccess: () => {
setError(null)
queryClient.invalidateQueries({ queryKey: ["admin-users"] })
},
onError: (e: Error) => setError(e.message),
})
const remove = useMutation({
mutationFn: async (id: string) => {
const res = await fetch(`/api/admin/users/${id}`, { method: "DELETE" })
const json = await res.json()
if (!res.ok) throw new Error(json.error || "Delete failed")
return json
},
onSuccess: () => {
setError(null)
setConfirmDelete(null)
queryClient.invalidateQueries({ queryKey: ["admin-users"] })
},
onError: (e: Error) => setError(e.message),
})
const makeResetLink = useMutation({
mutationFn: async (id: string) => {
const res = await fetch(`/api/admin/users/${id}/reset-link`, { method: "POST" })
const json = await res.json()
if (!res.ok) throw new Error(json.error || "Could not create a reset link")
return { id, url: json.url as string }
},
onSuccess: (r) => {
setError(null)
setResetLink(r)
},
onError: (e: Error) => setError(e.message),
})
const users = data?.users ?? []
if (isLoading) {
return <p className="text-sm text-muted-foreground">Loading...</p>
}
return (
<div className="space-y-4">
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
{users.map((user) => (
<Card key={user.id}>
<CardContent className="pt-6 space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium truncate">{user.name || "No name"}</p>
{user.role === "OWNER" && (
<Badge variant="outline" className="text-xs">
Owner
</Badge>
)}
{user.status === "SUSPENDED" && (
<Badge variant="outline" className="text-xs text-destructive">
Suspended
</Badge>
)}
{!user.aiEnabled && (
<Badge variant="outline" className="text-xs text-muted-foreground">
AI off
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground truncate">{user.email}</p>
<p className="text-xs text-muted-foreground mt-1">
{user._count.drinks} drinks · {user._count.ratings} ratings ·{" "}
{user._count.menuScans} scans · joined{" "}
{new Date(user.createdAt).toLocaleDateString()}
</p>
<p className="text-xs text-muted-foreground">
AI last 30 days: ${user.aiSpend30d.usd.toFixed(4)} over{" "}
{user.aiSpend30d.calls} calls
{user.role === "MEMBER" && (
<> · daily cap ${(user.aiDailyBudgetUsd ?? 1).toFixed(2)}</>
)}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
patch.mutate({ id: user.id, body: { aiEnabled: !user.aiEnabled } })
}
disabled={patch.isPending}
>
{user.aiEnabled ? "Turn AI off" : "Turn AI on"}
</Button>
{user.role !== "OWNER" && (
<>
<Button
variant="outline"
size="sm"
onClick={() =>
patch.mutate({
id: user.id,
body: {
status: user.status === "ACTIVE" ? "SUSPENDED" : "ACTIVE",
},
})
}
disabled={patch.isPending}
>
{user.status === "ACTIVE" ? (
<>
<UserX className="h-4 w-4 mr-1" />
Suspend
</>
) : (
<>
<UserCheck className="h-4 w-4 mr-1" />
Reactivate
</>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => makeResetLink.mutate(user.id)}
disabled={makeResetLink.isPending}
>
<KeyRound className="h-4 w-4 mr-1" />
Reset link
</Button>
{confirmDelete === user.id ? (
<>
<Button
variant="destructive"
size="sm"
onClick={() => remove.mutate(user.id)}
disabled={remove.isPending}
>
{remove.isPending ? (
<Loader2 className="h-4 w-4 animate-spin mr-1" />
) : null}
Delete everything
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setConfirmDelete(null)}
>
Cancel
</Button>
</>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setConfirmDelete(user.id)}
>
<Trash2 className="h-4 w-4 mr-1 text-destructive" />
Delete
</Button>
)}
</>
)}
</div>
{confirmDelete === user.id && (
<p className="text-xs text-destructive">
This permanently removes their account, drinks, ratings, photos and
gateway key. It cannot be undone.
</p>
)}
{resetLink?.id === user.id && (
<div className="rounded-md border border-dashed p-3 space-y-2">
<p className="text-xs text-muted-foreground">
Single-use, valid 24 hours. Send it to them yourself - the app
cannot send email.
</p>
<div className="flex gap-2">
<code className="text-xs bg-muted px-2 py-1 rounded flex-1 truncate">
{resetLink.url}
</code>
<Button
variant="outline"
size="sm"
onClick={async () => {
await navigator.clipboard.writeText(resetLink.url)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}}
>
{copied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
))}
</div>
)
}

View File

@@ -0,0 +1,98 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { Loader2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export function ResetForm({ token }: { token: string }) {
const router = useRouter()
const [password, setPassword] = useState("")
const [confirm, setConfirm] = useState("")
const [error, setError] = useState("")
const [done, setDone] = useState(false)
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError("")
if (password !== confirm) {
setError("Passwords do not match")
return
}
setLoading(true)
try {
const res = await fetch("/api/auth/reset", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, password }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error || "Could not reset the password")
setLoading(false)
return
}
setDone(true)
setTimeout(() => router.push("/login"), 1500)
} catch {
setError("Something went wrong. Please try again.")
setLoading(false)
}
}
if (done) {
return (
<p className="text-center text-sm text-muted-foreground">
Password updated. Taking you to sign in...
</p>
)
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="password">New password</Label>
<Input
id="password"
type="password"
placeholder="Min 10 chars, upper+lower+number"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={10}
maxLength={128}
disabled={loading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm">Confirm new password</Label>
<Input
id="confirm"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
minLength={10}
disabled={loading}
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
Update password
</Button>
</form>
)
}

View File

@@ -3,6 +3,7 @@
import { useState } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { useSession } from "next-auth/react"
import {
Camera,
Sparkles,
@@ -11,6 +12,8 @@ import {
MoreHorizontal,
X,
BookOpen,
UserPlus,
Users,
} from "lucide-react"
import { cn } from "@/lib/utils"
@@ -22,11 +25,21 @@ const moreItems = [
{ href: "/settings", label: "Settings", icon: Settings },
]
// Appended only for owners; the /admin layout 404s for everyone else.
const ADMIN_ITEMS = [
{ href: "/admin/invites", label: "Invites", icon: UserPlus },
{ href: "/admin/users", label: "People", icon: Users },
]
export function MoreMenu() {
const [open, setOpen] = useState(false)
const pathname = usePathname()
const { data: session } = useSession()
const isActiveInMore = moreItems.some((item) =>
const items =
session?.user?.role === "OWNER" ? [...moreItems, ...ADMIN_ITEMS] : moreItems
const isActiveInMore = items.some((item) =>
pathname === item.href || pathname.startsWith(item.href + "/")
)
@@ -57,7 +70,7 @@ export function MoreMenu() {
{/* Menu */}
<div className="absolute bottom-full right-0 mb-2 z-50 bg-card border rounded-lg shadow-lg p-2 min-w-[160px]">
{moreItems.map((item) => {
{items.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(item.href + "/")
return (
<Link

View File

@@ -15,6 +15,8 @@ import {
GlassWater,
Sparkles,
BookOpen,
UserPlus,
Users,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
@@ -32,6 +34,13 @@ const navItems = [
{ href: "/settings", label: "Settings", icon: Settings },
]
// Rendered separately and only for owners, so members never see that an admin
// area exists. The /admin layout 404s for them regardless.
const ADMIN_ITEMS = [
{ href: "/admin/invites", label: "Invites", icon: UserPlus },
{ href: "/admin/users", label: "People", icon: Users },
]
export function Sidebar() {
const pathname = usePathname()
const { data: session } = useSession()
@@ -65,6 +74,32 @@ export function Sidebar() {
})}
</nav>
{session?.user?.role === "OWNER" && (
<div className="px-2 mt-4 pt-4 border-t space-y-1">
<p className="px-3 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Admin
</p>
{ADMIN_ITEMS.map((item) => {
const isActive = pathname.startsWith(item.href)
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<item.icon className="h-5 w-5" />
{item.label}
</Link>
)
})}
</div>
)}
<HistorySection />
<div className="p-4 border-t">