c8f4b50ef3
All literal "team" tier-value references renamed to "family" across API routes, admin UI, OpenAPI schemas, and lib/tiers.ts. The DB enum value itself is renamed in place via ALTER TYPE ... RENAME VALUE (migration 0044) rather than drizzle-kit's auto-generated drop-and-recreate-the-enum migration, which would have failed against any existing row still holding 'team' — RENAME VALUE preserves existing data with no cast/backfill needed. Also adds STRIPE_PLAN.md — a full Stripe billing integration plan (Checkout+Portal, tier→Price mapping, admin billing dashboard, and a multi-user Family-group design since Family is meant to cover several accounts under one subscription, not one payer). Planning only, no Stripe code yet. v0.47.0
101 lines
3.3 KiB
TypeScript
101 lines
3.3 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { requireAdmin } from "@/lib/api-auth";
|
|
import { auth } from "@/lib/auth/server";
|
|
import { db, users, auditLogs, eq } from "@epicure/db";
|
|
import { createInvite, consumeInvite, INVITE_COOKIE } from "@/lib/invites";
|
|
import { randomBytes, randomUUID } from "crypto";
|
|
import { APIError } from "better-auth";
|
|
|
|
const VALID_ROLES = ["user", "moderator", "admin"] as const;
|
|
const VALID_TIERS = ["free", "pro", "family"] as const;
|
|
|
|
export async function POST(req: NextRequest) {
|
|
const { session, response } = await requireAdmin();
|
|
if (response) return response;
|
|
|
|
const body = (await req.json()) as {
|
|
email?: string;
|
|
name?: string;
|
|
role?: string;
|
|
tier?: string;
|
|
};
|
|
|
|
const email = body.email?.trim().toLowerCase();
|
|
const name = body.name?.trim();
|
|
const role = body.role ?? "user";
|
|
const tier = body.tier ?? "free";
|
|
|
|
if (!email || !name) {
|
|
return NextResponse.json({ error: "email and name are required" }, { status: 400 });
|
|
}
|
|
if (!VALID_ROLES.includes(role as (typeof VALID_ROLES)[number])) {
|
|
return NextResponse.json({ error: "Invalid role" }, { status: 400 });
|
|
}
|
|
if (!VALID_TIERS.includes(tier as (typeof VALID_TIERS)[number])) {
|
|
return NextResponse.json({ error: "Invalid tier" }, { status: 400 });
|
|
}
|
|
|
|
const [existing] = await db.select({ id: users.id }).from(users).where(eq(users.email, email));
|
|
if (existing) {
|
|
return NextResponse.json({ error: "A user with this email already exists" }, { status: 409 });
|
|
}
|
|
|
|
// Route creation through the same invite gate the public signup flow uses,
|
|
// so it works identically whether signups are currently open or closed —
|
|
// and so role/tier assignment goes through the one audited code path.
|
|
const invite = await createInvite({
|
|
createdById: session!.user.id,
|
|
email,
|
|
role: role as (typeof VALID_ROLES)[number],
|
|
tier: tier as (typeof VALID_TIERS)[number],
|
|
expiresInDays: 1,
|
|
});
|
|
|
|
const temporaryPassword = randomBytes(24).toString("base64url");
|
|
|
|
try {
|
|
await auth.api.signUpEmail({
|
|
body: { email, name, password: temporaryPassword },
|
|
headers: new Headers({ cookie: `${INVITE_COOKIE}=${invite!.token}` }),
|
|
});
|
|
} catch (err) {
|
|
if (err instanceof APIError) {
|
|
return NextResponse.json({ error: err.message }, { status: err.statusCode ?? 400 });
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
const [created] = await db
|
|
.update(users)
|
|
.set({ emailVerified: true })
|
|
.where(eq(users.email, email))
|
|
.returning();
|
|
|
|
if (!created) {
|
|
return NextResponse.json({ error: "User creation failed" }, { status: 500 });
|
|
}
|
|
|
|
// The invite gate only consumes on the databaseHooks "after" path when a
|
|
// cookie is present on a real request; belt-and-suspenders it here too.
|
|
await consumeInvite(invite!.id, created.id);
|
|
|
|
// Let the new user set their own password instead of the admin knowing it.
|
|
await auth.api.requestPasswordReset({
|
|
body: { email, redirectTo: "/reset-password" },
|
|
});
|
|
|
|
await db.insert(auditLogs).values({
|
|
id: randomUUID(),
|
|
userId: session!.user.id,
|
|
action: "admin.user.create",
|
|
targetType: "user",
|
|
targetId: created.id,
|
|
metadata: JSON.stringify({ email, role, tier }),
|
|
createdAt: new Date(),
|
|
});
|
|
|
|
return NextResponse.json({
|
|
user: { id: created.id, email: created.email, name: created.name, role: created.role, tier: created.tier },
|
|
});
|
|
}
|