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:
@@ -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