From 89269dff04da0568750e19b2fea53c57d8b7a60d Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Fri, 21 Aug 2026 12:07:17 +0330 Subject: [PATCH] Fix business tenant binding and update password on cross-site signup. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the active business from the host domain so multi-business staff no longer load another tenant’s CMS data, and replace the Meshkee password when linking an existing account to a new site. Co-authored-by: Cursor --- apps/business/src/context/AuthContext.tsx | 24 +++++------ apps/business/src/lib/businessContext.ts | 8 ++-- apps/customer/src/i18n/messages.ts | 16 ++++++-- apps/customer/src/pages/LoginPage.tsx | 50 +++++++++-------------- docs/PROJECT_CONTEXT.md | 2 +- packages/dashboard-core/src/types/auth.ts | 2 + 6 files changed, 52 insertions(+), 50 deletions(-) diff --git a/apps/business/src/context/AuthContext.tsx b/apps/business/src/context/AuthContext.tsx index e125b44..b38e41d 100644 --- a/apps/business/src/context/AuthContext.tsx +++ b/apps/business/src/context/AuthContext.tsx @@ -50,26 +50,26 @@ function canAccessBusinessDashboard(user: AuthUser) { async function prepareBusinessContext(user: AuthUser) { const domain = getBusinessDomain() + // Always resolve the host → business id. Never fall back to businesses[0]: + // slug ≠ first DNS label (e.g. arjmand-pharmacy vs arjmandpharmacy.com) and + // multi-business staff would otherwise load the wrong tenant's CMS data. + const tenant = await resolveTenantByDomain(domain) + setActiveBusinessById(String(tenant.id), tenant.domain) if (isSuperAdminUser(user)) { - const tenant = await resolveTenantByDomain(domain) - setActiveBusinessById(String(tenant.id), tenant.domain) return } - const membership = - user.businesses.find((business) => business.slug === tenantSlugFromDomain(domain)) ?? - user.businesses[0] + const membership = user.businesses.find( + (business) => String(business.id) === String(tenant.id), + ) if (!membership) { - throw new ApiError('No business membership found for this account.', 403) + throw new ApiError( + 'This account is not a staff member of this business.', + 403, + ) } - - setActiveBusinessById(String(membership.id), domain) -} - -function tenantSlugFromDomain(domain: string) { - return domain.split('.')[0]?.toLowerCase() ?? domain } async function assertBusinessAccess(user: AuthUser) { diff --git a/apps/business/src/lib/businessContext.ts b/apps/business/src/lib/businessContext.ts index f40f3ac..f243ff6 100644 --- a/apps/business/src/lib/businessContext.ts +++ b/apps/business/src/lib/businessContext.ts @@ -4,12 +4,14 @@ import { getBusinessDomain } from './config' const ACTIVE_BUSINESS_ID_KEY = 'meshkee_active_business_id' const ACTIVE_BUSINESS_DOMAIN_KEY = 'meshkee_active_business_domain' -export function setActiveBusiness(user: AuthUser) { +/** Prefer resolving the host tenant, then {@link setActiveBusinessById}. */ +export function setActiveBusiness(user: AuthUser, businessId?: string) { const domain = getBusinessDomain() localStorage.setItem(ACTIVE_BUSINESS_DOMAIN_KEY, domain) - if (user.businesses.length > 0) { - localStorage.setItem(ACTIVE_BUSINESS_ID_KEY, String(user.businesses[0].id)) + const id = businessId ?? (user.businesses[0] ? String(user.businesses[0].id) : null) + if (id) { + localStorage.setItem(ACTIVE_BUSINESS_ID_KEY, id) } } diff --git a/apps/customer/src/i18n/messages.ts b/apps/customer/src/i18n/messages.ts index 56a5d5e..b8e77b0 100644 --- a/apps/customer/src/i18n/messages.ts +++ b/apps/customer/src/i18n/messages.ts @@ -336,12 +336,16 @@ const en = { 'signup.error.length': 'Password must be at least 8 characters.', 'signup.error.create': 'Unable to create account.', 'signup.error.cellExists': - 'This mobile number already has an account in Meshkee Unified Management System (__MESHKEE_APP__) on {sites}. Your password and data will stay the same. Continue to verify by SMS and join this website too.', + 'This mobile number already has an account in Meshkee Unified Management System (__MESHKEE_APP__) on {sites}. Your profile stays the same; the password you enter here will replace your Meshkee password. Continue to verify by SMS and join this website too.', 'signup.error.cellExistsGeneric': - 'This mobile number already has an account in Meshkee Unified Management System (__MESHKEE_APP__) on another website. Your password and data will stay the same. Continue to verify by SMS and join this website too.', + 'This mobile number already has an account in Meshkee Unified Management System (__MESHKEE_APP__) on another website. Your profile stays the same; the password you enter here will replace your Meshkee password. Continue to verify by SMS and join this website too.', 'signup.info.verify': 'Account created. Enter the SMS code to verify your number and sign in.', 'signup.info.verifyExisting': 'Enter the SMS code to join this website. Your Meshkee password and profile stay the same.', + 'signup.info.verifyExistingPasswordUpdated': + 'Enter the SMS code to finish joining. Your Meshkee password was updated to the one you just entered.', + 'signup.toast.passwordUpdated': + 'Your Meshkee password was updated to the one you entered on this website.', 'signup.cellExists.title': 'Existing Meshkee account', 'signup.cellExists.admit': 'I understand, continue', @@ -729,12 +733,16 @@ const fa: Record = { 'signup.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.', 'signup.error.create': 'ایجاد حساب ممکن نشد.', 'signup.error.cellExists': - 'این شماره موبایل قبلاً در سیستم مدیریت یکپارچه ی مشکی (__MESHKEE_APP__) حساب دارد ({sites}). رمز عبور و اطلاعات شما همان خواهد بود. با ادامه، تأیید پیامکی را انجام دهید تا وارد این وب‌سایت هم شوید.', + 'این شماره موبایل قبلاً در سیستم مدیریت یکپارچه ی مشکی (__MESHKEE_APP__) حساب دارد ({sites}). اطلاعات شما همان می‌ماند؛ رمزی که اینجا وارد کردید جایگزین رمز مشکی می‌شود. با ادامه، تأیید پیامکی را انجام دهید تا وارد این وب‌سایت هم شوید.', 'signup.error.cellExistsGeneric': - 'این شماره موبایل قبلاً در سیستم مدیریت یکپارچه ی مشکی (__MESHKEE_APP__) (وب‌سایت دیگری) حساب دارد. رمز عبور و اطلاعات شما همان خواهد بود. با ادامه، تأیید پیامکی را انجام دهید تا وارد این وب‌سایت هم شوید.', + 'این شماره موبایل قبلاً در سیستم مدیریت یکپارچه ی مشکی (__MESHKEE_APP__) (وب‌سایت دیگری) حساب دارد. اطلاعات شما همان می‌ماند؛ رمزی که اینجا وارد کردید جایگزین رمز مشکی می‌شود. با ادامه، تأیید پیامکی را انجام دهید تا وارد این وب‌سایت هم شوید.', 'signup.info.verify': 'حساب ایجاد شد. کد پیامکی را وارد کنید تا شماره تأیید شود و وارد شوید.', 'signup.info.verifyExisting': 'کد پیامکی را وارد کنید تا به این وب‌سایت بپیوندید. رمز عبور و اطلاعات مشکی شما همان می‌ماند.', + 'signup.info.verifyExistingPasswordUpdated': + 'کد پیامکی را وارد کنید تا عضویت تمام شود. رمز مشکی شما به رمزی که همین الان وارد کردید به‌روز شد.', + 'signup.toast.passwordUpdated': + 'رمز مشکی شما به رمزی که در این وب‌سایت وارد کردید به‌روز شد.', 'signup.cellExists.title': 'حساب موجود در مشکی', 'signup.cellExists.admit': 'متوجه شدم، ادامه', diff --git a/apps/customer/src/pages/LoginPage.tsx b/apps/customer/src/pages/LoginPage.tsx index c3ee5bd..85d5150 100644 --- a/apps/customer/src/pages/LoginPage.tsx +++ b/apps/customer/src/pages/LoginPage.tsx @@ -16,7 +16,7 @@ import { resetPassword, sendOtp, } from '../services/authService' -import { LanguageSelect, PasswordStrengthMeter, isPasswordStrong, useLocale } from '@meshkee/dashboard-ui' +import { LanguageSelect, PasswordStrengthMeter, isPasswordStrong, useLocale, useToast } from '@meshkee/dashboard-ui' import { useT } from '../i18n/useT' import { safeRedirectPath } from '../lib/redirect' import { BackgroundOrbs } from '../components/BackgroundOrbs' @@ -66,6 +66,7 @@ export function LoginPage() { const { businessName, businessNameEn, logoUrl } = useTenantBranding() const tenantDomain = getTenantDomain() const t = useT() + const { showToast } = useToast() const { locale, dir } = useLocale() const [view, setView] = useState('login') @@ -207,7 +208,7 @@ export function LoginPage() { } } - async function runSignup() { + async function runSignup(acknowledgeExistingAccount = false) { const cellNumber = resolveCellNumber() if (!cellNumber) return @@ -220,10 +221,15 @@ export function LoginPage() { firstName: firstName.trim(), lastName: lastName.trim(), domain: tenantDomain, + ...(acknowledgeExistingAccount ? { acknowledgeExistingAccount: true } : {}), }) setCellExistsSites(null) + if (data.passwordUpdated) { + showToast(t('signup.toast.passwordUpdated'), 'info') + } + if (data.user.customerBusinesses.length === 0) { logoutRequest() setError(CUSTOMER_ACCESS_MESSAGE) @@ -232,14 +238,19 @@ export function LoginPage() { if (!data.user.cellVerifiedAt) { logoutRequest() - await continueToSmsVerify(cellNumber) + await continueToSmsVerify( + cellNumber, + data.passwordUpdated + ? t('signup.info.verifyExistingPasswordUpdated') + : t('signup.info.verify'), + ) return } await login(cellNumber, password) navigate(redirectTo) } catch (err) { - if (err instanceof ApiError) { + if (!acknowledgeExistingAccount && err instanceof ApiError) { const sites = parseCellExistsSites(err.message) if (sites !== null) { setCellExistsSites(sites) @@ -253,13 +264,13 @@ export function LoginPage() { } } - async function continueToSmsVerify(cellNumber: string) { + async function continueToSmsVerify(cellNumber: string, infoMessage?: string) { setView('otp') setSmsStep('phone') setPassword('') setConfirmPassword('') setSmsCode('') - setInfo(t('signup.info.verify')) + setInfo(infoMessage ?? t('signup.info.verify')) try { const otpResult = await sendOtp(cellNumber, tenantDomain) if (!otpResult.enabled) { @@ -292,9 +303,8 @@ export function LoginPage() { } /** - * Existing Meshkee account on another site: user admits the notice, then proves - * phone ownership via SMS. login-otp links them to this website (password/data unchanged), - * so they appear in the business Customers list after verification. + * Existing Meshkee account on another site: user admits the notice, then we link + * them with the new password and prove phone ownership via SMS. */ async function handleAdmitCellExists() { clearMessages() @@ -304,27 +314,7 @@ export function LoginPage() { return } - setIsSubmitting(true) - setCellExistsSites(null) - try { - setInfo(t('signup.info.verifyExisting')) - setView('otp') - setSmsStep('phone') - setPassword('') - setConfirmPassword('') - setSmsCode('') - const otpResult = await sendOtp(cellNumber, tenantDomain) - if (!otpResult.enabled) { - setInfo(otpResult.message) - } - setCodeSent(true) - setSmsStep('code') - startCountdown() - } catch (otpErr) { - handleApiError(otpErr, t('login.error.sendCode')) - } finally { - setIsSubmitting(false) - } + await runSignup(true) } async function handleResetPassword(e: React.FormEvent) { diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 64690cf..a0da894 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -239,7 +239,7 @@ Business and customer login pages share the same SMS-backed flows (super-admin s | Verify only | `POST /auth/verify-otp` | Marks verified; no tokens | | Dashboard SSO | `POST /auth/handoff` + `POST /auth/handoff/consume` | Admin icon on customer dashboard; 60s one-time ticket | -Auth helpers: `apps/*/src/services/authService.ts` (`login`, `loginWithOtp`, `resetPassword`, `sendOtp`, `register`, `createHandoff`). `AuthContext` exposes `login` + `loginWithOtp`. Customer header “open business dashboard” uses a one-time SSO ticket so staff do not log in again. +Auth helpers: `apps/*/src/services/authService.ts` (`login`, `loginWithOtp`, `resetPassword`, `sendOtp`, `register`, `createHandoff`). `AuthContext` exposes `login` + `loginWithOtp`. Business dashboard active tenant is resolved from the host domain (never `businesses[0]` alone). Customer header “open business dashboard” uses a one-time SSO ticket so staff do not log in again. --- diff --git a/packages/dashboard-core/src/types/auth.ts b/packages/dashboard-core/src/types/auth.ts index b413c42..6e7fe5a 100644 --- a/packages/dashboard-core/src/types/auth.ts +++ b/packages/dashboard-core/src/types/auth.ts @@ -51,6 +51,8 @@ export interface MeResponse { export interface RegisterResponse extends LoginResponse { smsEnabled?: boolean + /** True when an existing Meshkee account was linked and its password was replaced. */ + passwordUpdated?: boolean registeredBusiness?: { id: string; name: string; slug: string } }