Merge remote-tracking branch 'origin/fix/per-business-identity' into fix/enforced-tenant-isolation

This commit is contained in:
2026-10-12 11:40:38 +03:00
14 changed files with 407 additions and 52 deletions
+4 -2
View File
@@ -13,8 +13,10 @@ JWT_REFRESH_TTL=30d
DELIVERY_MAX_DEVICES=2 DELIVERY_MAX_DEVICES=2
# ---- Platform operator ---- # ---- Platform operator ----
# Comma-separated emails with cross-tenant access to /platform. Create these # Comma-separated emails with cross-tenant access to /platform. The account
# accounts with `npm run seed`; public signup refuses these addresses. # 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= PLATFORM_ADMIN_EMAILS=
# ---- Invite links ---- # ---- Invite links ----
+26 -2
View File
@@ -131,9 +131,33 @@ prices read-only.
mismatch. mismatch.
- The Products page always shows default prices. - 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 ## 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. - **Isolation by convention.** Tenant filters are still written per route.
The tests cover the sales, stock, transfer and access paths, not every The tests cover the sales, stock, transfer and access paths, not every
route. route.
+2 -1
View File
@@ -11,7 +11,8 @@
"migrate:multitenant": "tsx scripts/migrate-multitenant.ts", "migrate:multitenant": "tsx scripts/migrate-multitenant.ts",
"test": "vitest run", "test": "vitest run",
"typecheck": "tsc --noEmit", "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": { "dependencies": {
"axios": "^1.7.7", "axios": "^1.7.7",
+49
View File
@@ -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
View File
@@ -13,6 +13,7 @@ import { connectDb } from "../src/lib/server/db";
import { User } from "../src/lib/server/models/User"; import { User } from "../src/lib/server/models/User";
import { Product } from "../src/lib/server/models/Product"; import { Product } from "../src/lib/server/models/Product";
import { Business } from "../src/lib/server/models/Business"; import { Business } from "../src/lib/server/models/Business";
import { isPlatformAdminEmail } from "../src/lib/server/platform";
/** /**
* Run once against a fresh database: `npm run seed`. * Run once against a fresh database: `npm run seed`.
@@ -39,10 +40,23 @@ async function seed() {
} }
let business = await Business.findOne({ name: demoBusinessName }); 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) { if (existing) {
console.log(`Super admin (${email}) already exists, skipping`); 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) { if (!business) {
// Backfill a business for a legacy single-tenant admin if needed. // Backfill a business for a legacy single-tenant admin if needed.
business = await Business.create({ name: demoBusinessName, ownerId: existing._id }); business = await Business.create({ name: demoBusinessName, ownerId: existing._id });
@@ -61,6 +75,7 @@ async function seed() {
phoneNumber: "254700000000", phoneNumber: "254700000000",
email, email,
passwordHash, passwordHash,
platformAdmin,
businessId: new mongoose.Types.ObjectId(), // temporary, overwritten below businessId: new mongoose.Types.ObjectId(), // temporary, overwritten below
}); });
business = await Business.create({ name: demoBusinessName, ownerId: owner._id }); business = await Business.create({ name: demoBusinessName, ownerId: owner._id });
+59 -17
View File
@@ -7,7 +7,8 @@ import { Branch } from "@/lib/server/models/Branch";
import { Business } from "@/lib/server/models/Business"; import { Business } from "@/lib/server/models/Business";
import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt"; import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt";
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth"; 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 { getEnv } from "@/lib/server/env";
import { tenantAccessError } from "@/lib/server/subscription"; import { tenantAccessError } from "@/lib/server/subscription";
@@ -21,6 +22,9 @@ const loginSchema = z.object({
password: z.string().min(1), password: z.string().min(1),
deviceId: z.string().uuid(), deviceId: z.string().uuid(),
deviceLabel: z.string().max(120).optional(), 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) { async function enforceDeviceBinding(userId: string, deviceId: string) {
@@ -44,32 +48,70 @@ export async function POST(req: NextRequest) {
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 }); 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"); // An email is unique within a business, so it can belong to accounts in
if (!user || !user.isActive) { // 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 }); return NextResponse.json({ error: "Incorrect email or password" }, { status: 401 });
} }
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) { const now = Date.now();
const minutesLeft = Math.ceil((user.lockedUntil.getTime() - Date.now()) / 60000); 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 }); return NextResponse.json({ error: `Account locked. Try again in ${minutesLeft} minute(s).` }, { status: 423 });
} }
const validPassword = await user.comparePassword(password); const matched = [];
if (!validPassword) { for (const u of unlocked) {
user.failedLoginAttempts += 1; if (await u.comparePassword(password)) matched.push(u);
if (user.failedLoginAttempts >= MAX_FAILED_ATTEMPTS) { }
user.lockedUntil = new Date(Date.now() + LOCKOUT_MS);
user.failedLoginAttempts = 0; 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 }); return NextResponse.json({ error: "Incorrect email or password" }, { status: 401 });
} }
user.failedLoginAttempts = 0; // A correct password unlocks only the accounts it belongs to; a person
user.lockedUntil = null; // with a different password in another business is not affected.
await user.save(); 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); const tenantError = await tenantAccessError(user.businessId);
if (tenantError) { if (tenantError) {
@@ -123,7 +165,7 @@ export async function POST(req: NextRequest) {
email: user.email, email: user.email,
}, },
redirectTo: user.role === "DELIVERY" ? "/terminal" : "/admin/dashboard", redirectTo: user.role === "DELIVERY" ? "/terminal" : "/admin/dashboard",
isPlatformAdmin: isPlatformAdminEmail(user.email), isPlatformAdmin: isPlatformAdmin(user),
}); });
res.cookies.set(ACCESS_COOKIE, accessToken, accessCookieOptions()); res.cookies.set(ACCESS_COOKIE, accessToken, accessCookieOptions());
+2 -2
View File
@@ -4,7 +4,7 @@ import { User } from "@/lib/server/models/User";
import { Branch } from "@/lib/server/models/Branch"; import { Branch } from "@/lib/server/models/Branch";
import { Business } from "@/lib/server/models/Business"; import { Business } from "@/lib/server/models/Business";
import { getAuth } from "@/lib/server/auth"; 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 { isMpesaEnabled } from "@/lib/server/env";
import { TIERS, effectiveMaxBranches, type TierId, type SubscriptionStatus } from "@/lib/tiers"; import { TIERS, effectiveMaxBranches, type TierId, type SubscriptionStatus } from "@/lib/tiers";
@@ -38,7 +38,7 @@ export async function GET(req: NextRequest) {
branchName: branch?.branchName ?? null, branchName: branch?.branchName ?? null,
phoneNumber: user.phoneNumber, phoneNumber: user.phoneNumber,
email: user.email, email: user.email,
isPlatformAdmin: isPlatformAdminEmail(user.email), isPlatformAdmin: isPlatformAdmin(user),
}, },
subscription: { subscription: {
tier, tier,
+6 -12
View File
@@ -53,22 +53,16 @@ export async function POST(req: NextRequest) {
const lowerEmail = email.toLowerCase(); const lowerEmail = email.toLowerCase();
// Platform-operator rights are granted by email (PLATFORM_ADMIN_EMAILS) // Operator addresses are reserved, so nobody can open a business that
// and signup does not verify email ownership, so an operator address // looks like the operator's. (Access itself also needs the platformAdmin
// must never be claimable through public signup. Operator accounts are // flag, which signup never sets.)
// created with `npm run seed` instead.
if (isPlatformAdminEmail(lowerEmail)) { if (isPlatformAdminEmail(lowerEmail)) {
return NextResponse.json({ error: "This email cannot be used to sign up." }, { status: 403 }); return NextResponse.json({ error: "This email cannot be used to sign up." }, { status: 403 });
} }
const emailTaken = await User.findOne({ email: lowerEmail }).lean(); // No platform-wide duplicate check: a new business has no users yet, and
if (emailTaken) { // the same person may already work for another business. (Checking would
return NextResponse.json({ error: "An account with this email already exists." }, { status: 409 }); // also tell anyone whether an email or phone is registered elsewhere.)
}
const phoneTaken = await User.findOne({ phoneNumber: normalizedPhone }).lean();
if (phoneTaken) {
return NextResponse.json({ error: "An account with this phone number already exists." }, { status: 409 });
}
const passwordHash = await bcrypt.hash(password, 12); const passwordHash = await bcrypt.hash(password, 12);
+6 -5
View File
@@ -71,14 +71,15 @@ export async function POST(req: NextRequest) {
} catch (e) { } catch (e) {
return NextResponse.json({ error: (e as Error).message }, { status: 400 }); 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 lowerEmail = manager.email.toLowerCase();
const emailTaken = await User.findOne({ email: lowerEmail }).lean(); const emailTaken = await User.exists({ businessId, email: lowerEmail });
if (emailTaken) { 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) { 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) { } catch (err: any) {
await Branch.deleteOne({ _id: branch._id }).catch(() => {}); await Branch.deleteOne({ _id: branch._id }).catch(() => {});
if (err?.code === 11000) { 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 }); return NextResponse.json({ error: "Could not create the branch manager. Please try again." }, { status: 500 });
} }
+1 -1
View File
@@ -115,7 +115,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ user: obj, inviteUrl }, { status: 201 }); return NextResponse.json({ user: obj, inviteUrl }, { status: 201 });
} catch (err: any) { } catch (err: any) {
if (err?.code === 11000) { 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; throw err;
} }
+52 -2
View File
@@ -12,6 +12,18 @@ interface ActiveDevice {
lastUsedAt: string; 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() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
@@ -21,6 +33,8 @@ export default function LoginPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [deviceConflict, setDeviceConflict] = useState<ActiveDevice[] | 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); const [invited, setInvited] = useState(false);
useEffect(() => { useEffect(() => {
@@ -29,6 +43,10 @@ export default function LoginPage() {
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
await signIn();
}
async function signIn(businessId?: string) {
setError(null); setError(null);
setDeviceConflict(null); setDeviceConflict(null);
setLoading(true); setLoading(true);
@@ -41,11 +59,15 @@ export default function LoginPage() {
password, password,
deviceId: getDeviceId(), deviceId: getDeviceId(),
deviceLabel: getDeviceLabel(), deviceLabel: getDeviceLabel(),
businessId,
}), }),
}); });
router.replace(result.redirectTo); router.replace(result.redirectTo);
return;
} catch (err) { } 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[]) ?? []); setDeviceConflict((err.body?.activeDevices as ActiveDevice[]) ?? []);
setError(err.message); setError(err.message);
} else if (err instanceof ApiError) { } else if (err instanceof ApiError) {
@@ -75,7 +97,35 @@ export default function LoginPage() {
Run every branch with confidence — sales, stock, M-Pesa and cash. Run every branch with confidence — sales, stock, M-Pesa and cash.
</p> </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"> <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> <p className="text-sm font-medium text-alert-600">{error}</p>
<ul className="mt-3 space-y-2"> <ul className="mt-3 space-y-2">
+18 -2
View File
@@ -10,8 +10,16 @@ export interface IUser extends Document {
name: string; name: string;
role: UserRole; role: UserRole;
branchId: Types.ObjectId | null; // null only valid for SUPER_ADMIN (business owner) 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 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 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; passwordHash: string;
isActive: boolean; isActive: boolean;
failedLoginAttempts: number; failedLoginAttempts: number;
@@ -33,8 +41,9 @@ const userSchema = new Schema<IUser>(
name: { type: String, required: true, trim: true }, name: { type: String, required: true, trim: true },
role: { type: String, enum: USER_ROLES, required: true, default: "DELIVERY" }, role: { type: String, enum: USER_ROLES, required: true, default: "DELIVERY" },
branchId: { type: Schema.Types.ObjectId, ref: "Branch", default: null }, branchId: { type: Schema.Types.ObjectId, ref: "Branch", default: null },
phoneNumber: { type: String, required: true, unique: true, index: true }, phoneNumber: { type: String, required: true },
email: { type: String, required: true, unique: true, lowercase: true, trim: true }, email: { type: String, required: true, lowercase: true, trim: true },
platformAdmin: { type: Boolean, default: false },
passwordHash: { type: String, required: true, select: false }, passwordHash: { type: String, required: true, select: false },
isActive: { type: Boolean, default: true }, isActive: { type: Boolean, default: true },
failedLoginAttempts: { type: Number, default: 0 }, failedLoginAttempts: { type: Number, default: 0 },
@@ -45,6 +54,13 @@ const userSchema = new Schema<IUser>(
{ timestamps: true } { 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 // Enforce role/branch invariants at the model layer — belt-and-braces
// alongside API-level validation. Every role authenticates the same way // alongside API-level validation. Every role authenticates the same way
// now (email + password on the single /login page), so there is no // 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 { getAuth } from "@/lib/server/auth";
import { User } from "@/lib/server/models/User"; 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. * Resolve and authorize the current request as a platform operator.
* Returns the authenticated User's id/email on success, or null if the * 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. * 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( export async function requirePlatformAdmin(
req: NextRequest req: NextRequest
): Promise<{ userId: string; email: string } | null> { ): Promise<{ userId: string; email: string } | null> {
const auth = getAuth(req); const auth = getAuth(req);
if (!auth) return null; 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 (!user || !user.isActive) return null;
if (!isPlatformAdminEmail(user.email)) return null; if (!isPlatformAdmin(user)) return null;
return { userId: auth.sub, email: user.email }; return { userId: auth.sub, email: user.email };
} }
+152
View File
@@ -0,0 +1,152 @@
import { describe, expect, it } from "vitest";
import bcrypt from "bcryptjs";
import { User } from "@/lib/server/models/User";
import * as users from "@/app/api/users/route";
import * as login from "@/app/api/auth/login/route";
import * as me from "@/app/api/auth/me/route";
import * as platformBusinesses from "@/app/api/platform/businesses/route";
import { ACCESS_COOKIE } from "@/lib/server/auth";
import { json, makeTenant, req, uuid, type Actor, type Tenant } from "./helpers";
const PERSON = { name: "Wanjiru", email: "wanjiru@aquaflow.test", phoneNumber: "0722000111" };
const OPERATOR = "operator@aquaflow.test"; // listed in PLATFORM_ADMIN_EMAILS by tests/setup.ts
async function hire(t: Tenant, as: Actor, person = PERSON) {
return users.POST(
req("POST", "/api/users", { as, body: { ...person, role: "DELIVERY", branchId: t.branchA, password: "password-123" } })
);
}
const signIn = (email: string, password: string, businessId?: string) =>
login.POST(req("POST", "/api/auth/login", { body: { email, password, deviceId: uuid(), businessId } }));
async function setPassword(email: string, businessId: string, password: string) {
await User.updateOne({ email, businessId }, { passwordHash: await bcrypt.hash(password, 4) });
}
function accessCookie(res: Response): string {
const cookie = res.headers.get("set-cookie") ?? "";
return cookie.match(new RegExp(`${ACCESS_COOKIE}=([^;]+)`))![1];
}
describe("email and phone are unique per business", () => {
it("lets one person work for two businesses with the same email and phone", async () => {
const a = await makeTenant("Alpha");
const b = await makeTenant("Beta");
expect((await hire(a, a.owner)).status).toBe(201);
expect((await hire(b, b.owner)).status).toBe(201);
expect(await User.countDocuments({ email: PERSON.email })).toBe(2);
});
it("still refuses a duplicate inside the same business", async () => {
const a = await makeTenant("Alpha");
await hire(a, a.owner);
const sameEmail = await hire(a, a.owner, { ...PERSON, phoneNumber: "0733000222" });
const samePhone = await hire(a, a.owner, { ...PERSON, email: "other@aquaflow.test" });
expect([sameEmail.status, samePhone.status]).toEqual([409, 409]);
});
});
describe("login when an email exists in several businesses", () => {
it("signs straight in when only one account's password matches", async () => {
const a = await makeTenant("Alpha");
const b = await makeTenant("Beta");
await hire(a, a.owner);
await hire(b, b.owner);
await setPassword(PERSON.email, a.businessId, "alpha-secret");
await setPassword(PERSON.email, b.businessId, "beta-secret");
const res = await json(await signIn(PERSON.email, "beta-secret"));
expect(res.status).toBe(200);
expect(res.body.user.businessId).toBe(b.businessId);
});
it("asks which business when the same password opens both, then signs in to the one chosen", async () => {
const a = await makeTenant("Alpha");
const b = await makeTenant("Beta");
await hire(a, a.owner);
await hire(b, b.owner);
await setPassword(PERSON.email, a.businessId, "same-secret");
await setPassword(PERSON.email, b.businessId, "same-secret");
const ask = await json(await signIn(PERSON.email, "same-secret"));
expect(ask.status).toBe(300);
expect(ask.body.chooseBusiness.map((c: any) => c.businessName).sort()).toEqual(["Alpha", "Beta"]);
const chosen = await json(await signIn(PERSON.email, "same-secret", a.businessId));
expect(chosen.status).toBe(200);
expect(chosen.body.user.businessId).toBe(a.businessId);
});
it("reveals nothing about other businesses to a wrong password", async () => {
const a = await makeTenant("Alpha");
const b = await makeTenant("Beta");
await hire(a, a.owner);
await hire(b, b.owner);
const res = await json(await signIn(PERSON.email, "wrong-password"));
expect(res.status).toBe(401);
expect(res.body.chooseBusiness).toBeUndefined();
expect(res.body.error).toBe("Incorrect email or password");
});
it("does not let a wrong guess at one business unlock or reset the other", async () => {
const a = await makeTenant("Alpha");
const b = await makeTenant("Beta");
await hire(a, a.owner);
await hire(b, b.owner);
await setPassword(PERSON.email, a.businessId, "alpha-secret");
await setPassword(PERSON.email, b.businessId, "beta-secret");
for (let i = 0; i < 2; i++) await signIn(PERSON.email, "wrong");
await signIn(PERSON.email, "alpha-secret"); // resets Alpha's counter only
const [alpha, beta] = await Promise.all([
User.findOne({ email: PERSON.email, businessId: a.businessId }),
User.findOne({ email: PERSON.email, businessId: b.businessId }),
]);
expect([alpha!.failedLoginAttempts, beta!.failedLoginAttempts]).toEqual([0, 2]);
});
});
describe("platform operator access", () => {
it("is not granted by an email match alone: another business cannot mint an operator", async () => {
const mallory = await makeTenant("Mallory Water");
await hire(mallory, mallory.owner, { name: "Fake operator", email: OPERATOR, phoneNumber: "0744000333" });
await setPassword(OPERATOR, mallory.businessId, "i-am-the-operator");
const res = await signIn(OPERATOR, "i-am-the-operator");
expect(res.status).toBe(200);
expect((await res.clone().json()).isPlatformAdmin).toBe(false);
const consoleRes = await platformBusinesses.GET(
req("GET", "/api/platform/businesses", { cookies: { [ACCESS_COOKIE]: accessCookie(res) } })
);
expect(consoleRes.status).toBe(403);
});
it("is granted to the flagged account whose email is listed", async () => {
const home = await makeTenant("Operator Co");
await hire(home, home.owner, { name: "Operator", email: OPERATOR, phoneNumber: "0755000444" });
await User.updateOne({ email: OPERATOR, businessId: home.businessId }, { platformAdmin: true });
await setPassword(OPERATOR, home.businessId, "real-operator");
const res = await signIn(OPERATOR, "real-operator");
const cookies = { [ACCESS_COOKIE]: accessCookie(res) };
expect((await platformBusinesses.GET(req("GET", "/x", { cookies }))).status).toBe(200);
expect((await json(await me.GET(req("GET", "/x", { cookies })))).body.user.isPlatformAdmin).toBe(true);
});
it("is revoked by removing the email from PLATFORM_ADMIN_EMAILS, even with the flag", async () => {
const home = await makeTenant("Operator Co");
await hire(home, home.owner, { name: "Operator", email: OPERATOR, phoneNumber: "0755000444" });
await User.updateOne({ email: OPERATOR, businessId: home.businessId }, { platformAdmin: true });
await setPassword(OPERATOR, home.businessId, "real-operator");
const cookies = { [ACCESS_COOKIE]: accessCookie(await signIn(OPERATOR, "real-operator")) };
const listed = process.env.PLATFORM_ADMIN_EMAILS;
process.env.PLATFORM_ADMIN_EMAILS = "";
try {
expect((await platformBusinesses.GET(req("GET", "/x", { cookies }))).status).toBe(403);
} finally {
process.env.PLATFORM_ADMIN_EMAILS = listed;
}
});
});