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:
@@ -7,7 +7,8 @@ import { Branch } from "@/lib/server/models/Branch";
|
||||
import { Business } from "@/lib/server/models/Business";
|
||||
import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt";
|
||||
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
|
||||
import { isPlatformAdminEmail } from "@/lib/server/platform";
|
||||
import { isPlatformAdmin } from "@/lib/server/platform";
|
||||
import { objectId } from "@/lib/server/validation";
|
||||
import { getEnv } from "@/lib/server/env";
|
||||
import { tenantAccessError } from "@/lib/server/subscription";
|
||||
|
||||
@@ -21,6 +22,9 @@ const loginSchema = z.object({
|
||||
password: z.string().min(1),
|
||||
deviceId: z.string().uuid(),
|
||||
deviceLabel: z.string().max(120).optional(),
|
||||
// Which business to sign in to, when the same email and password match
|
||||
// accounts in more than one (the first attempt returns the choices).
|
||||
businessId: objectId.optional(),
|
||||
});
|
||||
|
||||
async function enforceDeviceBinding(userId: string, deviceId: string) {
|
||||
@@ -44,32 +48,70 @@ export async function POST(req: NextRequest) {
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||
}
|
||||
const { email, password, deviceId, deviceLabel } = parsed.data;
|
||||
const { email, password, deviceId, deviceLabel, businessId } = parsed.data;
|
||||
|
||||
const user = await User.findOne({ email: email.toLowerCase() }).select("+passwordHash");
|
||||
if (!user || !user.isActive) {
|
||||
// An email is unique within a business, so it can belong to accounts in
|
||||
// several businesses, each with its own password.
|
||||
const filter: Record<string, unknown> = { email: email.toLowerCase(), isActive: true };
|
||||
if (businessId) filter.businessId = businessId;
|
||||
const candidates = await User.find(filter).select("+passwordHash");
|
||||
if (candidates.length === 0) {
|
||||
return NextResponse.json({ error: "Incorrect email or password" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
|
||||
const minutesLeft = Math.ceil((user.lockedUntil.getTime() - Date.now()) / 60000);
|
||||
const now = Date.now();
|
||||
const unlocked = candidates.filter((u) => !u.lockedUntil || u.lockedUntil.getTime() <= now);
|
||||
if (unlocked.length === 0) {
|
||||
const soonest = Math.min(...candidates.map((u) => u.lockedUntil!.getTime()));
|
||||
const minutesLeft = Math.ceil((soonest - now) / 60000);
|
||||
return NextResponse.json({ error: `Account locked. Try again in ${minutesLeft} minute(s).` }, { status: 423 });
|
||||
}
|
||||
|
||||
const validPassword = await user.comparePassword(password);
|
||||
if (!validPassword) {
|
||||
user.failedLoginAttempts += 1;
|
||||
if (user.failedLoginAttempts >= MAX_FAILED_ATTEMPTS) {
|
||||
user.lockedUntil = new Date(Date.now() + LOCKOUT_MS);
|
||||
user.failedLoginAttempts = 0;
|
||||
const matched = [];
|
||||
for (const u of unlocked) {
|
||||
if (await u.comparePassword(password)) matched.push(u);
|
||||
}
|
||||
|
||||
if (matched.length === 0) {
|
||||
// Count the failure against every account this email could have meant.
|
||||
for (const u of unlocked) {
|
||||
u.failedLoginAttempts += 1;
|
||||
if (u.failedLoginAttempts >= MAX_FAILED_ATTEMPTS) {
|
||||
u.lockedUntil = new Date(now + LOCKOUT_MS);
|
||||
u.failedLoginAttempts = 0;
|
||||
}
|
||||
await u.save();
|
||||
}
|
||||
await user.save();
|
||||
return NextResponse.json({ error: "Incorrect email or password" }, { status: 401 });
|
||||
}
|
||||
|
||||
user.failedLoginAttempts = 0;
|
||||
user.lockedUntil = null;
|
||||
await user.save();
|
||||
// A correct password unlocks only the accounts it belongs to; a person
|
||||
// with a different password in another business is not affected.
|
||||
for (const u of matched) {
|
||||
u.failedLoginAttempts = 0;
|
||||
u.lockedUntil = null;
|
||||
await u.save();
|
||||
}
|
||||
|
||||
if (matched.length > 1) {
|
||||
// Only someone who knows the password learns which businesses it opens.
|
||||
const businesses = await Business.find({ _id: { $in: matched.map((u) => u.businessId) } })
|
||||
.select("name")
|
||||
.lean();
|
||||
const nameById = new Map(businesses.map((b) => [b._id.toString(), b.name]));
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "This email signs in to more than one business. Choose one.",
|
||||
chooseBusiness: matched.map((u) => ({
|
||||
businessId: u.businessId.toString(),
|
||||
businessName: nameById.get(u.businessId.toString()) ?? "Business",
|
||||
role: u.role,
|
||||
})),
|
||||
},
|
||||
{ status: 300 }
|
||||
);
|
||||
}
|
||||
const user = matched[0];
|
||||
|
||||
const tenantError = await tenantAccessError(user.businessId);
|
||||
if (tenantError) {
|
||||
@@ -123,7 +165,7 @@ export async function POST(req: NextRequest) {
|
||||
email: user.email,
|
||||
},
|
||||
redirectTo: user.role === "DELIVERY" ? "/terminal" : "/admin/dashboard",
|
||||
isPlatformAdmin: isPlatformAdminEmail(user.email),
|
||||
isPlatformAdmin: isPlatformAdmin(user),
|
||||
});
|
||||
|
||||
res.cookies.set(ACCESS_COOKIE, accessToken, accessCookieOptions());
|
||||
|
||||
@@ -4,7 +4,7 @@ import { User } from "@/lib/server/models/User";
|
||||
import { Branch } from "@/lib/server/models/Branch";
|
||||
import { Business } from "@/lib/server/models/Business";
|
||||
import { getAuth } from "@/lib/server/auth";
|
||||
import { isPlatformAdminEmail } from "@/lib/server/platform";
|
||||
import { isPlatformAdmin } from "@/lib/server/platform";
|
||||
import { isMpesaEnabled } from "@/lib/server/env";
|
||||
import { TIERS, effectiveMaxBranches, type TierId, type SubscriptionStatus } from "@/lib/tiers";
|
||||
|
||||
@@ -38,7 +38,7 @@ export async function GET(req: NextRequest) {
|
||||
branchName: branch?.branchName ?? null,
|
||||
phoneNumber: user.phoneNumber,
|
||||
email: user.email,
|
||||
isPlatformAdmin: isPlatformAdminEmail(user.email),
|
||||
isPlatformAdmin: isPlatformAdmin(user),
|
||||
},
|
||||
subscription: {
|
||||
tier,
|
||||
|
||||
@@ -53,22 +53,16 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const lowerEmail = email.toLowerCase();
|
||||
|
||||
// Platform-operator rights are granted by email (PLATFORM_ADMIN_EMAILS)
|
||||
// and signup does not verify email ownership, so an operator address
|
||||
// must never be claimable through public signup. Operator accounts are
|
||||
// created with `npm run seed` instead.
|
||||
// Operator addresses are reserved, so nobody can open a business that
|
||||
// looks like the operator's. (Access itself also needs the platformAdmin
|
||||
// flag, which signup never sets.)
|
||||
if (isPlatformAdminEmail(lowerEmail)) {
|
||||
return NextResponse.json({ error: "This email cannot be used to sign up." }, { status: 403 });
|
||||
}
|
||||
|
||||
const emailTaken = await User.findOne({ email: lowerEmail }).lean();
|
||||
if (emailTaken) {
|
||||
return NextResponse.json({ error: "An account with this email already exists." }, { status: 409 });
|
||||
}
|
||||
const phoneTaken = await User.findOne({ phoneNumber: normalizedPhone }).lean();
|
||||
if (phoneTaken) {
|
||||
return NextResponse.json({ error: "An account with this phone number already exists." }, { status: 409 });
|
||||
}
|
||||
// No platform-wide duplicate check: a new business has no users yet, and
|
||||
// the same person may already work for another business. (Checking would
|
||||
// also tell anyone whether an email or phone is registered elsewhere.)
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
|
||||
|
||||
@@ -71,14 +71,15 @@ export async function POST(req: NextRequest) {
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: (e as Error).message }, { status: 400 });
|
||||
}
|
||||
// Unique within this business only (see the User model).
|
||||
const lowerEmail = manager.email.toLowerCase();
|
||||
const emailTaken = await User.findOne({ email: lowerEmail }).lean();
|
||||
const emailTaken = await User.exists({ businessId, email: lowerEmail });
|
||||
if (emailTaken) {
|
||||
return NextResponse.json({ error: "An account with this email already exists." }, { status: 409 });
|
||||
return NextResponse.json({ error: "Someone in your business already uses this email." }, { status: 409 });
|
||||
}
|
||||
const phoneTaken = await User.findOne({ phoneNumber: managerPhone }).lean();
|
||||
const phoneTaken = await User.exists({ businessId, phoneNumber: managerPhone });
|
||||
if (phoneTaken) {
|
||||
return NextResponse.json({ error: "An account with this phone number already exists." }, { status: 409 });
|
||||
return NextResponse.json({ error: "Someone in your business already uses this phone number." }, { status: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +108,7 @@ export async function POST(req: NextRequest) {
|
||||
} catch (err: any) {
|
||||
await Branch.deleteOne({ _id: branch._id }).catch(() => {});
|
||||
if (err?.code === 11000) {
|
||||
return NextResponse.json({ error: "A user with this phone number or email already exists." }, { status: 409 });
|
||||
return NextResponse.json({ error: "Someone in your business already uses this phone number or email." }, { status: 409 });
|
||||
}
|
||||
return NextResponse.json({ error: "Could not create the branch manager. Please try again." }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ user: obj, inviteUrl }, { status: 201 });
|
||||
} catch (err: any) {
|
||||
if (err?.code === 11000) {
|
||||
return NextResponse.json({ error: "A user with this phone number or email already exists" }, { status: 409 });
|
||||
return NextResponse.json({ error: "Someone in your business already uses this phone number or email" }, { status: 409 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
+52
-2
@@ -12,6 +12,18 @@ interface ActiveDevice {
|
||||
lastUsedAt: string;
|
||||
}
|
||||
|
||||
interface BusinessChoice {
|
||||
businessId: string;
|
||||
businessName: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
SUPER_ADMIN: "Owner",
|
||||
BRANCH_MANAGER: "Branch manager",
|
||||
DELIVERY: "Cashier",
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -21,6 +33,8 @@ export default function LoginPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deviceConflict, setDeviceConflict] = useState<ActiveDevice[] | null>(null);
|
||||
// Set when this email and password open accounts in more than one business.
|
||||
const [choices, setChoices] = useState<BusinessChoice[] | null>(null);
|
||||
const [invited, setInvited] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -29,6 +43,10 @@ export default function LoginPage() {
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
await signIn();
|
||||
}
|
||||
|
||||
async function signIn(businessId?: string) {
|
||||
setError(null);
|
||||
setDeviceConflict(null);
|
||||
setLoading(true);
|
||||
@@ -41,11 +59,15 @@ export default function LoginPage() {
|
||||
password,
|
||||
deviceId: getDeviceId(),
|
||||
deviceLabel: getDeviceLabel(),
|
||||
businessId,
|
||||
}),
|
||||
});
|
||||
router.replace(result.redirectTo);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
if (err instanceof ApiError && err.status === 300) {
|
||||
setChoices((err.body?.chooseBusiness as BusinessChoice[]) ?? []);
|
||||
} else if (err instanceof ApiError && err.status === 409) {
|
||||
setDeviceConflict((err.body?.activeDevices as ActiveDevice[]) ?? []);
|
||||
setError(err.message);
|
||||
} else if (err instanceof ApiError) {
|
||||
@@ -75,7 +97,35 @@ export default function LoginPage() {
|
||||
Run every branch with confidence — sales, stock, M-Pesa and cash.
|
||||
</p>
|
||||
|
||||
{deviceConflict ? (
|
||||
{choices && !deviceConflict ? (
|
||||
<div className="mt-8">
|
||||
<p className="text-sm font-medium text-depth-800">Which business do you want to sign in to?</p>
|
||||
{error && choices.length === 0 && <p className="mt-2 text-sm text-alert-600">{error}</p>}
|
||||
<ul className="mt-3 space-y-2">
|
||||
{choices.map((c) => (
|
||||
<li key={c.businessId}>
|
||||
<button
|
||||
onClick={() => signIn(c.businessId)}
|
||||
disabled={loading}
|
||||
className="tap-target flex w-full items-center justify-between rounded-xl2 border border-depth-200 bg-white px-4 text-left transition hover:border-flow-500 disabled:opacity-60"
|
||||
>
|
||||
<span className="font-medium text-depth-900">{c.businessName}</span>
|
||||
<span className="text-xs text-depth-500">{ROLE_LABELS[c.role] ?? c.role}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
onClick={() => {
|
||||
setChoices(null);
|
||||
setError(null);
|
||||
}}
|
||||
className="mt-4 text-sm text-depth-500 underline underline-offset-2"
|
||||
>
|
||||
Use a different email
|
||||
</button>
|
||||
</div>
|
||||
) : deviceConflict ? (
|
||||
<div className="mt-8 rounded-xl2 border border-alert-500/30 bg-alert-500/10 p-4">
|
||||
<p className="text-sm font-medium text-alert-600">{error}</p>
|
||||
<ul className="mt-3 space-y-2">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user