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:
2026-10-12 11:31:45 +03:00
parent 1fe2421cc4
commit 9e3e59a804
14 changed files with 407 additions and 52 deletions
+52 -2
View File
@@ -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">