Fix business tenant binding and update password on cross-site signup.

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 <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-21 12:07:17 +03:30
co-authored by Cursor
parent 193c63bfd2
commit 89269dff04
6 changed files with 52 additions and 50 deletions
+12 -12
View File
@@ -50,26 +50,26 @@ function canAccessBusinessDashboard(user: AuthUser) {
async function prepareBusinessContext(user: AuthUser) { async function prepareBusinessContext(user: AuthUser) {
const domain = getBusinessDomain() 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)) { if (isSuperAdminUser(user)) {
const tenant = await resolveTenantByDomain(domain)
setActiveBusinessById(String(tenant.id), tenant.domain)
return return
} }
const membership = const membership = user.businesses.find(
user.businesses.find((business) => business.slug === tenantSlugFromDomain(domain)) ?? (business) => String(business.id) === String(tenant.id),
user.businesses[0] )
if (!membership) { 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) { async function assertBusinessAccess(user: AuthUser) {
+5 -3
View File
@@ -4,12 +4,14 @@ import { getBusinessDomain } from './config'
const ACTIVE_BUSINESS_ID_KEY = 'meshkee_active_business_id' const ACTIVE_BUSINESS_ID_KEY = 'meshkee_active_business_id'
const ACTIVE_BUSINESS_DOMAIN_KEY = 'meshkee_active_business_domain' 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() const domain = getBusinessDomain()
localStorage.setItem(ACTIVE_BUSINESS_DOMAIN_KEY, domain) localStorage.setItem(ACTIVE_BUSINESS_DOMAIN_KEY, domain)
if (user.businesses.length > 0) { const id = businessId ?? (user.businesses[0] ? String(user.businesses[0].id) : null)
localStorage.setItem(ACTIVE_BUSINESS_ID_KEY, String(user.businesses[0].id)) if (id) {
localStorage.setItem(ACTIVE_BUSINESS_ID_KEY, id)
} }
} }
+12 -4
View File
@@ -336,12 +336,16 @@ const en = {
'signup.error.length': 'Password must be at least 8 characters.', 'signup.error.length': 'Password must be at least 8 characters.',
'signup.error.create': 'Unable to create account.', 'signup.error.create': 'Unable to create account.',
'signup.error.cellExists': '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': '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.verify': 'Account created. Enter the SMS code to verify your number and sign in.',
'signup.info.verifyExisting': 'signup.info.verifyExisting':
'Enter the SMS code to join this website. Your Meshkee password and profile stay the same.', '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.title': 'Existing Meshkee account',
'signup.cellExists.admit': 'I understand, continue', 'signup.cellExists.admit': 'I understand, continue',
@@ -729,12 +733,16 @@ const fa: Record<MessageKey, string> = {
'signup.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.', 'signup.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.',
'signup.error.create': 'ایجاد حساب ممکن نشد.', 'signup.error.create': 'ایجاد حساب ممکن نشد.',
'signup.error.cellExists': 'signup.error.cellExists':
'این شماره موبایل قبلاً در سیستم مدیریت یکپارچه ی مشکی (__MESHKEE_APP__) حساب دارد ({sites}). رمز عبور و اطلاعات شما همان خواهد بود. با ادامه، تأیید پیامکی را انجام دهید تا وارد این وب‌سایت هم شوید.', 'این شماره موبایل قبلاً در سیستم مدیریت یکپارچه ی مشکی (__MESHKEE_APP__) حساب دارد ({sites}). اطلاعات شما همان می‌ماند؛ رمزی که اینجا وارد کردید جایگزین رمز مشکی می‌شود. با ادامه، تأیید پیامکی را انجام دهید تا وارد این وب‌سایت هم شوید.',
'signup.error.cellExistsGeneric': 'signup.error.cellExistsGeneric':
'این شماره موبایل قبلاً در سیستم مدیریت یکپارچه ی مشکی (__MESHKEE_APP__) (وب‌سایت دیگری) حساب دارد. رمز عبور و اطلاعات شما همان خواهد بود. با ادامه، تأیید پیامکی را انجام دهید تا وارد این وب‌سایت هم شوید.', 'این شماره موبایل قبلاً در سیستم مدیریت یکپارچه ی مشکی (__MESHKEE_APP__) (وب‌سایت دیگری) حساب دارد. اطلاعات شما همان می‌ماند؛ رمزی که اینجا وارد کردید جایگزین رمز مشکی می‌شود. با ادامه، تأیید پیامکی را انجام دهید تا وارد این وب‌سایت هم شوید.',
'signup.info.verify': 'حساب ایجاد شد. کد پیامکی را وارد کنید تا شماره تأیید شود و وارد شوید.', 'signup.info.verify': 'حساب ایجاد شد. کد پیامکی را وارد کنید تا شماره تأیید شود و وارد شوید.',
'signup.info.verifyExisting': 'signup.info.verifyExisting':
'کد پیامکی را وارد کنید تا به این وب‌سایت بپیوندید. رمز عبور و اطلاعات مشکی شما همان می‌ماند.', 'کد پیامکی را وارد کنید تا به این وب‌سایت بپیوندید. رمز عبور و اطلاعات مشکی شما همان می‌ماند.',
'signup.info.verifyExistingPasswordUpdated':
'کد پیامکی را وارد کنید تا عضویت تمام شود. رمز مشکی شما به رمزی که همین الان وارد کردید به‌روز شد.',
'signup.toast.passwordUpdated':
'رمز مشکی شما به رمزی که در این وب‌سایت وارد کردید به‌روز شد.',
'signup.cellExists.title': 'حساب موجود در مشکی', 'signup.cellExists.title': 'حساب موجود در مشکی',
'signup.cellExists.admit': 'متوجه شدم، ادامه', 'signup.cellExists.admit': 'متوجه شدم، ادامه',
+20 -30
View File
@@ -16,7 +16,7 @@ import {
resetPassword, resetPassword,
sendOtp, sendOtp,
} from '../services/authService' } 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 { useT } from '../i18n/useT'
import { safeRedirectPath } from '../lib/redirect' import { safeRedirectPath } from '../lib/redirect'
import { BackgroundOrbs } from '../components/BackgroundOrbs' import { BackgroundOrbs } from '../components/BackgroundOrbs'
@@ -66,6 +66,7 @@ export function LoginPage() {
const { businessName, businessNameEn, logoUrl } = useTenantBranding() const { businessName, businessNameEn, logoUrl } = useTenantBranding()
const tenantDomain = getTenantDomain() const tenantDomain = getTenantDomain()
const t = useT() const t = useT()
const { showToast } = useToast()
const { locale, dir } = useLocale() const { locale, dir } = useLocale()
const [view, setView] = useState<AuthView>('login') const [view, setView] = useState<AuthView>('login')
@@ -207,7 +208,7 @@ export function LoginPage() {
} }
} }
async function runSignup() { async function runSignup(acknowledgeExistingAccount = false) {
const cellNumber = resolveCellNumber() const cellNumber = resolveCellNumber()
if (!cellNumber) return if (!cellNumber) return
@@ -220,10 +221,15 @@ export function LoginPage() {
firstName: firstName.trim(), firstName: firstName.trim(),
lastName: lastName.trim(), lastName: lastName.trim(),
domain: tenantDomain, domain: tenantDomain,
...(acknowledgeExistingAccount ? { acknowledgeExistingAccount: true } : {}),
}) })
setCellExistsSites(null) setCellExistsSites(null)
if (data.passwordUpdated) {
showToast(t('signup.toast.passwordUpdated'), 'info')
}
if (data.user.customerBusinesses.length === 0) { if (data.user.customerBusinesses.length === 0) {
logoutRequest() logoutRequest()
setError(CUSTOMER_ACCESS_MESSAGE) setError(CUSTOMER_ACCESS_MESSAGE)
@@ -232,14 +238,19 @@ export function LoginPage() {
if (!data.user.cellVerifiedAt) { if (!data.user.cellVerifiedAt) {
logoutRequest() logoutRequest()
await continueToSmsVerify(cellNumber) await continueToSmsVerify(
cellNumber,
data.passwordUpdated
? t('signup.info.verifyExistingPasswordUpdated')
: t('signup.info.verify'),
)
return return
} }
await login(cellNumber, password) await login(cellNumber, password)
navigate(redirectTo) navigate(redirectTo)
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (!acknowledgeExistingAccount && err instanceof ApiError) {
const sites = parseCellExistsSites(err.message) const sites = parseCellExistsSites(err.message)
if (sites !== null) { if (sites !== null) {
setCellExistsSites(sites) setCellExistsSites(sites)
@@ -253,13 +264,13 @@ export function LoginPage() {
} }
} }
async function continueToSmsVerify(cellNumber: string) { async function continueToSmsVerify(cellNumber: string, infoMessage?: string) {
setView('otp') setView('otp')
setSmsStep('phone') setSmsStep('phone')
setPassword('') setPassword('')
setConfirmPassword('') setConfirmPassword('')
setSmsCode('') setSmsCode('')
setInfo(t('signup.info.verify')) setInfo(infoMessage ?? t('signup.info.verify'))
try { try {
const otpResult = await sendOtp(cellNumber, tenantDomain) const otpResult = await sendOtp(cellNumber, tenantDomain)
if (!otpResult.enabled) { if (!otpResult.enabled) {
@@ -292,9 +303,8 @@ export function LoginPage() {
} }
/** /**
* Existing Meshkee account on another site: user admits the notice, then proves * Existing Meshkee account on another site: user admits the notice, then we link
* phone ownership via SMS. login-otp links them to this website (password/data unchanged), * them with the new password and prove phone ownership via SMS.
* so they appear in the business Customers list after verification.
*/ */
async function handleAdmitCellExists() { async function handleAdmitCellExists() {
clearMessages() clearMessages()
@@ -304,27 +314,7 @@ export function LoginPage() {
return return
} }
setIsSubmitting(true) await runSignup(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)
}
} }
async function handleResetPassword(e: React.FormEvent) { async function handleResetPassword(e: React.FormEvent) {
+1 -1
View File
@@ -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 | | 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 | | 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.
--- ---
@@ -51,6 +51,8 @@ export interface MeResponse {
export interface RegisterResponse extends LoginResponse { export interface RegisterResponse extends LoginResponse {
smsEnabled?: boolean smsEnabled?: boolean
/** True when an existing Meshkee account was linked and its password was replaced. */
passwordUpdated?: boolean
registeredBusiness?: { id: string; name: string; slug: string } registeredBusiness?: { id: string; name: string; slug: string }
} }