Files
ERP/src/app/api/users/route.ts
T
denis_ireri 9e3e59a804 Make email and phone unique per business, not platform-wide
One person could not work for two businesses, and signup and staff
creation told anyone whether an email or phone was registered anywhere
on the platform.

- User email and phone are now unique per business: indexes on
  (businessId, email) and (businessId, phoneNumber). Duplicate checks in
  signup and branch-manager creation are scoped to the business, so they
  no longer reveal other businesses' users.
- Login finds the email in every business and checks the password
  against each account. One match signs straight in; several return 300
  with the businesses to choose from, and the login page shows a picker.
  A wrong password returns the same 401 as before and reveals nothing;
  failures count against each candidate account, and a correct password
  only resets the accounts it opens.
- Platform-operator access now also needs a platformAdmin flag on the
  account. With per-business emails, an email match alone would have let
  any owner create a staff account with the operator's email and open the
  cross-tenant console. The flag is only set by the seed and the
  migration; removing the email from PLATFORM_ADMIN_EMAILS still revokes.
- npm run migrate:identity flags today's operators (while each listed
  email still matches one account) and syncs the User indexes, dropping
  the old unique email_1 and phoneNumber_1. Checked against a database in
  the old shape; safe to re-run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:31:45 +03:00

122 lines
4.5 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { User } from "@/lib/server/models/User";
import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
import { objectId } from "@/lib/server/validation";
import { normalizeKenyanPhone } from "@/lib/server/phone";
import { userLimitError, loadTier } from "@/lib/server/subscription";
import { issueInvite, unusablePasswordHash } from "@/lib/server/invites";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
await connectDb();
const gate = authorize(req, MANAGERS);
if (!gate.ok) return gate.response;
const auth = gate.auth;
const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId"));
if (!scope.ok) return scope.response;
const filter: Record<string, unknown> = { businessId: auth.businessId };
if (scope.branchId) filter.branchId = scope.branchId;
const users = await User.find(filter).select("-passwordHash -inviteTokenHash").sort({ name: 1 }).lean();
// invitePending: the person has not set their password yet.
return NextResponse.json({
users: users.map((u) => ({ ...u, invitePending: !!u.inviteExpiresAt })),
});
}
const createSchema = z.object({
name: z.string().trim().min(1).max(120),
role: z.enum(["DELIVERY", "BRANCH_MANAGER", "SUPER_ADMIN"]),
branchId: objectId.optional(),
phoneNumber: z.string().min(9).max(15),
email: z.string().email(),
// Omit the password to invite instead: the response carries a one-time
// link the person uses to set their own password.
password: z.string().min(8).optional(),
});
export async function POST(req: NextRequest) {
await connectDb();
const gate = authorize(req, MANAGERS);
if (!gate.ok) return gate.response;
const auth = gate.auth;
const json = await req.json().catch(() => null);
const parsed = createSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const data = parsed.data;
if (auth.role === "BRANCH_MANAGER") {
if (data.role !== "DELIVERY") {
return NextResponse.json({ error: "Branch managers may only create delivery staff accounts" }, { status: 403 });
}
if (data.branchId && data.branchId !== auth.branchId) {
return NextResponse.json({ error: "Branch managers may only add staff to their own branch" }, { status: 403 });
}
}
const branchId = data.role === "SUPER_ADMIN" ? null : data.branchId ?? auth.branchId ?? undefined;
if (data.role !== "SUPER_ADMIN" && !branchId) {
return NextResponse.json({ error: "branchId is required for this role" }, { status: 400 });
}
if (branchId) {
const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId, ...ACTIVE_BRANCH }).lean();
if (!branch) {
return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
}
}
let phone: string;
try {
phone = normalizeKenyanPhone(data.phoneNumber);
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 400 });
}
// Enforce the plan's user cap (counts owner + managers + staff).
const tier = await loadTier(auth.businessId);
const userErr = await userLimitError(auth.businessId, tier);
if (userErr) return NextResponse.json({ error: userErr }, { status: 403 });
const passwordHash = data.password ? await bcrypt.hash(data.password, 12) : await unusablePasswordHash();
try {
const user = new User({
businessId: auth.businessId,
name: data.name,
role: data.role,
branchId: branchId ?? null,
phoneNumber: phone,
email: data.email.toLowerCase(),
passwordHash,
});
const inviteUrl = data.password ? null : issueInvite(user, req);
await user.save();
if (data.role === "BRANCH_MANAGER" && branchId) {
await Branch.findOneAndUpdate(
{ _id: branchId, businessId: auth.businessId },
{ managerId: user._id }
);
}
const obj: any = user.toObject();
delete obj.passwordHash;
delete obj.inviteTokenHash;
return NextResponse.json({ user: obj, inviteUrl }, { status: 201 });
} catch (err: any) {
if (err?.code === 11000) {
return NextResponse.json({ error: "Someone in your business already uses this phone number or email" }, { status: 409 });
}
throw err;
}
}