Show existing-Meshkee signup notice as an acknowledge modal.
After admit, continue via SMS OTP so login-otp can link the user to that website with unchanged password/profile. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
cce28a4d7c
commit
8d59166fe8
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft, User } from 'lucide-react'
|
||||
import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft, User, X } from 'lucide-react'
|
||||
import {
|
||||
useAuth,
|
||||
CUSTOMER_ACCESS_MESSAGE,
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
resetPassword,
|
||||
sendOtp,
|
||||
} from '../services/authService'
|
||||
import { LanguageSelect, PasswordStrengthMeter, isPasswordStrong } from '@meshkee/dashboard-ui'
|
||||
import { LanguageSelect, PasswordStrengthMeter, isPasswordStrong, useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { safeRedirectPath } from '../lib/redirect'
|
||||
import meshkeeLogo from '../assets/meshkee-logo.png'
|
||||
@@ -24,6 +25,38 @@ import styles from './LoginPage.module.css'
|
||||
type AuthView = 'login' | 'signup' | 'forgot' | 'otp'
|
||||
type SmsStep = 'phone' | 'code'
|
||||
|
||||
const MESHKEE_APP_MARKER = '__MESHKEE_APP__'
|
||||
const MESHKEE_SITE_URL = 'https://meshkee.com'
|
||||
|
||||
function parseCellExistsSites(message: string): string | null {
|
||||
if (message.startsWith('CELL_EXISTS_OTHER_SITE:')) {
|
||||
return message.slice('CELL_EXISTS_OTHER_SITE:'.length).trim()
|
||||
}
|
||||
if (message.includes('Cell number exists on another account')) {
|
||||
return ''
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function renderWithMeshkeeLink(message: string, linkClassName: string): ReactNode {
|
||||
const idx = message.indexOf(MESHKEE_APP_MARKER)
|
||||
if (idx === -1) return message
|
||||
return (
|
||||
<>
|
||||
{message.slice(0, idx)}
|
||||
<a
|
||||
href={MESHKEE_SITE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={linkClassName}
|
||||
>
|
||||
Meshkee.app
|
||||
</a>
|
||||
{message.slice(idx + MESHKEE_APP_MARKER.length)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
@@ -32,6 +65,7 @@ export function LoginPage() {
|
||||
const { businessName, businessNameEn, logoUrl } = useTenantBranding()
|
||||
const tenantDomain = getTenantDomain()
|
||||
const t = useT()
|
||||
const { locale, dir } = useLocale()
|
||||
|
||||
const [view, setView] = useState<AuthView>('login')
|
||||
const [smsStep, setSmsStep] = useState<SmsStep>('phone')
|
||||
@@ -49,6 +83,8 @@ export function LoginPage() {
|
||||
const [error, setError] = useState('')
|
||||
const [info, setInfo] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
/** `null` closed; `''` generic notice; otherwise site names */
|
||||
const [cellExistsSites, setCellExistsSites] = useState<string | null>(null)
|
||||
|
||||
function clearMessages() {
|
||||
setError('')
|
||||
@@ -67,6 +103,7 @@ export function LoginPage() {
|
||||
setSmsStep('phone')
|
||||
setCodeSent(false)
|
||||
setShowPassword(false)
|
||||
setCellExistsSites(null)
|
||||
clearMessages()
|
||||
}
|
||||
|
||||
@@ -102,18 +139,6 @@ export function LoginPage() {
|
||||
err.message.includes('Unable to send SMS')
|
||||
) {
|
||||
setError(t('login.error.smsProvider'))
|
||||
} else if (
|
||||
err.message.includes('CELL_EXISTS_OTHER_SITE:') ||
|
||||
err.message.includes('Cell number exists on another account')
|
||||
) {
|
||||
const sites = err.message.startsWith('CELL_EXISTS_OTHER_SITE:')
|
||||
? err.message.slice('CELL_EXISTS_OTHER_SITE:'.length).trim()
|
||||
: ''
|
||||
setError(
|
||||
sites
|
||||
? t('signup.error.cellExists', { sites })
|
||||
: t('signup.error.cellExistsGeneric'),
|
||||
)
|
||||
} else {
|
||||
setError(err.message)
|
||||
}
|
||||
@@ -177,20 +202,7 @@ export function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSignup(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
clearMessages()
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError(t('signup.error.match'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!isPasswordStrong(password)) {
|
||||
setError(t('forgot.error.strength'))
|
||||
return
|
||||
}
|
||||
|
||||
async function runSignup() {
|
||||
const cellNumber = resolveCellNumber()
|
||||
if (!cellNumber) return
|
||||
|
||||
@@ -205,6 +217,8 @@ export function LoginPage() {
|
||||
domain: tenantDomain,
|
||||
})
|
||||
|
||||
setCellExistsSites(null)
|
||||
|
||||
if (data.user.customerBusinesses.length === 0) {
|
||||
logoutRequest()
|
||||
setError(CUSTOMER_ACCESS_MESSAGE)
|
||||
@@ -213,35 +227,101 @@ export function LoginPage() {
|
||||
|
||||
if (!data.user.cellVerifiedAt) {
|
||||
logoutRequest()
|
||||
setView('otp')
|
||||
setSmsStep('phone')
|
||||
setPassword('')
|
||||
setConfirmPassword('')
|
||||
setSmsCode('')
|
||||
setInfo(t('signup.info.verify'))
|
||||
try {
|
||||
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'))
|
||||
}
|
||||
await continueToSmsVerify(cellNumber)
|
||||
return
|
||||
}
|
||||
|
||||
await login(cellNumber, password)
|
||||
navigate(redirectTo)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
const sites = parseCellExistsSites(err.message)
|
||||
if (sites !== null) {
|
||||
setCellExistsSites(sites)
|
||||
return
|
||||
}
|
||||
}
|
||||
setCellExistsSites(null)
|
||||
handleApiError(err, t('signup.error.create'))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function continueToSmsVerify(cellNumber: string) {
|
||||
setView('otp')
|
||||
setSmsStep('phone')
|
||||
setPassword('')
|
||||
setConfirmPassword('')
|
||||
setSmsCode('')
|
||||
setInfo(t('signup.info.verify'))
|
||||
try {
|
||||
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'))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSignup(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
clearMessages()
|
||||
setCellExistsSites(null)
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError(t('signup.error.match'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!isPasswordStrong(password)) {
|
||||
setError(t('forgot.error.strength'))
|
||||
return
|
||||
}
|
||||
|
||||
await runSignup()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async function handleAdmitCellExists() {
|
||||
clearMessages()
|
||||
const cellNumber = resolveCellNumber()
|
||||
if (!cellNumber) {
|
||||
setCellExistsSites(null)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetPassword(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
clearMessages()
|
||||
@@ -842,6 +922,56 @@ export function LoginPage() {
|
||||
>
|
||||
Powered by Meshkee.app
|
||||
</a>
|
||||
|
||||
{cellExistsSites !== null &&
|
||||
createPortal(
|
||||
<div
|
||||
className={styles.noticeOverlay}
|
||||
onClick={() => !isSubmitting && setCellExistsSites(null)}
|
||||
>
|
||||
<div
|
||||
className={styles.noticeModal}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="cell-exists-title"
|
||||
lang={locale}
|
||||
dir={dir}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={styles.noticeHeader}>
|
||||
<h2 id="cell-exists-title" className={styles.noticeTitle}>
|
||||
{t('signup.cellExists.title')}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.noticeClose}
|
||||
onClick={() => setCellExistsSites(null)}
|
||||
disabled={isSubmitting}
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<p className={styles.noticeBody}>
|
||||
{renderWithMeshkeeLink(
|
||||
cellExistsSites
|
||||
? t('signup.error.cellExists', { sites: cellExistsSites })
|
||||
: t('signup.error.cellExistsGeneric'),
|
||||
styles.noticeLink,
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.noticeAdmit}
|
||||
onClick={() => void handleAdmitCellExists()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? t('signup.creating') : t('signup.cellExists.admit')}
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user