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>
This commit is contained in:
2026-10-12 11:31:45 +03:00
parent 1fe2421cc4
commit 9e3e59a804
14 changed files with 407 additions and 52 deletions
+18 -2
View File
@@ -10,8 +10,16 @@ export interface IUser extends Document {
name: string;
role: UserRole;
branchId: Types.ObjectId | null; // null only valid for SUPER_ADMIN (business owner)
// Email and phone are unique WITHIN a business, not across the platform,
// so one person can work for two businesses (each account has its own
// password and role). Login asks which business when the same email and
// password match accounts in more than one.
phoneNumber: string; // E.164-ish normalized Kenyan format — kept for contact/M-Pesa reference, not used for login
email: string; // login identifier for every role, via the single /login page
// Platform operator (cross-tenant console). Only set by `npm run seed` or
// the identity migration, never through the app, and only honoured while
// the email is also listed in PLATFORM_ADMIN_EMAILS.
platformAdmin: boolean;
passwordHash: string;
isActive: boolean;
failedLoginAttempts: number;
@@ -33,8 +41,9 @@ const userSchema = new Schema<IUser>(
name: { type: String, required: true, trim: true },
role: { type: String, enum: USER_ROLES, required: true, default: "DELIVERY" },
branchId: { type: Schema.Types.ObjectId, ref: "Branch", default: null },
phoneNumber: { type: String, required: true, unique: true, index: true },
email: { type: String, required: true, unique: true, lowercase: true, trim: true },
phoneNumber: { type: String, required: true },
email: { type: String, required: true, lowercase: true, trim: true },
platformAdmin: { type: Boolean, default: false },
passwordHash: { type: String, required: true, select: false },
isActive: { type: Boolean, default: true },
failedLoginAttempts: { type: Number, default: 0 },
@@ -45,6 +54,13 @@ const userSchema = new Schema<IUser>(
{ timestamps: true }
);
userSchema.index({ businessId: 1, email: 1 }, { unique: true });
userSchema.index({ businessId: 1, phoneNumber: 1 }, { unique: true });
// Login looks an email up across businesses. Named so it never clashes with
// the old platform-wide unique `email_1` index, which the identity
// migration (npm run migrate:identity) drops.
userSchema.index({ email: 1 }, { name: "email_lookup" });
// Enforce role/branch invariants at the model layer — belt-and-braces
// alongside API-level validation. Every role authenticates the same way
// now (email + password on the single /login page), so there is no
+14 -5
View File
@@ -26,21 +26,30 @@ import type { NextRequest } from "next/server";
import { getAuth } from "@/lib/server/auth";
import { User } from "@/lib/server/models/User";
/**
* Whether this account is a platform operator. Two conditions, because
* emails are only unique within a business: any owner can create a staff
* account with any email in their own business, so an email match alone
* must never grant cross-tenant access. The account must also carry the
* platformAdmin flag, which the app never sets (seed/migration only).
* Removing an email from PLATFORM_ADMIN_EMAILS revokes access at once.
*/
export function isPlatformAdmin(user: { email?: string | null; platformAdmin?: boolean | null }): boolean {
return user.platformAdmin === true && isPlatformAdminEmail(user.email);
}
/**
* Resolve and authorize the current request as a platform operator.
* Returns the authenticated User's id/email on success, or null if the
* caller is not a platform admin. The DB must already be connected.
*
* The JWT has no email, so we re-read the User and match its email
* against PLATFORM_ADMIN_EMAILS.
*/
export async function requirePlatformAdmin(
req: NextRequest
): Promise<{ userId: string; email: string } | null> {
const auth = getAuth(req);
if (!auth) return null;
const user = await User.findById(auth.sub).select("email isActive").lean();
const user = await User.findById(auth.sub).select("email isActive platformAdmin").lean();
if (!user || !user.isActive) return null;
if (!isPlatformAdminEmail(user.email)) return null;
if (!isPlatformAdmin(user)) return null;
return { userId: auth.sub, email: user.email };
}