diff --git a/.env.example b/.env.example
index 96864b1..88851b5 100644
--- a/.env.example
+++ b/.env.example
@@ -13,8 +13,10 @@ JWT_REFRESH_TTL=30d
DELIVERY_MAX_DEVICES=2
# ---- Platform operator ----
-# Comma-separated emails with cross-tenant access to /platform. Create these
-# accounts with `npm run seed`; public signup refuses these addresses.
+# Comma-separated emails with cross-tenant access to /platform. The account
+# must also carry the platformAdmin flag, set only by `npm run seed` or
+# `npm run migrate:identity`. Removing an email here revokes access.
+# Public signup refuses these addresses.
PLATFORM_ADMIN_EMAILS=
# ---- Invite links ----
diff --git a/docs/PILOT_READINESS.md b/docs/PILOT_READINESS.md
index 66320d0..0807a4a 100644
--- a/docs/PILOT_READINESS.md
+++ b/docs/PILOT_READINESS.md
@@ -131,9 +131,33 @@ prices read-only.
mismatch.
- The Products page always shows default prices.
+## People in more than one business
+
+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 looks the email up in every business. If the password matches one
+ account, it signs straight in; if it matches several, the login page asks
+ which business. Someone with a wrong password learns nothing about which
+ businesses an email belongs to, and a wrong guess never resets the
+ failed-login count of another business's account.
+- Signup and staff creation only check for duplicates inside the business,
+ so they no longer reveal whether an email or phone is registered
+ elsewhere.
+- Platform-operator access now needs the `platformAdmin` flag on the
+ account as well as the email in `PLATFORM_ADMIN_EMAILS`. Without the
+ flag, any owner could create a staff account with the operator's email
+ in their own business. The flag is only set by `npm run seed` and
+ `npm run migrate:identity`, never through the app.
+
+**Deploying this change:** run `npm run migrate:identity` straight after
+the deploy. It flags the existing operator accounts and swaps the old
+platform-wide unique indexes for per-business ones. Until it runs,
+operators cannot open the Platform console and a second business cannot
+reuse an email. It is safe to re-run.
+
## Still open
-- **Global email and phone uniqueness.** One person cannot work for two
- businesses, and signup reveals whether an address is already registered.
- **Isolation by convention.** Tenant filters are still written per route.
The tests cover the sales, stock, transfer and access paths, not every
route.
diff --git a/package.json b/package.json
index 717b4a0..4716ed0 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,8 @@
"migrate:multitenant": "tsx scripts/migrate-multitenant.ts",
"test": "vitest run",
"typecheck": "tsc --noEmit",
- "backfill:stock": "tsx scripts/backfill-stock-from-purchases.ts"
+ "backfill:stock": "tsx scripts/backfill-stock-from-purchases.ts",
+ "migrate:identity": "tsx scripts/migrate-per-business-identity.ts"
},
"dependencies": {
"axios": "^1.7.7",
diff --git a/scripts/migrate-per-business-identity.ts b/scripts/migrate-per-business-identity.ts
new file mode 100644
index 0000000..d3ecff7
--- /dev/null
+++ b/scripts/migrate-per-business-identity.ts
@@ -0,0 +1,49 @@
+import dotenv from "dotenv";
+import path from "path";
+dotenv.config({ path: path.resolve(__dirname, "../.env.local") });
+
+import mongoose from "mongoose";
+import { connectDb } from "../src/lib/server/db";
+import { User } from "../src/lib/server/models/User";
+import { platformAdminEmails } from "../src/lib/server/platform";
+
+/**
+ * One-off, safe to re-run. Moves user identity from platform-wide to
+ * per-business uniqueness:
+ *
+ * 1. Marks today's platform operators. Until now an operator was anyone
+ * whose email was in PLATFORM_ADMIN_EMAILS; emails were unique, so
+ * that was one account each. From now on emails are only unique per
+ * business, so access also needs the platformAdmin flag on the account.
+ * Run this BEFORE dropping the old index, while each listed email
+ * still matches exactly one account.
+ * 2. Syncs the User indexes: drops the old unique `email_1` and
+ * `phoneNumber_1`, creates unique (businessId, email) and
+ * (businessId, phoneNumber), and a plain email lookup index.
+ *
+ * npm run migrate:identity
+ */
+async function main() {
+ await connectDb();
+
+ for (const email of platformAdminEmails()) {
+ const accounts = await User.find({ email }).select("_id").lean();
+ if (accounts.length === 1) {
+ await User.updateOne({ _id: accounts[0]._id }, { $set: { platformAdmin: true } });
+ console.log(`Platform operator: ${email}`);
+ } else if (accounts.length === 0) {
+ console.warn(`No account for ${email} - create it with npm run seed.`);
+ } else {
+ console.warn(`${email} has ${accounts.length} accounts - set platformAdmin on the right one by hand.`);
+ }
+ }
+
+ const dropped = await User.syncIndexes();
+ console.log(`User indexes synced. Dropped: ${dropped.length ? dropped.join(", ") : "none"}.`);
+ await mongoose.disconnect();
+}
+
+main().catch((err) => {
+ console.error("migration failed:", err.message);
+ process.exit(1);
+});
diff --git a/scripts/seed.ts b/scripts/seed.ts
index 38b0a7b..3b677c2 100644
--- a/scripts/seed.ts
+++ b/scripts/seed.ts
@@ -13,6 +13,7 @@ import { connectDb } from "../src/lib/server/db";
import { User } from "../src/lib/server/models/User";
import { Product } from "../src/lib/server/models/Product";
import { Business } from "../src/lib/server/models/Business";
+import { isPlatformAdminEmail } from "../src/lib/server/platform";
/**
* Run once against a fresh database: `npm run seed`.
@@ -39,10 +40,23 @@ async function seed() {
}
let business = await Business.findOne({ name: demoBusinessName });
- const existing = await User.findOne({ email });
+ // Emails are unique per business, so look in the seeded business (or, for
+ // a legacy single-tenant database, for its one super admin).
+ const existing = business
+ ? await User.findOne({ email, businessId: business._id })
+ : await User.findOne({ email, role: "SUPER_ADMIN" });
+ // The seed is the only way (besides the identity migration) to make an
+ // operator: the flag is honoured only while the email is also listed in
+ // PLATFORM_ADMIN_EMAILS.
+ const platformAdmin = isPlatformAdminEmail(email);
if (existing) {
console.log(`Super admin (${email}) already exists, skipping`);
+ if (platformAdmin && !existing.platformAdmin) {
+ existing.platformAdmin = true;
+ await existing.save();
+ console.log(`Marked ${email} as a platform operator`);
+ }
if (!business) {
// Backfill a business for a legacy single-tenant admin if needed.
business = await Business.create({ name: demoBusinessName, ownerId: existing._id });
@@ -61,6 +75,7 @@ async function seed() {
phoneNumber: "254700000000",
email,
passwordHash,
+ platformAdmin,
businessId: new mongoose.Types.ObjectId(), // temporary, overwritten below
});
business = await Business.create({ name: demoBusinessName, ownerId: owner._id });
diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts
index bc57232..c2cf544 100644
--- a/src/app/api/auth/login/route.ts
+++ b/src/app/api/auth/login/route.ts
@@ -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 = { 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());
diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts
index ff178c1..da965d7 100644
--- a/src/app/api/auth/me/route.ts
+++ b/src/app/api/auth/me/route.ts
@@ -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,
diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts
index ea4d4fd..db150a1 100644
--- a/src/app/api/auth/signup/route.ts
+++ b/src/app/api/auth/signup/route.ts
@@ -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);
diff --git a/src/app/api/branches/route.ts b/src/app/api/branches/route.ts
index 344d2b9..770b2b7 100644
--- a/src/app/api/branches/route.ts
+++ b/src/app/api/branches/route.ts
@@ -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 });
}
diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts
index 1a849b3..3513a6b 100644
--- a/src/app/api/users/route.ts
+++ b/src/app/api/users/route.ts
@@ -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;
}
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx
index 056cc3e..2fc66c2 100644
--- a/src/app/login/page.tsx
+++ b/src/app/login/page.tsx
@@ -12,6 +12,18 @@ interface ActiveDevice {
lastUsedAt: string;
}
+interface BusinessChoice {
+ businessId: string;
+ businessName: string;
+ role: string;
+}
+
+const ROLE_LABELS: Record = {
+ 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(null);
const [deviceConflict, setDeviceConflict] = useState(null);
+ // Set when this email and password open accounts in more than one business.
+ const [choices, setChoices] = useState(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.