Wire SMS registration, forgot password, and one-time login.

Connect business and customer login pages to login-otp and reset-password, and document the auth flows in project context.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-05 15:10:51 +03:30
co-authored by Cursor
parent d8c9c0b017
commit c33cab40fe
12 changed files with 148 additions and 114 deletions
-1
View File
@@ -87,7 +87,6 @@ export function Header() {
<button className={styles.menuBtn} aria-label={t('header.toggleMenu')}>
<Menu size={22} />
</button>
<h1 className={styles.title}>{t('header.title')}</h1>
</div>
<div className={styles.right}>
@@ -18,11 +18,23 @@
margin-bottom: 8px;
}
.welcomeTitle {
font-size: 22px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 6px;
}
.pageSubtitle {
font-size: 15px;
color: var(--text-secondary);
}
.welcomeSubtitle {
font-size: 13px;
color: var(--text-secondary);
}
.dateBadge {
display: flex;
align-items: center;
@@ -116,6 +128,10 @@
font-size: 22px;
}
.welcomeTitle {
font-size: 20px;
}
.dateBadge {
align-self: flex-start;
}
+10 -2
View File
@@ -15,6 +15,7 @@ import { getBusinessDomain } from '../lib/config'
import {
fetchCurrentUser,
login as loginRequest,
loginWithOtp as loginWithOtpRequest,
logout as logoutRequest,
} from '../services/authService'
import { resolveTenantByDomain } from '../services/tenantService'
@@ -24,6 +25,7 @@ interface AuthContextValue {
user: AuthUser | null
isLoading: boolean
login: (cellNumber: string, password: string) => Promise<void>
loginWithOtp: (cellNumber: string, code: string) => Promise<void>
logout: () => void
setUser: (user: AuthUser) => void
}
@@ -130,9 +132,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(data.user)
}, [])
const loginWithOtp = useCallback(async (cellNumber: string, code: string) => {
const data = await loginWithOtpRequest(cellNumber, code)
await assertBusinessAccess(data.user)
setUser(data.user)
}, [])
const value = useMemo(
() => ({ user, isLoading, login, logout, setUser }),
[user, isLoading, login, logout],
() => ({ user, isLoading, login, loginWithOtp, logout, setUser }),
[user, isLoading, login, loginWithOtp, logout],
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
+10 -18
View File
@@ -1030,23 +1030,19 @@ const en = {
'forgot.reset': 'Reset password',
'forgot.verifying': 'Verifying...',
'forgot.error.length': 'Password must be at least 8 characters.',
'forgot.error.verify': 'Unable to verify code.',
'forgot.info.partial':
'Phone number verified. Full password reset via SMS is not available yet — please contact support or sign in if you remember your password.',
'forgot.error.verify': 'Unable to reset password. Check the code and try again.',
'forgot.info.success': 'Password updated. You can sign in with your new password.',
'otp.back': 'Back to sign in',
'otp.title': 'One-time login',
'otp.subtitlePhone': 'Verify your mobile number with a one-time SMS code',
'otp.subtitleCode': 'Enter the SMS code and your password',
'otp.subtitlePhone': 'We will send a one-time SMS code to your mobile number',
'otp.subtitleCode': 'Enter the SMS code to sign in',
'otp.sendCode': 'Send SMS code',
'otp.sending': 'Sending...',
'otp.code': 'SMS verification code',
'otp.password': 'Password',
'otp.passwordPlaceholder': 'Your account password',
'otp.signIn': 'Sign in',
'otp.signingIn': 'Signing in...',
'otp.error.password': 'Enter your account password to complete sign-in after SMS verification.',
'otp.error.signIn': 'Unable to sign in with SMS verification.',
'otp.error.signIn': 'Unable to sign in with SMS code.',
'common.close': 'Close',
'common.resendIn': 'Resend code in {seconds}s',
@@ -2087,23 +2083,19 @@ const fa: Record<MessageKey, string> = {
'forgot.reset': 'بازنشانی رمز عبور',
'forgot.verifying': 'در حال تأیید...',
'forgot.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.',
'forgot.error.verify': 'تأیید کد ممکن نشد.',
'forgot.info.partial':
'شماره تأیید شد. بازنشانی کامل رمز با پیامک هنوز فعال نیست — با پشتیبانی تماس بگیرید یا اگر رمز را به یاد دارید وارد شوید.',
'forgot.error.verify': 'بازنشانی رمز ممکن نشد. کد را بررسی کنید و دوباره تلاش کنید.',
'forgot.info.success': 'رمز عبور به‌روز شد. می‌توانید با رمز جدید وارد شوید.',
'otp.back': 'بازگشت به ورود',
'otp.title': 'ورود یک‌بارمصرف',
'otp.subtitlePhone': 'شماره موبایل را با کد پیامکی یک‌بارمصرف تأیید کنید',
'otp.subtitleCode': 'کد پیامکی و رمز عبور را وارد کنید',
'otp.subtitlePhone': 'کد یک‌بارمصرف را با پیامک به شماره موبایل ارسال می‌کنیم',
'otp.subtitleCode': 'کد پیامکی را وارد کنید تا وارد شوید',
'otp.sendCode': 'ارسال کد پیامکی',
'otp.sending': 'در حال ارسال...',
'otp.code': 'کد تأیید پیامکی',
'otp.password': 'رمز عبور',
'otp.passwordPlaceholder': 'رمز عبور حساب',
'otp.signIn': 'ورود',
'otp.signingIn': 'در حال ورود...',
'otp.error.password': 'برای تکمیل ورود پس از تأیید پیامک، رمز عبور حساب را وارد کنید.',
'otp.error.signIn': 'ورود با تأیید پیامکی ممکن نشد.',
'otp.error.signIn': 'ورود با کد پیامکی ممکن نشد.',
'common.close': 'بستن',
'common.resendIn': 'ارسال مجدد کد تا {seconds} ثانیه',
+2 -2
View File
@@ -149,8 +149,8 @@ export function HomePage() {
<main className={styles.content}>
<div className={styles.pageHeader}>
<div>
<h2 className={styles.pageTitle}>{t('home.welcome', { name: firstName })}</h2>
<p className={styles.pageSubtitle}>{t('home.subtitle')}</p>
<h2 className={styles.welcomeTitle}>{t('home.welcome', { name: firstName })}</h2>
<p className={styles.welcomeSubtitle}>{t('home.subtitle')}</p>
</div>
<div className={styles.dateBadge}>
+7 -30
View File
@@ -11,8 +11,8 @@ import { setActiveBusiness } from '../lib/businessContext'
import {
logout as logoutRequest,
register,
resetPassword,
sendOtp,
verifyOtp,
} from '../services/authService'
import { useT } from '../i18n/useT'
import styles from './LoginPage.module.css'
@@ -22,7 +22,7 @@ type SmsStep = 'phone' | 'code'
export function LoginPage() {
const navigate = useNavigate()
const { login } = useAuth()
const { login, loginWithOtp } = useAuth()
const { businessName, logoUrl } = useTenantBranding()
const businessDomain = getBusinessDomain()
const t = useT()
@@ -184,9 +184,9 @@ export function LoginPage() {
try {
const cellNumber = toE164CellNumber(phone)
await verifyOtp(cellNumber, smsCode)
setInfo(t('forgot.info.partial'))
setTimeout(() => switchView('login'), 2500)
await resetPassword(cellNumber, smsCode, newPassword)
setInfo(t('forgot.info.success'))
setTimeout(() => switchView('login'), 2000)
} catch (err) {
handleApiError(err, t('forgot.error.verify'))
} finally {
@@ -201,14 +201,7 @@ export function LoginPage() {
try {
const cellNumber = toE164CellNumber(phone)
await verifyOtp(cellNumber, smsCode)
if (!password) {
setError(t('otp.error.password'))
return
}
await login(cellNumber, password)
await loginWithOtp(cellNumber, smsCode)
navigate('/')
} catch (err) {
handleApiError(err, t('otp.error.signIn'))
@@ -600,6 +593,7 @@ export function LoginPage() {
{error}
</div>
)}
{info && <div className={styles.info}>{info}</div>}
{smsStep === 'phone' ? (
<>
@@ -653,23 +647,6 @@ export function LoginPage() {
</div>
</div>
<div className={styles.field}>
<label htmlFor="otp-password">{t('otp.password')}</label>
<div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} />
<input
id="otp-password"
type={showPassword ? 'text' : 'password'}
placeholder={t('otp.passwordPlaceholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
disabled={isSubmitting}
/>
</div>
</div>
<div className={styles.resendRow}>
{countdown > 0 ? (
<span className={styles.countdown}>
+17
View File
@@ -54,6 +54,23 @@ export async function verifyOtp(cellNumber: string, code: string) {
})
}
export async function loginWithOtp(cellNumber: string, code: string) {
const data = await apiRequest<LoginResponse>('/auth/login-otp', {
method: 'POST',
body: { cellNumber, code },
})
setTokens(data.accessToken, data.refreshToken)
return data
}
export async function resetPassword(cellNumber: string, code: string, newPassword: string) {
return apiRequest<{ message: string }>('/auth/reset-password', {
method: 'POST',
body: { cellNumber, code, newPassword },
})
}
export async function changePassword(currentPassword: string, newPassword: string) {
return apiRequest<{ message: string }>('/auth/change-password', {
method: 'POST',
+10 -2
View File
@@ -13,6 +13,7 @@ import { getTenantDomain } from '../lib/config'
import {
fetchCurrentUser,
login as loginRequest,
loginWithOtp as loginWithOtpRequest,
logout as logoutRequest,
} from '../services/authService'
import { resolveTenantByDomain } from '../services/tenantService'
@@ -22,6 +23,7 @@ interface AuthContextValue {
user: AuthUser | null
isLoading: boolean
login: (cellNumber: string, password: string) => Promise<void>
loginWithOtp: (cellNumber: string, code: string) => Promise<void>
logout: () => void
setUser: (user: AuthUser) => void
}
@@ -124,9 +126,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(data.user)
}, [])
const loginWithOtp = useCallback(async (cellNumber: string, code: string) => {
const data = await loginWithOtpRequest(cellNumber, code)
await assertCustomerAccess(data.user)
setUser(data.user)
}, [])
const value = useMemo(
() => ({ user, isLoading, login, logout, setUser }),
[user, isLoading, login, logout],
() => ({ user, isLoading, login, loginWithOtp, logout, setUser }),
[user, isLoading, login, loginWithOtp, logout],
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
+12 -18
View File
@@ -194,6 +194,7 @@ const en = {
'signup.error.match': 'Passwords do not match.',
'signup.error.length': 'Password must be at least 8 characters.',
'signup.error.create': 'Unable to create account.',
'signup.info.verify': 'Account created. Enter the SMS code to verify your number and sign in.',
'forgot.back': 'Back to sign in',
'forgot.title': 'Forgot password',
@@ -207,23 +208,19 @@ const en = {
'forgot.reset': 'Reset password',
'forgot.verifying': 'Verifying...',
'forgot.error.length': 'Password must be at least 8 characters.',
'forgot.error.verify': 'Unable to verify code.',
'forgot.info.partial':
'Phone number verified. Full password reset via SMS is not available yet — please contact support or sign in if you remember your password.',
'forgot.error.verify': 'Unable to reset password. Check the code and try again.',
'forgot.info.success': 'Password updated. You can sign in with your new password.',
'otp.back': 'Back to sign in',
'otp.title': 'One-time login',
'otp.subtitlePhone': 'Verify your mobile number with a one-time SMS code',
'otp.subtitleCode': 'Enter the SMS code and your password',
'otp.subtitlePhone': 'We will send a one-time SMS code to your mobile number',
'otp.subtitleCode': 'Enter the SMS code to sign in',
'otp.sendCode': 'Send SMS code',
'otp.sending': 'Sending...',
'otp.code': 'SMS verification code',
'otp.password': 'Password',
'otp.passwordPlaceholder': 'Your account password',
'otp.signIn': 'Sign in',
'otp.signingIn': 'Signing in...',
'otp.error.password': 'Enter your account password to complete sign-in after SMS verification.',
'otp.error.signIn': 'Unable to sign in with SMS verification.',
'otp.error.signIn': 'Unable to sign in with SMS code.',
'common.close': 'Close',
'common.resendIn': 'Resend code in {seconds}s',
@@ -427,6 +424,7 @@ const fa: Record<MessageKey, string> = {
'signup.error.match': 'رمزهای عبور یکسان نیستند.',
'signup.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.',
'signup.error.create': 'ایجاد حساب ممکن نشد.',
'signup.info.verify': 'حساب ایجاد شد. کد پیامکی را وارد کنید تا شماره تأیید شود و وارد شوید.',
'forgot.back': 'بازگشت به ورود',
'forgot.title': 'فراموشی رمز عبور',
@@ -440,23 +438,19 @@ const fa: Record<MessageKey, string> = {
'forgot.reset': 'بازیابی رمز عبور',
'forgot.verifying': 'در حال تأیید...',
'forgot.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.',
'forgot.error.verify': 'تأیید کد ممکن نشد.',
'forgot.info.partial':
'شماره موبایل تأیید شد. بازیابی کامل رمز با پیامک هنوز در دسترس نیست — با پشتیبانی تماس بگیرید یا اگر رمز را به‌خاطر دارید وارد شوید.',
'forgot.error.verify': 'بازنشانی رمز ممکن نشد. کد را بررسی کنید و دوباره تلاش کنید.',
'forgot.info.success': 'رمز عبور به‌روز شد. می‌توانید با رمز جدید وارد شوید.',
'otp.back': 'بازگشت به ورود',
'otp.title': 'ورود یک‌بارمصرف',
'otp.subtitlePhone': 'شماره موبایل خود را با کد یک‌بارمصرف پیامکی تأیید کنید',
'otp.subtitleCode': 'کد پیامکی و رمز عبور خود را وارد کنید',
'otp.subtitlePhone': 'کد یک‌بارمصرف را با پیامک به شماره موبایل ارسال می‌کنیم',
'otp.subtitleCode': 'کد پیامکی را وارد کنید تا وارد شوید',
'otp.sendCode': 'ارسال کد پیامکی',
'otp.sending': 'در حال ارسال...',
'otp.code': 'کد تأیید پیامکی',
'otp.password': 'رمز عبور',
'otp.passwordPlaceholder': 'رمز عبور حساب شما',
'otp.signIn': 'ورود',
'otp.signingIn': 'در حال ورود...',
'otp.error.password': 'برای تکمیل ورود پس از تأیید پیامکی، رمز حساب خود را وارد کنید.',
'otp.error.signIn': 'ورود با تأیید پیامکی ممکن نشد.',
'otp.error.signIn': 'ورود با کد پیامکی ممکن نشد.',
'common.close': 'بستن',
'common.resendIn': 'ارسال مجدد کد تا {seconds} ثانیه',
+27 -38
View File
@@ -4,7 +4,6 @@ import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft, User } from 'lucide
import {
useAuth,
CUSTOMER_ACCESS_MESSAGE,
VERIFICATION_REQUIRED_MESSAGE,
} from '../context/AuthContext'
import { useTenantBranding } from '../context/TenantBrandingContext'
import { ApiError } from '../lib/api'
@@ -13,8 +12,8 @@ import { getTenantDomain } from '../lib/config'
import {
logout as logoutRequest,
register,
resetPassword,
sendOtp,
verifyOtp,
} from '../services/authService'
import { LanguageSelect } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
@@ -35,7 +34,7 @@ export function LoginPage() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const redirectTo = safeRedirectPath(searchParams.get('redirect'))
const { login } = useAuth()
const { login, loginWithOtp } = useAuth()
const { businessName, logoUrl } = useTenantBranding()
const tenantDomain = getTenantDomain()
const t = useT()
@@ -172,11 +171,24 @@ export function LoginPage() {
}
if (!data.user.cellVerifiedAt) {
setInfo(
`${VERIFICATION_REQUIRED_MESSAGE} Use one-time login with SMS to verify your number.`,
)
switchView('otp')
setPhone(phone)
logoutRequest()
setView('otp')
setSmsStep('phone')
setPassword('')
setConfirmPassword('')
setSmsCode('')
setInfo(t('signup.info.verify'))
try {
const otpResult = await sendOtp(cellNumber)
if (!otpResult.enabled) {
setInfo(otpResult.message)
}
setCodeSent(true)
setSmsStep('code')
startCountdown()
} catch (otpErr) {
handleApiError(otpErr, t('login.error.sendCode'))
}
return
}
@@ -202,9 +214,9 @@ export function LoginPage() {
try {
const cellNumber = toE164CellNumber(phone)
await verifyOtp(cellNumber, smsCode)
setInfo(t('forgot.info.partial'))
setTimeout(() => switchView('login'), 2500)
await resetPassword(cellNumber, smsCode, newPassword)
setInfo(t('forgot.info.success'))
setTimeout(() => switchView('login'), 2000)
} catch (err) {
handleApiError(err, t('forgot.error.verify'))
} finally {
@@ -219,14 +231,7 @@ export function LoginPage() {
try {
const cellNumber = toE164CellNumber(phone)
await verifyOtp(cellNumber, smsCode)
if (!password) {
setError(t('otp.error.password'))
return
}
await login(cellNumber, password)
await loginWithOtp(cellNumber, smsCode)
navigate(redirectTo)
} catch (err) {
handleApiError(err, t('otp.error.signIn'))
@@ -619,6 +624,7 @@ export function LoginPage() {
{error}
</div>
)}
{info && <div className={styles.info}>{info}</div>}
{smsStep === 'phone' ? (
<>
@@ -645,7 +651,7 @@ export function LoginPage() {
onClick={() => void handleSendCode()}
disabled={isSubmitting}
>
{isSubmitting ? t('forgot.sending') : t('forgot.sendCode')}
{isSubmitting ? t('otp.sending') : t('otp.sendCode')}
</button>
</>
) : (
@@ -674,23 +680,6 @@ export function LoginPage() {
</div>
</div>
<div className={styles.field}>
<label htmlFor="otp-password">{t('otp.password')}</label>
<div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} />
<input
id="otp-password"
type={showPassword ? 'text' : 'password'}
placeholder={t('otp.passwordPlaceholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
disabled={isSubmitting}
/>
</div>
</div>
<div className={styles.resendRow}>
{countdown > 0 ? (
<span className={styles.countdown}>{t('common.resendIn', { seconds: countdown })}</span>
@@ -707,7 +696,7 @@ export function LoginPage() {
</div>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting ? t('login.signingIn') : t('login.signIn')}
{isSubmitting ? t('otp.signingIn') : t('otp.signIn')}
</button>
</>
)}
+17
View File
@@ -79,6 +79,23 @@ export async function verifyOtp(cellNumber: string, code: string) {
})
}
export async function loginWithOtp(cellNumber: string, code: string) {
const data = await apiRequest<LoginResponse>('/auth/login-otp', {
method: 'POST',
body: { cellNumber, code },
})
setTokens(data.accessToken, data.refreshToken)
return data
}
export async function resetPassword(cellNumber: string, code: string, newPassword: string) {
return apiRequest<{ message: string }>('/auth/reset-password', {
method: 'POST',
body: { cellNumber, code, newPassword },
})
}
export function logout() {
clearTokens()
clearActiveBusiness()