diff --git a/apps/business/src/components/Header.tsx b/apps/business/src/components/Header.tsx index 56e750f..77ee411 100644 --- a/apps/business/src/components/Header.tsx +++ b/apps/business/src/components/Header.tsx @@ -87,7 +87,6 @@ export function Header() { -

{t('header.title')}

diff --git a/apps/business/src/components/PageContent.module.css b/apps/business/src/components/PageContent.module.css index eade76d..01d6a9d 100644 --- a/apps/business/src/components/PageContent.module.css +++ b/apps/business/src/components/PageContent.module.css @@ -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; } diff --git a/apps/business/src/context/AuthContext.tsx b/apps/business/src/context/AuthContext.tsx index ce549f0..6e48d60 100644 --- a/apps/business/src/context/AuthContext.tsx +++ b/apps/business/src/context/AuthContext.tsx @@ -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 + loginWithOtp: (cellNumber: string, code: string) => Promise 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 {children} diff --git a/apps/business/src/i18n/messages.ts b/apps/business/src/i18n/messages.ts index 18c3296..88ae180 100644 --- a/apps/business/src/i18n/messages.ts +++ b/apps/business/src/i18n/messages.ts @@ -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 = { '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} ثانیه', diff --git a/apps/business/src/pages/HomePage.tsx b/apps/business/src/pages/HomePage.tsx index 7ef1974..4d3c443 100644 --- a/apps/business/src/pages/HomePage.tsx +++ b/apps/business/src/pages/HomePage.tsx @@ -149,8 +149,8 @@ export function HomePage() {
-

{t('home.welcome', { name: firstName })}

-

{t('home.subtitle')}

+

{t('home.welcome', { name: firstName })}

+

{t('home.subtitle')}

diff --git a/apps/business/src/pages/LoginPage.tsx b/apps/business/src/pages/LoginPage.tsx index a57aea0..cefe433 100644 --- a/apps/business/src/pages/LoginPage.tsx +++ b/apps/business/src/pages/LoginPage.tsx @@ -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}
)} + {info &&
{info}
} {smsStep === 'phone' ? ( <> @@ -653,23 +647,6 @@ export function LoginPage() {
-
- -
- - setPassword(e.target.value)} - required - minLength={8} - disabled={isSubmitting} - /> -
-
-
{countdown > 0 ? ( diff --git a/apps/business/src/services/authService.ts b/apps/business/src/services/authService.ts index 1b2a2f2..6f6cf6e 100644 --- a/apps/business/src/services/authService.ts +++ b/apps/business/src/services/authService.ts @@ -54,6 +54,23 @@ export async function verifyOtp(cellNumber: string, code: string) { }) } +export async function loginWithOtp(cellNumber: string, code: string) { + const data = await apiRequest('/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', diff --git a/apps/customer/src/context/AuthContext.tsx b/apps/customer/src/context/AuthContext.tsx index cc03c71..49b6e20 100644 --- a/apps/customer/src/context/AuthContext.tsx +++ b/apps/customer/src/context/AuthContext.tsx @@ -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 + loginWithOtp: (cellNumber: string, code: string) => Promise 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 {children} diff --git a/apps/customer/src/i18n/messages.ts b/apps/customer/src/i18n/messages.ts index 5a1bf4a..5ec2151 100644 --- a/apps/customer/src/i18n/messages.ts +++ b/apps/customer/src/i18n/messages.ts @@ -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 = { 'signup.error.match': 'رمزهای عبور یکسان نیستند.', 'signup.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.', 'signup.error.create': 'ایجاد حساب ممکن نشد.', + 'signup.info.verify': 'حساب ایجاد شد. کد پیامکی را وارد کنید تا شماره تأیید شود و وارد شوید.', 'forgot.back': 'بازگشت به ورود', 'forgot.title': 'فراموشی رمز عبور', @@ -440,23 +438,19 @@ const fa: Record = { '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} ثانیه', diff --git a/apps/customer/src/pages/LoginPage.tsx b/apps/customer/src/pages/LoginPage.tsx index f6a7bf2..1a4c9d0 100644 --- a/apps/customer/src/pages/LoginPage.tsx +++ b/apps/customer/src/pages/LoginPage.tsx @@ -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}
)} + {info &&
{info}
} {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')} ) : ( @@ -674,23 +680,6 @@ export function LoginPage() { -
- -
- - setPassword(e.target.value)} - required - minLength={8} - disabled={isSubmitting} - /> -
-
-
{countdown > 0 ? ( {t('common.resendIn', { seconds: countdown })} @@ -707,7 +696,7 @@ export function LoginPage() {
)} diff --git a/apps/customer/src/services/authService.ts b/apps/customer/src/services/authService.ts index 36c846e..92545ae 100644 --- a/apps/customer/src/services/authService.ts +++ b/apps/customer/src/services/authService.ts @@ -79,6 +79,23 @@ export async function verifyOtp(cellNumber: string, code: string) { }) } +export async function loginWithOtp(cellNumber: string, code: string) { + const data = await apiRequest('/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() diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 03f00e2..e7e58f0 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -3,7 +3,7 @@ > **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. -Last updated: August 1, 2026 +Last updated: August 5, 2026 --- @@ -153,7 +153,7 @@ Add to `/etc/hosts` (one line per tenant): | 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/login` | Checkout sign-in step | | `/checkout/cart` | Cart review | @@ -170,7 +170,7 @@ Add to `/etc/hosts` (one line per tenant): | Path | Page | Backend connected? | |------|------|-------------------| -| `/login` | Login | Yes | +| `/login` | Login (password, forgot-password SMS, one-time OTP login) | Yes | | `/` | Home | Partial | | `/products` | Products hub | — | | `/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) All routes require JWT + business permission. `businessId` comes from tenant context after login.