mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
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:
co-authored by
Cursor
parent
d8c9c0b017
commit
c33cab40fe
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} ثانیه',
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} ثانیه',
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user