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')}> <button className={styles.menuBtn} aria-label={t('header.toggleMenu')}>
<Menu size={22} /> <Menu size={22} />
</button> </button>
<h1 className={styles.title}>{t('header.title')}</h1>
</div> </div>
<div className={styles.right}> <div className={styles.right}>
@@ -18,11 +18,23 @@
margin-bottom: 8px; margin-bottom: 8px;
} }
.welcomeTitle {
font-size: 22px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 6px;
}
.pageSubtitle { .pageSubtitle {
font-size: 15px; font-size: 15px;
color: var(--text-secondary); color: var(--text-secondary);
} }
.welcomeSubtitle {
font-size: 13px;
color: var(--text-secondary);
}
.dateBadge { .dateBadge {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -116,6 +128,10 @@
font-size: 22px; font-size: 22px;
} }
.welcomeTitle {
font-size: 20px;
}
.dateBadge { .dateBadge {
align-self: flex-start; align-self: flex-start;
} }
+10 -2
View File
@@ -15,6 +15,7 @@ import { getBusinessDomain } from '../lib/config'
import { import {
fetchCurrentUser, fetchCurrentUser,
login as loginRequest, login as loginRequest,
loginWithOtp as loginWithOtpRequest,
logout as logoutRequest, logout as logoutRequest,
} from '../services/authService' } from '../services/authService'
import { resolveTenantByDomain } from '../services/tenantService' import { resolveTenantByDomain } from '../services/tenantService'
@@ -24,6 +25,7 @@ interface AuthContextValue {
user: AuthUser | null user: AuthUser | null
isLoading: boolean isLoading: boolean
login: (cellNumber: string, password: string) => Promise<void> login: (cellNumber: string, password: string) => Promise<void>
loginWithOtp: (cellNumber: string, code: string) => Promise<void>
logout: () => void logout: () => void
setUser: (user: AuthUser) => void setUser: (user: AuthUser) => void
} }
@@ -130,9 +132,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(data.user) 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( const value = useMemo(
() => ({ user, isLoading, login, logout, setUser }), () => ({ user, isLoading, login, loginWithOtp, logout, setUser }),
[user, isLoading, login, logout], [user, isLoading, login, loginWithOtp, logout],
) )
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider> return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
+10 -18
View File
@@ -1030,23 +1030,19 @@ const en = {
'forgot.reset': 'Reset password', 'forgot.reset': 'Reset password',
'forgot.verifying': 'Verifying...', 'forgot.verifying': 'Verifying...',
'forgot.error.length': 'Password must be at least 8 characters.', 'forgot.error.length': 'Password must be at least 8 characters.',
'forgot.error.verify': 'Unable to verify code.', 'forgot.error.verify': 'Unable to reset password. Check the code and try again.',
'forgot.info.partial': 'forgot.info.success': 'Password updated. You can sign in with your new password.',
'Phone number verified. Full password reset via SMS is not available yet — please contact support or sign in if you remember your password.',
'otp.back': 'Back to sign in', 'otp.back': 'Back to sign in',
'otp.title': 'One-time login', 'otp.title': 'One-time login',
'otp.subtitlePhone': 'Verify your mobile number with a one-time SMS code', 'otp.subtitlePhone': 'We will send a one-time SMS code to your mobile number',
'otp.subtitleCode': 'Enter the SMS code and your password', 'otp.subtitleCode': 'Enter the SMS code to sign in',
'otp.sendCode': 'Send SMS code', 'otp.sendCode': 'Send SMS code',
'otp.sending': 'Sending...', 'otp.sending': 'Sending...',
'otp.code': 'SMS verification code', 'otp.code': 'SMS verification code',
'otp.password': 'Password',
'otp.passwordPlaceholder': 'Your account password',
'otp.signIn': 'Sign in', 'otp.signIn': 'Sign in',
'otp.signingIn': 'Signing 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 code.',
'otp.error.signIn': 'Unable to sign in with SMS verification.',
'common.close': 'Close', 'common.close': 'Close',
'common.resendIn': 'Resend code in {seconds}s', 'common.resendIn': 'Resend code in {seconds}s',
@@ -2087,23 +2083,19 @@ const fa: Record<MessageKey, string> = {
'forgot.reset': 'بازنشانی رمز عبور', 'forgot.reset': 'بازنشانی رمز عبور',
'forgot.verifying': 'در حال تأیید...', 'forgot.verifying': 'در حال تأیید...',
'forgot.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.', 'forgot.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.',
'forgot.error.verify': 'تأیید کد ممکن نشد.', 'forgot.error.verify': 'بازنشانی رمز ممکن نشد. کد را بررسی کنید و دوباره تلاش کنید.',
'forgot.info.partial': 'forgot.info.success': 'رمز عبور به‌روز شد. می‌توانید با رمز جدید وارد شوید.',
'شماره تأیید شد. بازنشانی کامل رمز با پیامک هنوز فعال نیست — با پشتیبانی تماس بگیرید یا اگر رمز را به یاد دارید وارد شوید.',
'otp.back': 'بازگشت به ورود', 'otp.back': 'بازگشت به ورود',
'otp.title': 'ورود یک‌بارمصرف', 'otp.title': 'ورود یک‌بارمصرف',
'otp.subtitlePhone': 'شماره موبایل را با کد پیامکی یک‌بارمصرف تأیید کنید', 'otp.subtitlePhone': 'کد یک‌بارمصرف را با پیامک به شماره موبایل ارسال می‌کنیم',
'otp.subtitleCode': 'کد پیامکی و رمز عبور را وارد کنید', 'otp.subtitleCode': 'کد پیامکی را وارد کنید تا وارد شوید',
'otp.sendCode': 'ارسال کد پیامکی', 'otp.sendCode': 'ارسال کد پیامکی',
'otp.sending': 'در حال ارسال...', 'otp.sending': 'در حال ارسال...',
'otp.code': 'کد تأیید پیامکی', 'otp.code': 'کد تأیید پیامکی',
'otp.password': 'رمز عبور',
'otp.passwordPlaceholder': 'رمز عبور حساب',
'otp.signIn': 'ورود', 'otp.signIn': 'ورود',
'otp.signingIn': 'در حال ورود...', 'otp.signingIn': 'در حال ورود...',
'otp.error.password': 'برای تکمیل ورود پس از تأیید پیامک، رمز عبور حساب را وارد کنید.', 'otp.error.signIn': 'ورود با کد پیامکی ممکن نشد.',
'otp.error.signIn': 'ورود با تأیید پیامکی ممکن نشد.',
'common.close': 'بستن', 'common.close': 'بستن',
'common.resendIn': 'ارسال مجدد کد تا {seconds} ثانیه', 'common.resendIn': 'ارسال مجدد کد تا {seconds} ثانیه',
+2 -2
View File
@@ -149,8 +149,8 @@ export function HomePage() {
<main className={styles.content}> <main className={styles.content}>
<div className={styles.pageHeader}> <div className={styles.pageHeader}>
<div> <div>
<h2 className={styles.pageTitle}>{t('home.welcome', { name: firstName })}</h2> <h2 className={styles.welcomeTitle}>{t('home.welcome', { name: firstName })}</h2>
<p className={styles.pageSubtitle}>{t('home.subtitle')}</p> <p className={styles.welcomeSubtitle}>{t('home.subtitle')}</p>
</div> </div>
<div className={styles.dateBadge}> <div className={styles.dateBadge}>
+7 -30
View File
@@ -11,8 +11,8 @@ import { setActiveBusiness } from '../lib/businessContext'
import { import {
logout as logoutRequest, logout as logoutRequest,
register, register,
resetPassword,
sendOtp, sendOtp,
verifyOtp,
} from '../services/authService' } from '../services/authService'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import styles from './LoginPage.module.css' import styles from './LoginPage.module.css'
@@ -22,7 +22,7 @@ type SmsStep = 'phone' | 'code'
export function LoginPage() { export function LoginPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { login } = useAuth() const { login, loginWithOtp } = useAuth()
const { businessName, logoUrl } = useTenantBranding() const { businessName, logoUrl } = useTenantBranding()
const businessDomain = getBusinessDomain() const businessDomain = getBusinessDomain()
const t = useT() const t = useT()
@@ -184,9 +184,9 @@ export function LoginPage() {
try { try {
const cellNumber = toE164CellNumber(phone) const cellNumber = toE164CellNumber(phone)
await verifyOtp(cellNumber, smsCode) await resetPassword(cellNumber, smsCode, newPassword)
setInfo(t('forgot.info.partial')) setInfo(t('forgot.info.success'))
setTimeout(() => switchView('login'), 2500) setTimeout(() => switchView('login'), 2000)
} catch (err) { } catch (err) {
handleApiError(err, t('forgot.error.verify')) handleApiError(err, t('forgot.error.verify'))
} finally { } finally {
@@ -201,14 +201,7 @@ export function LoginPage() {
try { try {
const cellNumber = toE164CellNumber(phone) const cellNumber = toE164CellNumber(phone)
await verifyOtp(cellNumber, smsCode) await loginWithOtp(cellNumber, smsCode)
if (!password) {
setError(t('otp.error.password'))
return
}
await login(cellNumber, password)
navigate('/') navigate('/')
} catch (err) { } catch (err) {
handleApiError(err, t('otp.error.signIn')) handleApiError(err, t('otp.error.signIn'))
@@ -600,6 +593,7 @@ export function LoginPage() {
{error} {error}
</div> </div>
)} )}
{info && <div className={styles.info}>{info}</div>}
{smsStep === 'phone' ? ( {smsStep === 'phone' ? (
<> <>
@@ -653,23 +647,6 @@ export function LoginPage() {
</div> </div>
</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}> <div className={styles.resendRow}>
{countdown > 0 ? ( {countdown > 0 ? (
<span className={styles.countdown}> <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) { export async function changePassword(currentPassword: string, newPassword: string) {
return apiRequest<{ message: string }>('/auth/change-password', { return apiRequest<{ message: string }>('/auth/change-password', {
method: 'POST', method: 'POST',
+10 -2
View File
@@ -13,6 +13,7 @@ import { getTenantDomain } from '../lib/config'
import { import {
fetchCurrentUser, fetchCurrentUser,
login as loginRequest, login as loginRequest,
loginWithOtp as loginWithOtpRequest,
logout as logoutRequest, logout as logoutRequest,
} from '../services/authService' } from '../services/authService'
import { resolveTenantByDomain } from '../services/tenantService' import { resolveTenantByDomain } from '../services/tenantService'
@@ -22,6 +23,7 @@ interface AuthContextValue {
user: AuthUser | null user: AuthUser | null
isLoading: boolean isLoading: boolean
login: (cellNumber: string, password: string) => Promise<void> login: (cellNumber: string, password: string) => Promise<void>
loginWithOtp: (cellNumber: string, code: string) => Promise<void>
logout: () => void logout: () => void
setUser: (user: AuthUser) => void setUser: (user: AuthUser) => void
} }
@@ -124,9 +126,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(data.user) 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( const value = useMemo(
() => ({ user, isLoading, login, logout, setUser }), () => ({ user, isLoading, login, loginWithOtp, logout, setUser }),
[user, isLoading, login, logout], [user, isLoading, login, loginWithOtp, logout],
) )
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider> 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.match': 'Passwords do not match.',
'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.info.verify': 'Account created. Enter the SMS code to verify your number and sign in.',
'forgot.back': 'Back to sign in', 'forgot.back': 'Back to sign in',
'forgot.title': 'Forgot password', 'forgot.title': 'Forgot password',
@@ -207,23 +208,19 @@ const en = {
'forgot.reset': 'Reset password', 'forgot.reset': 'Reset password',
'forgot.verifying': 'Verifying...', 'forgot.verifying': 'Verifying...',
'forgot.error.length': 'Password must be at least 8 characters.', 'forgot.error.length': 'Password must be at least 8 characters.',
'forgot.error.verify': 'Unable to verify code.', 'forgot.error.verify': 'Unable to reset password. Check the code and try again.',
'forgot.info.partial': 'forgot.info.success': 'Password updated. You can sign in with your new password.',
'Phone number verified. Full password reset via SMS is not available yet — please contact support or sign in if you remember your password.',
'otp.back': 'Back to sign in', 'otp.back': 'Back to sign in',
'otp.title': 'One-time login', 'otp.title': 'One-time login',
'otp.subtitlePhone': 'Verify your mobile number with a one-time SMS code', 'otp.subtitlePhone': 'We will send a one-time SMS code to your mobile number',
'otp.subtitleCode': 'Enter the SMS code and your password', 'otp.subtitleCode': 'Enter the SMS code to sign in',
'otp.sendCode': 'Send SMS code', 'otp.sendCode': 'Send SMS code',
'otp.sending': 'Sending...', 'otp.sending': 'Sending...',
'otp.code': 'SMS verification code', 'otp.code': 'SMS verification code',
'otp.password': 'Password',
'otp.passwordPlaceholder': 'Your account password',
'otp.signIn': 'Sign in', 'otp.signIn': 'Sign in',
'otp.signingIn': 'Signing 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 code.',
'otp.error.signIn': 'Unable to sign in with SMS verification.',
'common.close': 'Close', 'common.close': 'Close',
'common.resendIn': 'Resend code in {seconds}s', 'common.resendIn': 'Resend code in {seconds}s',
@@ -427,6 +424,7 @@ const fa: Record<MessageKey, string> = {
'signup.error.match': 'رمزهای عبور یکسان نیستند.', 'signup.error.match': 'رمزهای عبور یکسان نیستند.',
'signup.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.', 'signup.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.',
'signup.error.create': 'ایجاد حساب ممکن نشد.', 'signup.error.create': 'ایجاد حساب ممکن نشد.',
'signup.info.verify': 'حساب ایجاد شد. کد پیامکی را وارد کنید تا شماره تأیید شود و وارد شوید.',
'forgot.back': 'بازگشت به ورود', 'forgot.back': 'بازگشت به ورود',
'forgot.title': 'فراموشی رمز عبور', 'forgot.title': 'فراموشی رمز عبور',
@@ -440,23 +438,19 @@ const fa: Record<MessageKey, string> = {
'forgot.reset': 'بازیابی رمز عبور', 'forgot.reset': 'بازیابی رمز عبور',
'forgot.verifying': 'در حال تأیید...', 'forgot.verifying': 'در حال تأیید...',
'forgot.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.', 'forgot.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.',
'forgot.error.verify': 'تأیید کد ممکن نشد.', 'forgot.error.verify': 'بازنشانی رمز ممکن نشد. کد را بررسی کنید و دوباره تلاش کنید.',
'forgot.info.partial': 'forgot.info.success': 'رمز عبور به‌روز شد. می‌توانید با رمز جدید وارد شوید.',
'شماره موبایل تأیید شد. بازیابی کامل رمز با پیامک هنوز در دسترس نیست — با پشتیبانی تماس بگیرید یا اگر رمز را به‌خاطر دارید وارد شوید.',
'otp.back': 'بازگشت به ورود', 'otp.back': 'بازگشت به ورود',
'otp.title': 'ورود یک‌بارمصرف', 'otp.title': 'ورود یک‌بارمصرف',
'otp.subtitlePhone': 'شماره موبایل خود را با کد یک‌بارمصرف پیامکی تأیید کنید', 'otp.subtitlePhone': 'کد یک‌بارمصرف را با پیامک به شماره موبایل ارسال می‌کنیم',
'otp.subtitleCode': 'کد پیامکی و رمز عبور خود را وارد کنید', 'otp.subtitleCode': 'کد پیامکی را وارد کنید تا وارد شوید',
'otp.sendCode': 'ارسال کد پیامکی', 'otp.sendCode': 'ارسال کد پیامکی',
'otp.sending': 'در حال ارسال...', 'otp.sending': 'در حال ارسال...',
'otp.code': 'کد تأیید پیامکی', 'otp.code': 'کد تأیید پیامکی',
'otp.password': 'رمز عبور',
'otp.passwordPlaceholder': 'رمز عبور حساب شما',
'otp.signIn': 'ورود', 'otp.signIn': 'ورود',
'otp.signingIn': 'در حال ورود...', 'otp.signingIn': 'در حال ورود...',
'otp.error.password': 'برای تکمیل ورود پس از تأیید پیامکی، رمز حساب خود را وارد کنید.', 'otp.error.signIn': 'ورود با کد پیامکی ممکن نشد.',
'otp.error.signIn': 'ورود با تأیید پیامکی ممکن نشد.',
'common.close': 'بستن', 'common.close': 'بستن',
'common.resendIn': 'ارسال مجدد کد تا {seconds} ثانیه', 'common.resendIn': 'ارسال مجدد کد تا {seconds} ثانیه',
+27 -38
View File
@@ -4,7 +4,6 @@ import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft, User } from 'lucide
import { import {
useAuth, useAuth,
CUSTOMER_ACCESS_MESSAGE, CUSTOMER_ACCESS_MESSAGE,
VERIFICATION_REQUIRED_MESSAGE,
} from '../context/AuthContext' } from '../context/AuthContext'
import { useTenantBranding } from '../context/TenantBrandingContext' import { useTenantBranding } from '../context/TenantBrandingContext'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
@@ -13,8 +12,8 @@ import { getTenantDomain } from '../lib/config'
import { import {
logout as logoutRequest, logout as logoutRequest,
register, register,
resetPassword,
sendOtp, sendOtp,
verifyOtp,
} from '../services/authService' } from '../services/authService'
import { LanguageSelect } from '@meshkee/dashboard-ui' import { LanguageSelect } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
@@ -35,7 +34,7 @@ export function LoginPage() {
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const redirectTo = safeRedirectPath(searchParams.get('redirect')) const redirectTo = safeRedirectPath(searchParams.get('redirect'))
const { login } = useAuth() const { login, loginWithOtp } = useAuth()
const { businessName, logoUrl } = useTenantBranding() const { businessName, logoUrl } = useTenantBranding()
const tenantDomain = getTenantDomain() const tenantDomain = getTenantDomain()
const t = useT() const t = useT()
@@ -172,11 +171,24 @@ export function LoginPage() {
} }
if (!data.user.cellVerifiedAt) { if (!data.user.cellVerifiedAt) {
setInfo( logoutRequest()
`${VERIFICATION_REQUIRED_MESSAGE} Use one-time login with SMS to verify your number.`, setView('otp')
) setSmsStep('phone')
switchView('otp') setPassword('')
setPhone(phone) 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 return
} }
@@ -202,9 +214,9 @@ export function LoginPage() {
try { try {
const cellNumber = toE164CellNumber(phone) const cellNumber = toE164CellNumber(phone)
await verifyOtp(cellNumber, smsCode) await resetPassword(cellNumber, smsCode, newPassword)
setInfo(t('forgot.info.partial')) setInfo(t('forgot.info.success'))
setTimeout(() => switchView('login'), 2500) setTimeout(() => switchView('login'), 2000)
} catch (err) { } catch (err) {
handleApiError(err, t('forgot.error.verify')) handleApiError(err, t('forgot.error.verify'))
} finally { } finally {
@@ -219,14 +231,7 @@ export function LoginPage() {
try { try {
const cellNumber = toE164CellNumber(phone) const cellNumber = toE164CellNumber(phone)
await verifyOtp(cellNumber, smsCode) await loginWithOtp(cellNumber, smsCode)
if (!password) {
setError(t('otp.error.password'))
return
}
await login(cellNumber, password)
navigate(redirectTo) navigate(redirectTo)
} catch (err) { } catch (err) {
handleApiError(err, t('otp.error.signIn')) handleApiError(err, t('otp.error.signIn'))
@@ -619,6 +624,7 @@ export function LoginPage() {
{error} {error}
</div> </div>
)} )}
{info && <div className={styles.info}>{info}</div>}
{smsStep === 'phone' ? ( {smsStep === 'phone' ? (
<> <>
@@ -645,7 +651,7 @@ export function LoginPage() {
onClick={() => void handleSendCode()} onClick={() => void handleSendCode()}
disabled={isSubmitting} disabled={isSubmitting}
> >
{isSubmitting ? t('forgot.sending') : t('forgot.sendCode')} {isSubmitting ? t('otp.sending') : t('otp.sendCode')}
</button> </button>
</> </>
) : ( ) : (
@@ -674,23 +680,6 @@ export function LoginPage() {
</div> </div>
</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}> <div className={styles.resendRow}>
{countdown > 0 ? ( {countdown > 0 ? (
<span className={styles.countdown}>{t('common.resendIn', { seconds: countdown })}</span> <span className={styles.countdown}>{t('common.resendIn', { seconds: countdown })}</span>
@@ -707,7 +696,7 @@ export function LoginPage() {
</div> </div>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}> <button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting ? t('login.signingIn') : t('login.signIn')} {isSubmitting ? t('otp.signingIn') : t('otp.signIn')}
</button> </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() { export function logout() {
clearTokens() clearTokens()
clearActiveBusiness() clearActiveBusiness()
+20 -3
View File
@@ -3,7 +3,7 @@
> **For AI agents:** Read this file at the start of a new chat before making changes. > **For AI agents:** Read this file at the start of a new chat before making changes.
> Update this document when a major feature is completed or architecture changes. > Update this document when a major feature is completed or architecture changes.
Last updated: August 1, 2026 Last updated: August 5, 2026
--- ---
@@ -153,7 +153,7 @@ Add to `/etc/hosts` (one line per tenant):
| Path | Page | | Path | Page |
|------|------| |------|------|
| `/login` | Login | | `/login` | Login (password, register, forgot-password SMS, one-time OTP login) |
| `/checkout` | Shopping cart checkout (standalone layout — login → cart → delivery → payment) | | `/checkout` | Shopping cart checkout (standalone layout — login → cart → delivery → payment) |
| `/checkout/login` | Checkout sign-in step | | `/checkout/login` | Checkout sign-in step |
| `/checkout/cart` | Cart review | | `/checkout/cart` | Cart review |
@@ -170,7 +170,7 @@ Add to `/etc/hosts` (one line per tenant):
| Path | Page | Backend connected? | | Path | Page | Backend connected? |
|------|------|-------------------| |------|------|-------------------|
| `/login` | Login | Yes | | `/login` | Login (password, forgot-password SMS, one-time OTP login) | Yes |
| `/` | Home | Partial | | `/` | Home | Partial |
| `/products` | Products hub | — | | `/products` | Products hub | — |
| `/products/categories` | Category tree + variations | Yes | | `/products/categories` | Category tree + variations | Yes |
@@ -209,6 +209,23 @@ Add to `/etc/hosts` (one line per tenant):
--- ---
## Auth (login pages)
Business and customer login pages share the same SMS-backed flows (super-admin stays password-only).
| Flow | API | Notes |
|------|-----|--------|
| Password login | `POST /auth/login` | Rejects unverified cell when SMS is enabled |
| Register | `POST /auth/register` | Customer by website domain; with SMS on, customer goes to OTP step |
| Send OTP | `POST /auth/send-otp` | Redis 5-min code via Gama |
| One-time login | `POST /auth/login-otp` | Code only → tokens + marks cell verified |
| Forgot password | `POST /auth/reset-password` | Code + new password |
| Verify only | `POST /auth/verify-otp` | Marks verified; no tokens |
Auth helpers: `apps/*/src/services/authService.ts` (`login`, `loginWithOtp`, `resetPassword`, `sendOtp`, `register`). `AuthContext` exposes `login` + `loginWithOtp`.
---
## API surface (business-scoped) ## API surface (business-scoped)
All routes require JWT + business permission. `businessId` comes from tenant context after login. All routes require JWT + business permission. `businessId` comes from tenant context after login.