9e3e59a804
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>
122 lines
4.5 KiB
TypeScript
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;
|
|
}
|
|
} |