+ Set a new password
+
+ {usable
+ ? "Choose a new password for your account."
+ : "That reset link is no longer valid. Ask the owner for a new one."}
+
+
+
+ {usable ? (
+
+ ) : (
+
+
+ Back to sign in
+
+
+ )}
+
+
+
+ )
+}
diff --git a/src/app/api/admin/invites/[id]/qr/route.ts b/src/app/api/admin/invites/[id]/qr/route.ts
new file mode 100644
index 0000000..0d87563
--- /dev/null
+++ b/src/app/api/admin/invites/[id]/qr/route.ts
@@ -0,0 +1,42 @@
+import { NextResponse } from "next/server"
+import QRCode from "qrcode"
+import { requireOwner } from "@/lib/authz"
+import { prisma } from "@/lib/prisma"
+import { inviteUrl } from "@/lib/invites"
+import { publicOrigin } from "@/lib/origin"
+
+/**
+ * QR code for an invite link, as an SVG.
+ *
+ * Generated locally by node-qrcode - no external service, which matters because the
+ * CSP forbids loading from anywhere but this origin. Served as image/svg+xml so it
+ * can be used in a plain , which `img-src 'self'` already allows.
+ */
+export async function GET(
+ request: Request,
+ { params }: { params: { id: string } }
+) {
+ const session = await requireOwner()
+ if (session instanceof NextResponse) return session
+
+ const invite = await prisma.invite.findUnique({
+ where: { id: params.id },
+ select: { token: true },
+ })
+ if (!invite) return new Response(null, { status: 404 })
+
+ const svg = await QRCode.toString(inviteUrl(invite.token, publicOrigin(request)), {
+ type: "svg",
+ margin: 1,
+ width: 320,
+ errorCorrectionLevel: "M",
+ })
+
+ return new Response(svg, {
+ headers: {
+ "Content-Type": "image/svg+xml",
+ // The link is a bearer token; keep it out of shared caches.
+ "Cache-Control": "private, no-store",
+ },
+ })
+}
diff --git a/src/app/api/admin/invites/[id]/route.ts b/src/app/api/admin/invites/[id]/route.ts
new file mode 100644
index 0000000..0c0a62b
--- /dev/null
+++ b/src/app/api/admin/invites/[id]/route.ts
@@ -0,0 +1,27 @@
+import { NextResponse } from "next/server"
+import { requireOwner } from "@/lib/authz"
+import { prisma } from "@/lib/prisma"
+
+/**
+ * Revokes rather than deletes, so the redemption history of an already-used invite
+ * survives - that is the record of who invited whom.
+ */
+export async function DELETE(
+ _request: Request,
+ { params }: { params: { id: string } }
+) {
+ const session = await requireOwner()
+ if (session instanceof NextResponse) return session
+
+ const invite = await prisma.invite.findUnique({ where: { id: params.id } })
+ if (!invite) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 })
+ }
+
+ await prisma.invite.update({
+ where: { id: params.id },
+ data: { revokedAt: new Date() },
+ })
+
+ return NextResponse.json({ success: true })
+}
diff --git a/src/app/api/admin/invites/route.ts b/src/app/api/admin/invites/route.ts
new file mode 100644
index 0000000..f3209b7
--- /dev/null
+++ b/src/app/api/admin/invites/route.ts
@@ -0,0 +1,56 @@
+import { NextResponse } from "next/server"
+import { z } from "zod"
+import { requireOwner } from "@/lib/authz"
+import { prisma } from "@/lib/prisma"
+import { generateInviteToken } from "@/lib/invites"
+
+const createSchema = z.object({
+ label: z.string().max(100).optional(),
+ maxUses: z.number().int().min(1).max(50).default(1),
+ expiresInHours: z.number().int().min(1).max(24 * 90).nullable().optional(),
+})
+
+export async function GET() {
+ const session = await requireOwner()
+ if (session instanceof NextResponse) return session
+
+ const invites = await prisma.invite.findMany({
+ orderBy: { createdAt: "desc" },
+ include: {
+ redemptions: {
+ select: { id: true, email: true, createdAt: true },
+ },
+ },
+ })
+
+ return NextResponse.json({ invites })
+}
+
+export async function POST(request: Request) {
+ const session = await requireOwner()
+ if (session instanceof NextResponse) return session
+
+ const parsed = createSchema.safeParse(await request.json().catch(() => ({})))
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: "Invalid input", details: parsed.error.flatten() },
+ { status: 400 }
+ )
+ }
+
+ const { label, maxUses, expiresInHours } = parsed.data
+
+ const invite = await prisma.invite.create({
+ data: {
+ token: generateInviteToken(),
+ label: label?.trim() || null,
+ createdById: session.user.id,
+ maxUses,
+ expiresAt: expiresInHours
+ ? new Date(Date.now() + expiresInHours * 60 * 60 * 1000)
+ : null,
+ },
+ })
+
+ return NextResponse.json({ invite }, { status: 201 })
+}
diff --git a/src/app/api/admin/users/[id]/reset-link/route.ts b/src/app/api/admin/users/[id]/reset-link/route.ts
new file mode 100644
index 0000000..0c33fd9
--- /dev/null
+++ b/src/app/api/admin/users/[id]/reset-link/route.ts
@@ -0,0 +1,57 @@
+import { NextResponse } from "next/server"
+import { randomBytes } from "crypto"
+import { requireOwner } from "@/lib/authz"
+import { prisma } from "@/lib/prisma"
+import { publicOrigin } from "@/lib/origin"
+
+const RESET_TTL_HOURS = 24
+
+/**
+ * Issues a single-use password reset link for a member.
+ *
+ * There is no email infrastructure, so the owner delivers this out of band, the same
+ * way an invite is delivered. Without it a member who forgets their password is
+ * permanently locked out and the owner has no way to help.
+ *
+ * Any unused reset for the same user is invalidated first, so only the newest link works.
+ */
+export async function POST(
+ request: Request,
+ { params }: { params: { id: string } }
+) {
+ const session = await requireOwner()
+ if (session instanceof NextResponse) return session
+
+ const user = await prisma.user.findUnique({
+ where: { id: params.id },
+ select: { id: true, email: true, password: true },
+ })
+ if (!user) return NextResponse.json({ error: "Not found" }, { status: 404 })
+ if (!user.password) {
+ return NextResponse.json(
+ { error: "That account does not use a password" },
+ { status: 400 }
+ )
+ }
+
+ const token = randomBytes(32).toString("hex")
+
+ await prisma.$transaction([
+ prisma.passwordReset.updateMany({
+ where: { userId: params.id, usedAt: null },
+ data: { usedAt: new Date() },
+ }),
+ prisma.passwordReset.create({
+ data: {
+ token,
+ userId: params.id,
+ expiresAt: new Date(Date.now() + RESET_TTL_HOURS * 60 * 60 * 1000),
+ },
+ }),
+ ])
+
+ return NextResponse.json({
+ url: `${publicOrigin(request)}/reset/${token}`,
+ expiresInHours: RESET_TTL_HOURS,
+ })
+}
diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts
new file mode 100644
index 0000000..f03b942
--- /dev/null
+++ b/src/app/api/admin/users/[id]/route.ts
@@ -0,0 +1,113 @@
+import { NextResponse } from "next/server"
+import { z } from "zod"
+import { requireOwner } from "@/lib/authz"
+import { prisma } from "@/lib/prisma"
+import { deleteImage } from "@/lib/s3"
+import { revokeGatewayKey } from "@/lib/ai/switchboard-keys"
+
+const patchSchema = z.object({
+ status: z.enum(["ACTIVE", "SUSPENDED"]).optional(),
+ aiEnabled: z.boolean().optional(),
+ aiDailyBudgetUsd: z.number().min(0).max(100).nullable().optional(),
+})
+
+/** Refuse to strand the deployment without a usable owner. */
+async function guardLastOwner(targetId: string): Promise {
+ const target = await prisma.user.findUnique({
+ where: { id: targetId },
+ select: { role: true },
+ })
+ if (!target) return "Not found"
+ if (target.role !== "OWNER") return null
+
+ const activeOwners = await prisma.user.count({
+ where: { role: "OWNER", status: "ACTIVE" },
+ })
+ return activeOwners <= 1 ? "Cannot suspend or delete the only owner" : null
+}
+
+export async function PATCH(
+ request: Request,
+ { params }: { params: { id: string } }
+) {
+ const session = await requireOwner()
+ if (session instanceof NextResponse) return session
+
+ const parsed = patchSchema.safeParse(await request.json().catch(() => ({})))
+ if (!parsed.success) {
+ return NextResponse.json({ error: "Invalid input" }, { status: 400 })
+ }
+
+ if (parsed.data.status === "SUSPENDED") {
+ if (params.id === session.user.id) {
+ return NextResponse.json(
+ { error: "You cannot suspend your own account" },
+ { status: 400 }
+ )
+ }
+ const problem = await guardLastOwner(params.id)
+ if (problem) return NextResponse.json({ error: problem }, { status: 400 })
+ }
+
+ const user = await prisma.user.update({
+ where: { id: params.id },
+ data: parsed.data,
+ select: { id: true, status: true, aiEnabled: true, aiDailyBudgetUsd: true },
+ })
+
+ return NextResponse.json({ user })
+}
+
+export async function DELETE(
+ _request: Request,
+ { params }: { params: { id: string } }
+) {
+ const session = await requireOwner()
+ if (session instanceof NextResponse) return session
+
+ if (params.id === session.user.id) {
+ return NextResponse.json(
+ { error: "You cannot delete your own account" },
+ { status: 400 }
+ )
+ }
+ const problem = await guardLastOwner(params.id)
+ if (problem) {
+ return NextResponse.json({ error: problem }, { status: problem === "Not found" ? 404 : 400 })
+ }
+
+ // Collect what the cascade will make unreachable, before it happens.
+ const [drinks, barItems, scans, keys] = await Promise.all([
+ prisma.drink.findMany({ where: { userId: params.id }, select: { imageUrl: true } }),
+ prisma.barItem.findMany({ where: { userId: params.id }, select: { imageUrl: true } }),
+ prisma.menuScan.findMany({ where: { userId: params.id }, select: { imageUrl: true } }),
+ prisma.userApiKey.findMany({
+ where: { userId: params.id },
+ select: { gatewayKeyId: true },
+ }),
+ ])
+
+ await prisma.user.delete({ where: { id: params.id } })
+
+ // Best effort after the fact: never let storage or the gateway block a deletion
+ // the user asked for, and never leave the account half-deleted.
+ const keyPrefix = "/minio-images/"
+ const objectKeys = [...drinks, ...barItems, ...scans]
+ .map((r) => r.imageUrl)
+ .filter((u): u is string => !!u && u.startsWith(keyPrefix))
+ .map((u) => u.slice(keyPrefix.length))
+
+ await Promise.allSettled([
+ ...objectKeys.map((k) => deleteImage(k)),
+ ...keys
+ .map((k) => k.gatewayKeyId)
+ .filter((id): id is string => !!id)
+ .map((id) => revokeGatewayKey(id)),
+ ])
+
+ return NextResponse.json({
+ success: true,
+ imagesDeleted: objectKeys.length,
+ gatewayKeysRevoked: keys.filter((k) => k.gatewayKeyId).length,
+ })
+}
diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts
new file mode 100644
index 0000000..96cfae5
--- /dev/null
+++ b/src/app/api/admin/users/route.ts
@@ -0,0 +1,42 @@
+import { NextResponse } from "next/server"
+import { requireOwner } from "@/lib/authz"
+import { prisma } from "@/lib/prisma"
+
+export async function GET() {
+ const session = await requireOwner()
+ if (session instanceof NextResponse) return session
+
+ const users = await prisma.user.findMany({
+ orderBy: { createdAt: "asc" },
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ role: true,
+ status: true,
+ aiEnabled: true,
+ aiDailyBudgetUsd: true,
+ createdAt: true,
+ _count: { select: { drinks: true, ratings: true, menuScans: true } },
+ },
+ })
+
+ // Spend for the trailing 30 days, grouped in one query rather than per user.
+ const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
+ const spend = await prisma.aiCall.groupBy({
+ by: ["userId"],
+ where: { createdAt: { gte: since } },
+ _sum: { costUsd: true },
+ _count: { _all: true },
+ })
+ const spendByUser = new Map(
+ spend.map((s) => [s.userId, { usd: s._sum.costUsd ?? 0, calls: s._count._all }])
+ )
+
+ return NextResponse.json({
+ users: users.map((u) => ({
+ ...u,
+ aiSpend30d: spendByUser.get(u.id) ?? { usd: 0, calls: 0 },
+ })),
+ })
+}
diff --git a/src/app/api/auth/reset/route.ts b/src/app/api/auth/reset/route.ts
new file mode 100644
index 0000000..b97eee0
--- /dev/null
+++ b/src/app/api/auth/reset/route.ts
@@ -0,0 +1,62 @@
+import { NextResponse } from "next/server"
+import { z } from "zod"
+import bcrypt from "bcryptjs"
+import { prisma } from "@/lib/prisma"
+import { rateLimit } from "@/lib/rate-limit"
+
+const resetSchema = z.object({
+ token: z.string().min(32).max(128),
+ password: z
+ .string()
+ .min(10, "Password must be at least 10 characters")
+ .max(128, "Password must be 128 characters or less")
+ .regex(/[a-z]/, "Password must contain at least one lowercase letter")
+ .regex(/[A-Z]/, "Password must contain at least one uppercase letter")
+ .regex(/[0-9]/, "Password must contain at least one number"),
+})
+
+export async function POST(request: Request) {
+ const parsed = resetSchema.safeParse(await request.json().catch(() => ({})))
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: parsed.error.issues[0]?.message ?? "Invalid input" },
+ { status: 400 }
+ )
+ }
+
+ const { token, password } = parsed.data
+
+ // Keyed on the token so guessing one costs attempts against that token alone.
+ const rl = rateLimit(`reset:${token.slice(0, 16)}`, 5, 15 * 60 * 1000)
+ if (!rl.success) {
+ return NextResponse.json(
+ { error: "Too many attempts. Please try again later." },
+ { status: 429 }
+ )
+ }
+
+ const reset = await prisma.passwordReset.findUnique({ where: { token } })
+ // One message for every failure mode, so this cannot be used to probe which
+ // tokens exist or have been used.
+ if (!reset || reset.usedAt || reset.expiresAt <= new Date()) {
+ return NextResponse.json(
+ { error: "That reset link is no longer valid. Ask the owner for a new one." },
+ { status: 400 }
+ )
+ }
+
+ const hashed = await bcrypt.hash(password, 10)
+
+ await prisma.$transaction([
+ prisma.user.update({
+ where: { id: reset.userId },
+ data: { password: hashed },
+ }),
+ prisma.passwordReset.update({
+ where: { id: reset.id },
+ data: { usedAt: new Date() },
+ }),
+ ])
+
+ return NextResponse.json({ success: true })
+}
diff --git a/src/app/invite/[token]/route.ts b/src/app/invite/[token]/route.ts
index 9400162..ee02c3d 100644
--- a/src/app/invite/[token]/route.ts
+++ b/src/app/invite/[token]/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server"
import { INVITE_COOKIE, INVITE_COOKIE_MAX_AGE, inspectInvite } from "@/lib/invites"
+import { publicOrigin } from "@/lib/origin"
/**
* Entry point for an invite link or QR code.
@@ -13,20 +14,6 @@ import { INVITE_COOKIE, INVITE_COOKIE_MAX_AGE, inspectInvite } from "@/lib/invit
*/
export const dynamic = "force-dynamic"
-/**
- * The app binds 0.0.0.0:3000 behind a reverse proxy, so `request.url` carries the
- * internal address and redirects built from it are unreachable. NEXTAUTH_URL is the
- * configured public origin and cannot be influenced by a request header.
- */
-function publicOrigin(request: Request): string {
- const configured = process.env.NEXTAUTH_URL
- if (configured) return configured.replace(/\/$/, "")
-
- const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host")
- const proto = request.headers.get("x-forwarded-proto") ?? "https"
- return host ? `${proto}://${host}` : new URL(request.url).origin
-}
-
export async function GET(
request: Request,
{ params }: { params: { token: string } }
diff --git a/src/components/admin/invites-client.tsx b/src/components/admin/invites-client.tsx
new file mode 100644
index 0000000..95c7c55
--- /dev/null
+++ b/src/components/admin/invites-client.tsx
@@ -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(null)
+ const [showQr, setShowQr] = useState(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 (
+ <>
+
+
+
+
+ New invite
+
+
+ Anyone holding the link can sign up, so prefer a single use and a short
+ expiry.
+
+
+
+
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index 044190a..1d0d099 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -9,7 +9,14 @@ import { rateLimit } from "@/lib/rate-limit"
* prefix, so `/share` also covers `/share/`. Everything else is private -
* see the `authorized` callback and the denylist matcher in src/middleware.ts.
*/
-const PUBLIC_ROUTES = ["/login", "/register", "/join", "/invite", "/share"]
+const PUBLIC_ROUTES = [
+ "/login",
+ "/register",
+ "/join",
+ "/invite",
+ "/reset",
+ "/share",
+]
// Email and password only. Google and GitHub were configured but never had
// credentials set and no account ever linked to them, and dropping them keeps
diff --git a/src/lib/origin.ts b/src/lib/origin.ts
new file mode 100644
index 0000000..3973fbe
--- /dev/null
+++ b/src/lib/origin.ts
@@ -0,0 +1,15 @@
+/**
+ * The public origin of this deployment.
+ *
+ * `request.url` carries the app's internal bind address (0.0.0.0:3000) because it
+ * runs behind a reverse proxy, so links built from it are unreachable. NEXTAUTH_URL
+ * is the configured public origin and cannot be influenced by a request header.
+ */
+export function publicOrigin(request: Request): string {
+ const configured = process.env.NEXTAUTH_URL
+ if (configured) return configured.replace(/\/$/, "")
+
+ const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host")
+ const proto = request.headers.get("x-forwarded-proto") ?? "https"
+ return host ? `${proto}://${host}` : new URL(request.url).origin
+}