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:
@@ -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);
|
||||
});
|
||||
+16
-1
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user