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:
+52
-2
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user