Add OTP-first customer signup and product catalog PDF field.
Registration collects name and phone, verifies SMS, then asks for a password only for new Meshkee accounts. Product add/edit supports an optional catalog PDF. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
e2950fa20e
commit
6d85a217e1
@@ -27,6 +27,8 @@ interface AuthContextValue {
|
||||
cellNumber: string,
|
||||
code: string,
|
||||
) => Promise<{ passwordUpdated?: boolean }>
|
||||
/** Apply an already-authenticated user (tokens must already be stored). */
|
||||
establishSession: (user: AuthUser) => Promise<void>
|
||||
logout: () => void
|
||||
setUser: (user: AuthUser) => void
|
||||
}
|
||||
@@ -136,9 +138,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
return { passwordUpdated: data.passwordUpdated }
|
||||
}, [])
|
||||
|
||||
const establishSession = useCallback(async (nextUser: AuthUser) => {
|
||||
await assertCustomerAccess(nextUser)
|
||||
setUser(nextUser)
|
||||
}, [])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ user, isLoading, login, loginWithOtp, logout, setUser }),
|
||||
[user, isLoading, login, loginWithOtp, logout],
|
||||
() => ({ user, isLoading, login, loginWithOtp, establishSession, logout, setUser }),
|
||||
[user, isLoading, login, loginWithOtp, establishSession, logout],
|
||||
)
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
|
||||
@@ -330,31 +330,42 @@ const en = {
|
||||
|
||||
'signup.title': 'Create account',
|
||||
'signup.subtitle': 'Register as a customer of {domain}',
|
||||
'signup.subtitleDetails': 'Enter your name and mobile number',
|
||||
'signup.subtitleOtp': 'Enter the SMS verification code',
|
||||
'signup.subtitlePassword': 'Choose a password for your Meshkee account',
|
||||
'signup.subtitleExisting': 'You already have a Meshkee account',
|
||||
'signup.firstName': 'First name',
|
||||
'signup.lastName': 'Last name',
|
||||
'signup.passwordPlaceholder': 'Choose a password',
|
||||
'signup.confirm': 'Confirm password',
|
||||
'signup.confirmPlaceholder': 'Repeat your password',
|
||||
'signup.continue': 'Continue',
|
||||
'signup.sendingCode': 'Sending code...',
|
||||
'signup.verifyCode': 'Verify code',
|
||||
'signup.verifying': 'Verifying...',
|
||||
'signup.create': 'Create account',
|
||||
'signup.creating': 'Creating account...',
|
||||
'signup.joinContinue': 'Continue and join',
|
||||
'signup.back': 'Back',
|
||||
'signup.hasAccount': 'Already have an account?',
|
||||
'signup.signIn': 'Sign in',
|
||||
'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.error.cellExists':
|
||||
'This mobile number already has an account in Meshkee Unified Management System (__MESHKEE_APP__) on {sites}. Your profile stays the same; the password you enter here will replace your Meshkee password. Continue to verify by SMS and join this website too.',
|
||||
'signup.error.cellExistsGeneric':
|
||||
'This mobile number already has an account in Meshkee Unified Management System (__MESHKEE_APP__) on another website. Your profile stays the same; the password you enter here will replace your Meshkee password. Continue to verify by SMS and join this website too.',
|
||||
'signup.info.verify': 'Account created. Enter the SMS code to verify your number and sign in.',
|
||||
'signup.info.verifyExisting':
|
||||
'Enter the SMS code to join this website. Your Meshkee password and profile stay the same.',
|
||||
'signup.info.verifyExistingPasswordUpdated':
|
||||
'Enter the SMS code to finish joining. Your Meshkee password will then be updated to the one you just entered.',
|
||||
'signup.error.alreadyOnSite':
|
||||
'This mobile number is already registered on this website. Please sign in instead.',
|
||||
'signup.error.sessionExpired': 'Registration session expired. Please start again.',
|
||||
'signup.info.verify': 'Enter the SMS code we sent to verify your number.',
|
||||
'signup.info.choosePassword': 'Phone verified. Choose a password to finish creating your account.',
|
||||
'signup.info.existingAccount':
|
||||
'This mobile number already has an account in Meshkee Unified Management System (__MESHKEE_APP__) on {sites}. Your password stays the same as on those websites.',
|
||||
'signup.info.existingAccountGeneric':
|
||||
'This mobile number already has an account in Meshkee Unified Management System (__MESHKEE_APP__). Your password stays the same as on your other Meshkee websites.',
|
||||
'signup.info.existingPasswordHint':
|
||||
'Continue to join this website with that same password. If you forgot it, use Forgot password.',
|
||||
'signup.toast.linkedExisting': 'Joined this website with your existing Meshkee account.',
|
||||
'signup.toast.passwordUpdated':
|
||||
'Your Meshkee password was updated to the one you entered on this website.',
|
||||
'signup.cellExists.title': 'Existing Meshkee account',
|
||||
'signup.cellExists.admit': 'I understand, continue',
|
||||
|
||||
'forgot.back': 'Back to sign in',
|
||||
'forgot.title': 'Forgot password',
|
||||
@@ -741,31 +752,42 @@ const fa: Record<MessageKey, string> = {
|
||||
|
||||
'signup.title': 'ایجاد حساب',
|
||||
'signup.subtitle': 'بهعنوان مشتری {domain} ثبتنام کنید',
|
||||
'signup.subtitleDetails': 'نام و شماره موبایل خود را وارد کنید',
|
||||
'signup.subtitleOtp': 'کد تأیید پیامکی را وارد کنید',
|
||||
'signup.subtitlePassword': 'برای حساب مشکی خود رمز عبور انتخاب کنید',
|
||||
'signup.subtitleExisting': 'قبلاً در مشکی حساب دارید',
|
||||
'signup.firstName': 'نام',
|
||||
'signup.lastName': 'نام خانوادگی',
|
||||
'signup.passwordPlaceholder': 'یک رمز عبور انتخاب کنید',
|
||||
'signup.confirm': 'تأیید رمز عبور',
|
||||
'signup.confirmPlaceholder': 'رمز عبور را تکرار کنید',
|
||||
'signup.continue': 'ادامه',
|
||||
'signup.sendingCode': 'در حال ارسال کد...',
|
||||
'signup.verifyCode': 'تأیید کد',
|
||||
'signup.verifying': 'در حال تأیید...',
|
||||
'signup.create': 'ایجاد حساب',
|
||||
'signup.creating': 'در حال ایجاد حساب...',
|
||||
'signup.joinContinue': 'ادامه و عضویت',
|
||||
'signup.back': 'بازگشت',
|
||||
'signup.hasAccount': 'قبلاً حساب دارید؟',
|
||||
'signup.signIn': 'ورود',
|
||||
'signup.error.match': 'رمزهای عبور یکسان نیستند.',
|
||||
'signup.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.',
|
||||
'signup.error.create': 'ایجاد حساب ممکن نشد.',
|
||||
'signup.error.cellExists':
|
||||
'این شماره موبایل قبلاً در سیستم مدیریت یکپارچه ی مشکی (__MESHKEE_APP__) حساب دارد ({sites}). اطلاعات شما همان میماند؛ رمزی که اینجا وارد کردید جایگزین رمز مشکی میشود. با ادامه، تأیید پیامکی را انجام دهید تا وارد این وبسایت هم شوید.',
|
||||
'signup.error.cellExistsGeneric':
|
||||
'این شماره موبایل قبلاً در سیستم مدیریت یکپارچه ی مشکی (__MESHKEE_APP__) (وبسایت دیگری) حساب دارد. اطلاعات شما همان میماند؛ رمزی که اینجا وارد کردید جایگزین رمز مشکی میشود. با ادامه، تأیید پیامکی را انجام دهید تا وارد این وبسایت هم شوید.',
|
||||
'signup.info.verify': 'حساب ایجاد شد. کد پیامکی را وارد کنید تا شماره تأیید شود و وارد شوید.',
|
||||
'signup.info.verifyExisting':
|
||||
'کد پیامکی را وارد کنید تا به این وبسایت بپیوندید. رمز عبور و اطلاعات مشکی شما همان میماند.',
|
||||
'signup.info.verifyExistingPasswordUpdated':
|
||||
'کد پیامکی را وارد کنید تا عضویت تمام شود. پس از تأیید، رمز مشکی شما به رمزی که همین الان وارد کردید بهروز میشود.',
|
||||
'signup.error.alreadyOnSite':
|
||||
'این شماره موبایل قبلاً در این وبسایت ثبت شده است. لطفاً وارد شوید.',
|
||||
'signup.error.sessionExpired': 'نشست ثبتنام منقضی شد. لطفاً دوباره شروع کنید.',
|
||||
'signup.info.verify': 'کد پیامکی ارسالشده را وارد کنید تا شماره تأیید شود.',
|
||||
'signup.info.choosePassword': 'شماره تأیید شد. برای تکمیل ایجاد حساب، رمز عبور انتخاب کنید.',
|
||||
'signup.info.existingAccount':
|
||||
'این شماره موبایل قبلاً در سیستم مدیریت یکپارچه ی مشکی (__MESHKEE_APP__) حساب دارد ({sites}). رمز عبور شما همان رمز وبسایتهای دیگر مشکی است.',
|
||||
'signup.info.existingAccountGeneric':
|
||||
'این شماره موبایل قبلاً در سیستم مدیریت یکپارچه ی مشکی (__MESHKEE_APP__) حساب دارد. رمز عبور شما همان رمز وبسایتهای دیگر مشکی است.',
|
||||
'signup.info.existingPasswordHint':
|
||||
'با ادامه، با همان رمز به این وبسایت میپیوندید. اگر رمز را فراموش کردهاید، از فراموشی رمز عبور استفاده کنید.',
|
||||
'signup.toast.linkedExisting': 'با حساب موجود مشکی به این وبسایت پیوستید.',
|
||||
'signup.toast.passwordUpdated':
|
||||
'رمز مشکی شما به رمزی که در این وبسایت وارد کردید بهروز شد.',
|
||||
'signup.cellExists.title': 'حساب موجود در مشکی',
|
||||
'signup.cellExists.admit': 'متوجه شدم، ادامه',
|
||||
|
||||
'forgot.back': 'بازگشت به ورود',
|
||||
'forgot.title': 'فراموشی رمز عبور',
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft, User, X } from 'lucide-react'
|
||||
import {
|
||||
useAuth,
|
||||
CUSTOMER_ACCESS_MESSAGE,
|
||||
} from '../context/AuthContext'
|
||||
import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft, User } from 'lucide-react'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useTenantBranding } from '../context/TenantBrandingContext'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { toE164CellNumber } from '../lib/cellNumber'
|
||||
import { getTenantDomain } from '../lib/config'
|
||||
import {
|
||||
logout as logoutRequest,
|
||||
register,
|
||||
completeSignup,
|
||||
resetPassword,
|
||||
sendOtp,
|
||||
startSignup,
|
||||
verifySignupOtp,
|
||||
} from '../services/authService'
|
||||
import { LanguageSelect, PasswordStrengthMeter, isPasswordStrong, useLocale, useToast } from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
@@ -24,20 +21,11 @@ import styles from './LoginPage.module.css'
|
||||
|
||||
type AuthView = 'login' | 'signup' | 'forgot' | 'otp'
|
||||
type SmsStep = 'phone' | 'code'
|
||||
type SignupStep = 'details' | 'otp' | 'password' | 'existing'
|
||||
|
||||
const MESHKEE_APP_MARKER = '__MESHKEE_APP__'
|
||||
const MESHKEE_SITE_URL = 'https://meshkee.com'
|
||||
|
||||
function parseCellExistsSites(message: string): string | null {
|
||||
if (message.startsWith('CELL_EXISTS_OTHER_SITE:')) {
|
||||
return message.slice('CELL_EXISTS_OTHER_SITE:'.length).trim()
|
||||
}
|
||||
if (message.includes('Cell number exists on another account')) {
|
||||
return ''
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function renderWithMeshkeeLink(message: string, linkClassName: string): ReactNode {
|
||||
const idx = message.indexOf(MESHKEE_APP_MARKER)
|
||||
if (idx === -1) return message
|
||||
@@ -61,15 +49,16 @@ export function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const redirectTo = safeRedirectPath(searchParams.get('redirect'))
|
||||
const { login, loginWithOtp } = useAuth()
|
||||
const { login, loginWithOtp, establishSession } = useAuth()
|
||||
const { businessName, businessNameEn, logoUrl } = useTenantBranding()
|
||||
const tenantDomain = getTenantDomain()
|
||||
const t = useT()
|
||||
const { showToast } = useToast()
|
||||
const { locale, dir } = useLocale()
|
||||
const { locale } = useLocale()
|
||||
|
||||
const [view, setView] = useState<AuthView>('login')
|
||||
const [smsStep, setSmsStep] = useState<SmsStep>('phone')
|
||||
const [signupStep, setSignupStep] = useState<SignupStep>('details')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [phone, setPhone] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
@@ -84,8 +73,8 @@ export function LoginPage() {
|
||||
const [error, setError] = useState('')
|
||||
const [info, setInfo] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
/** `null` closed; `''` generic notice; otherwise site names */
|
||||
const [cellExistsSites, setCellExistsSites] = useState<string | null>(null)
|
||||
const [registrationToken, setRegistrationToken] = useState('')
|
||||
const [existingSites, setExistingSites] = useState<string[]>([])
|
||||
|
||||
function clearMessages() {
|
||||
setError('')
|
||||
@@ -102,9 +91,11 @@ export function LoginPage() {
|
||||
setNewPassword('')
|
||||
setConfirmNewPassword('')
|
||||
setSmsStep('phone')
|
||||
setSignupStep('details')
|
||||
setCodeSent(false)
|
||||
setShowPassword(false)
|
||||
setCellExistsSites(null)
|
||||
setRegistrationToken('')
|
||||
setExistingSites([])
|
||||
clearMessages()
|
||||
}
|
||||
|
||||
@@ -212,116 +203,196 @@ export function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function runSignup(acknowledgeExistingAccount = false) {
|
||||
function advanceAfterSignupVerify(result: {
|
||||
registrationToken: string
|
||||
existingAccount: boolean
|
||||
sites?: string[]
|
||||
}) {
|
||||
setRegistrationToken(result.registrationToken)
|
||||
setExistingSites(result.sites ?? [])
|
||||
if (result.existingAccount) {
|
||||
setSignupStep('existing')
|
||||
setInfo('')
|
||||
} else {
|
||||
setPassword('')
|
||||
setConfirmPassword('')
|
||||
setSignupStep('password')
|
||||
setInfo(t('signup.info.choosePassword'))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSignupStart(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
clearMessages()
|
||||
|
||||
const cellNumber = resolveCellNumber()
|
||||
if (!cellNumber) return
|
||||
if (firstName.trim().length < 2 || lastName.trim().length < 2) {
|
||||
setError(t('signup.error.create'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const result = await startSignup({
|
||||
cellNumber,
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
domain: tenantDomain,
|
||||
})
|
||||
|
||||
if (!result.requiresOtp && result.registrationToken) {
|
||||
advanceAfterSignupVerify({
|
||||
registrationToken: result.registrationToken,
|
||||
existingAccount: Boolean(result.existingAccount),
|
||||
sites: result.sites,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setCodeSent(true)
|
||||
setSignupStep('otp')
|
||||
setSmsCode('')
|
||||
setInfo(result.enabled ? t('signup.info.verify') : result.message)
|
||||
startCountdown()
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof ApiError &&
|
||||
err.message.includes('already registered on this website')
|
||||
) {
|
||||
setError(t('signup.error.alreadyOnSite'))
|
||||
} else {
|
||||
handleApiError(err, t('signup.error.create'))
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSignupResendCode() {
|
||||
clearMessages()
|
||||
const cellNumber = resolveCellNumber()
|
||||
if (!cellNumber) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const data = await register({
|
||||
const result = await startSignup({
|
||||
cellNumber,
|
||||
password,
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
domain: tenantDomain,
|
||||
...(acknowledgeExistingAccount ? { acknowledgeExistingAccount: true } : {}),
|
||||
})
|
||||
|
||||
setCellExistsSites(null)
|
||||
|
||||
// Password is applied only after OTP when passwordPending; toast then.
|
||||
if (data.passwordUpdated) {
|
||||
showToast(t('signup.toast.passwordUpdated'), 'info')
|
||||
}
|
||||
|
||||
if (data.requiresOtp || data.passwordPending || !data.user.cellVerifiedAt) {
|
||||
if (data.accessToken) {
|
||||
logoutRequest()
|
||||
}
|
||||
await continueToSmsVerify(
|
||||
cellNumber,
|
||||
data.passwordPending
|
||||
? t('signup.info.verifyExistingPasswordUpdated')
|
||||
: t('signup.info.verify'),
|
||||
)
|
||||
if (!result.requiresOtp && result.registrationToken) {
|
||||
advanceAfterSignupVerify({
|
||||
registrationToken: result.registrationToken,
|
||||
existingAccount: Boolean(result.existingAccount),
|
||||
sites: result.sites,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (data.user.customerBusinesses.length === 0) {
|
||||
logoutRequest()
|
||||
setError(CUSTOMER_ACCESS_MESSAGE)
|
||||
return
|
||||
}
|
||||
setCodeSent(true)
|
||||
setInfo(result.enabled ? t('signup.info.verify') : result.message)
|
||||
startCountdown()
|
||||
} catch (err) {
|
||||
handleApiError(err, t('login.error.sendCode'))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
await login(cellNumber, password)
|
||||
async function handleSignupVerifyOtp(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
clearMessages()
|
||||
const cellNumber = resolveCellNumber()
|
||||
if (!cellNumber) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const result = await verifySignupOtp(cellNumber, smsCode)
|
||||
advanceAfterSignupVerify(result)
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof ApiError &&
|
||||
err.message.includes('already registered on this website')
|
||||
) {
|
||||
setError(t('signup.error.alreadyOnSite'))
|
||||
} else {
|
||||
handleApiError(err, t('otp.error.signIn'))
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function finishSignup(token: string, nextPassword?: string) {
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const data = await completeSignup({
|
||||
registrationToken: token,
|
||||
...(nextPassword ? { password: nextPassword } : {}),
|
||||
})
|
||||
await establishSession(data.user)
|
||||
if (data.existingAccount) {
|
||||
showToast(t('signup.toast.linkedExisting'), 'info')
|
||||
}
|
||||
navigate(redirectTo)
|
||||
} catch (err) {
|
||||
if (!acknowledgeExistingAccount && err instanceof ApiError) {
|
||||
const sites = parseCellExistsSites(err.message)
|
||||
if (sites !== null) {
|
||||
setCellExistsSites(sites)
|
||||
return
|
||||
}
|
||||
}
|
||||
setCellExistsSites(null)
|
||||
handleApiError(err, t('signup.error.create'))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function continueToSmsVerify(cellNumber: string, infoMessage?: string) {
|
||||
setView('otp')
|
||||
setSmsStep('phone')
|
||||
setPassword('')
|
||||
setConfirmPassword('')
|
||||
setSmsCode('')
|
||||
setInfo(infoMessage ?? t('signup.info.verify'))
|
||||
try {
|
||||
const otpResult = await sendOtp(cellNumber, tenantDomain)
|
||||
if (!otpResult.enabled) {
|
||||
setInfo(otpResult.message)
|
||||
}
|
||||
setCodeSent(true)
|
||||
setSmsStep('code')
|
||||
startCountdown()
|
||||
} catch (otpErr) {
|
||||
handleApiError(otpErr, t('login.error.sendCode'))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSignup(e: React.FormEvent) {
|
||||
async function handleSignupPassword(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
clearMessages()
|
||||
setCellExistsSites(null)
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError(t('signup.error.match'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!isPasswordStrong(password)) {
|
||||
setError(t('forgot.error.strength'))
|
||||
return
|
||||
}
|
||||
|
||||
await runSignup()
|
||||
}
|
||||
|
||||
/**
|
||||
* Existing Meshkee account on another site: user admits the notice, then we link
|
||||
* them with the new password and prove phone ownership via SMS.
|
||||
*/
|
||||
async function handleAdmitCellExists() {
|
||||
clearMessages()
|
||||
const cellNumber = resolveCellNumber()
|
||||
if (!cellNumber) {
|
||||
setCellExistsSites(null)
|
||||
if (!registrationToken) {
|
||||
setError(t('signup.error.sessionExpired'))
|
||||
setSignupStep('details')
|
||||
return
|
||||
}
|
||||
|
||||
await runSignup(true)
|
||||
await finishSignup(registrationToken, password)
|
||||
}
|
||||
|
||||
async function handleSignupLinkExisting() {
|
||||
clearMessages()
|
||||
if (!registrationToken) {
|
||||
setError(t('signup.error.sessionExpired'))
|
||||
setSignupStep('details')
|
||||
return
|
||||
}
|
||||
await finishSignup(registrationToken)
|
||||
}
|
||||
|
||||
function handleSignupBack() {
|
||||
clearMessages()
|
||||
if (signupStep === 'otp') {
|
||||
setSignupStep('details')
|
||||
setSmsCode('')
|
||||
setCodeSent(false)
|
||||
return
|
||||
}
|
||||
if (signupStep === 'password' || signupStep === 'existing') {
|
||||
setSignupStep('details')
|
||||
setRegistrationToken('')
|
||||
setExistingSites([])
|
||||
setPassword('')
|
||||
setConfirmPassword('')
|
||||
return
|
||||
}
|
||||
switchView('login')
|
||||
}
|
||||
|
||||
async function handleResetPassword(e: React.FormEvent) {
|
||||
@@ -379,7 +450,17 @@ export function LoginPage() {
|
||||
view === 'login'
|
||||
? { title: t('login.welcome'), subtitle: t('login.subtitle') }
|
||||
: view === 'signup'
|
||||
? { title: t('signup.title'), subtitle: null }
|
||||
? {
|
||||
title: t('signup.title'),
|
||||
subtitle:
|
||||
signupStep === 'details'
|
||||
? t('signup.subtitleDetails')
|
||||
: signupStep === 'otp'
|
||||
? t('signup.subtitleOtp')
|
||||
: signupStep === 'existing'
|
||||
? t('signup.subtitleExisting')
|
||||
: t('signup.subtitlePassword'),
|
||||
}
|
||||
: view === 'forgot'
|
||||
? {
|
||||
title: t('forgot.title'),
|
||||
@@ -405,15 +486,25 @@ export function LoginPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.card}>
|
||||
{(view === 'forgot' || view === 'otp') && (
|
||||
{(view === 'forgot' || view === 'otp' || (view === 'signup' && signupStep !== 'details')) && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backBtn}
|
||||
onClick={() => switchView('login')}
|
||||
onClick={() => {
|
||||
if (view === 'signup') {
|
||||
handleSignupBack()
|
||||
return
|
||||
}
|
||||
switchView('login')
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
{view === 'forgot' ? t('forgot.back') : t('otp.back')}
|
||||
{view === 'forgot'
|
||||
? t('forgot.back')
|
||||
: view === 'signup'
|
||||
? t('signup.back')
|
||||
: t('otp.back')}
|
||||
</button>
|
||||
)}
|
||||
<div className={styles.cardHeader}>
|
||||
@@ -527,155 +618,284 @@ export function LoginPage() {
|
||||
|
||||
{view === 'signup' && (
|
||||
<>
|
||||
<form className={styles.form} onSubmit={handleSignup}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
{signupStep === 'details' && (
|
||||
<form className={styles.form} onSubmit={handleSignupStart}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{info && <div className={styles.info}>{info}</div>}
|
||||
|
||||
<div className={styles.fieldRow}>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="signup-first">{t('signup.firstName')}</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<User size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="signup-first"
|
||||
type="text"
|
||||
placeholder={t('signup.firstName')}
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
required
|
||||
minLength={2}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="signup-last">{t('signup.lastName')}</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<User size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="signup-last"
|
||||
type="text"
|
||||
placeholder={t('signup.lastName')}
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
required
|
||||
minLength={2}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{info && <div className={styles.info}>{info}</div>}
|
||||
|
||||
<div className={styles.fieldRow}>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="signup-first">{t('signup.firstName')}</label>
|
||||
<label htmlFor="signup-phone">{t('login.mobile')}</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<User size={18} className={styles.inputIcon} />
|
||||
<Smartphone size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="signup-first"
|
||||
type="text"
|
||||
placeholder={t('signup.firstName')}
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
id="signup-phone"
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
placeholder="09123456789"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
required
|
||||
minLength={2}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
||||
{isSubmitting ? t('signup.sendingCode') : t('signup.continue')}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{signupStep === 'otp' && (
|
||||
<form className={styles.form} onSubmit={handleSignupVerifyOtp}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{info && <div className={styles.info}>{info}</div>}
|
||||
|
||||
{codeSent && (
|
||||
<p className={styles.codeHint}>
|
||||
{t('common.codeSent', { phone })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="signup-last">{t('signup.lastName')}</label>
|
||||
<label htmlFor="signup-code">{t('otp.code')}</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<User size={18} className={styles.inputIcon} />
|
||||
<KeyRound size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="signup-last"
|
||||
id="signup-code"
|
||||
type="text"
|
||||
placeholder={t('signup.lastName')}
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
inputMode="numeric"
|
||||
placeholder="123456"
|
||||
maxLength={6}
|
||||
value={smsCode}
|
||||
onChange={(e) => setSmsCode(e.target.value.replace(/\D/g, ''))}
|
||||
required
|
||||
minLength={2}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="signup-phone">{t('login.mobile')}</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Smartphone size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="signup-phone"
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
placeholder="09123456789"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<div className={styles.resendRow}>
|
||||
{countdown > 0 ? (
|
||||
<span className={styles.countdown}>
|
||||
{t('common.resendIn', { seconds: countdown })}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkBtn}
|
||||
onClick={() => void handleSignupResendCode()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t('common.resend')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="signup-password">{t('login.password')}</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Lock size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="signup-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
dir="ltr"
|
||||
lang="en"
|
||||
placeholder={t('signup.passwordPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.submitBtn}
|
||||
disabled={isSubmitting || smsCode.length !== 6}
|
||||
>
|
||||
{isSubmitting ? t('signup.verifying') : t('signup.verifyCode')}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{signupStep === 'existing' && (
|
||||
<div className={styles.form}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.info}>
|
||||
{renderWithMeshkeeLink(
|
||||
existingSites.length > 0
|
||||
? t('signup.info.existingAccount', {
|
||||
sites: existingSites.join(', '),
|
||||
})
|
||||
: t('signup.info.existingAccountGeneric'),
|
||||
styles.noticeLink,
|
||||
)}
|
||||
</div>
|
||||
<p className={styles.codeHint}>{t('signup.info.existingPasswordHint')}</p>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.submitBtn}
|
||||
onClick={() => void handleSignupLinkExisting()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? t('signup.creating') : t('signup.joinContinue')}
|
||||
</button>
|
||||
<p className={styles.footerText}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.togglePassword}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? t('login.hidePassword') : t('login.showPassword')}
|
||||
className={styles.linkBtn}
|
||||
onClick={() => {
|
||||
clearMessages()
|
||||
setPassword('')
|
||||
setConfirmPassword('')
|
||||
setSmsCode('')
|
||||
setSignupStep('details')
|
||||
setRegistrationToken('')
|
||||
setExistingSites([])
|
||||
setSmsStep('phone')
|
||||
setCodeSent(false)
|
||||
setView('forgot')
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
{t('login.forgot')}
|
||||
</button>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="signup-confirm">{t('signup.confirm')}</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Lock size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="signup-confirm"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
dir="ltr"
|
||||
lang="en"
|
||||
placeholder={t('signup.confirmPlaceholder')}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
disabled={isSubmitting}
|
||||
aria-describedby="signup-password-strength signup-password-strength-rules"
|
||||
{signupStep === 'password' && (
|
||||
<form className={styles.form} onSubmit={handleSignupPassword}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{info && <div className={styles.info}>{info}</div>}
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="signup-password">{t('login.password')}</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Lock size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="signup-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
dir="ltr"
|
||||
lang="en"
|
||||
placeholder={t('signup.passwordPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.togglePassword}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? t('login.hidePassword') : t('login.showPassword')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="signup-confirm">{t('signup.confirm')}</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Lock size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="signup-confirm"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
dir="ltr"
|
||||
lang="en"
|
||||
placeholder={t('signup.confirmPlaceholder')}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
disabled={isSubmitting}
|
||||
aria-describedby="signup-password-strength signup-password-strength-rules"
|
||||
/>
|
||||
</div>
|
||||
<PasswordStrengthMeter
|
||||
password={password}
|
||||
confirmPassword={confirmPassword}
|
||||
id="signup-password-strength"
|
||||
labels={{
|
||||
title: t('password.strength'),
|
||||
empty: t('password.strength.empty'),
|
||||
weak: t('password.strength.weak'),
|
||||
fair: t('password.strength.fair'),
|
||||
good: t('password.strength.good'),
|
||||
strong: t('password.strength.strong'),
|
||||
rules: {
|
||||
length: t('password.rule.length'),
|
||||
upper: t('password.rule.upper'),
|
||||
number: t('password.rule.number'),
|
||||
match: t('password.rule.match'),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<PasswordStrengthMeter
|
||||
password={password}
|
||||
confirmPassword={confirmPassword}
|
||||
id="signup-password-strength"
|
||||
labels={{
|
||||
title: t('password.strength'),
|
||||
empty: t('password.strength.empty'),
|
||||
weak: t('password.strength.weak'),
|
||||
fair: t('password.strength.fair'),
|
||||
good: t('password.strength.good'),
|
||||
strong: t('password.strength.strong'),
|
||||
rules: {
|
||||
length: t('password.rule.length'),
|
||||
upper: t('password.rule.upper'),
|
||||
number: t('password.rule.number'),
|
||||
match: t('password.rule.match'),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.submitBtn}
|
||||
disabled={
|
||||
isSubmitting || !isPasswordStrong(password) || password !== confirmPassword
|
||||
}
|
||||
>
|
||||
{isSubmitting ? t('signup.creating') : t('signup.create')}
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.submitBtn}
|
||||
disabled={
|
||||
isSubmitting || !isPasswordStrong(password) || password !== confirmPassword
|
||||
}
|
||||
>
|
||||
{isSubmitting ? t('signup.creating') : t('signup.create')}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<p className={styles.footerText}>
|
||||
<span>{t('signup.hasAccount')}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkBtn}
|
||||
onClick={() => switchView('login')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t('signup.signIn')}
|
||||
</button>
|
||||
</p>
|
||||
{signupStep === 'details' && (
|
||||
<p className={styles.footerText}>
|
||||
<span>{t('signup.hasAccount')}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkBtn}
|
||||
onClick={() => switchView('login')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t('signup.signIn')}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -935,56 +1155,6 @@ export function LoginPage() {
|
||||
>
|
||||
Powered by Meshkee.app
|
||||
</a>
|
||||
|
||||
{cellExistsSites !== null &&
|
||||
createPortal(
|
||||
<div
|
||||
className={styles.noticeOverlay}
|
||||
onClick={() => !isSubmitting && setCellExistsSites(null)}
|
||||
>
|
||||
<div
|
||||
className={styles.noticeModal}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="cell-exists-title"
|
||||
lang={locale}
|
||||
dir={dir}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={styles.noticeHeader}>
|
||||
<h2 id="cell-exists-title" className={styles.noticeTitle}>
|
||||
{t('signup.cellExists.title')}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.noticeClose}
|
||||
onClick={() => setCellExistsSites(null)}
|
||||
disabled={isSubmitting}
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<p className={styles.noticeBody}>
|
||||
{renderWithMeshkeeLink(
|
||||
cellExistsSites
|
||||
? t('signup.error.cellExists', { sites: cellExistsSites })
|
||||
: t('signup.error.cellExistsGeneric'),
|
||||
styles.noticeLink,
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.noticeAdmit}
|
||||
onClick={() => void handleAdmitCellExists()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? t('signup.creating') : t('signup.cellExists.admit')}
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
OtpSendResponse,
|
||||
OtpVerifyResponse,
|
||||
RegisterResponse,
|
||||
SignupStartResponse,
|
||||
SignupVerifyResponse,
|
||||
HandoffCreateResponse,
|
||||
UserProfile,
|
||||
} from '../types/auth'
|
||||
@@ -45,6 +47,40 @@ export async function register(input: {
|
||||
return data
|
||||
}
|
||||
|
||||
export async function startSignup(input: {
|
||||
cellNumber: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
domain: string
|
||||
}) {
|
||||
return apiRequest<SignupStartResponse>('/auth/signup/start', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
|
||||
export async function verifySignupOtp(cellNumber: string, code: string) {
|
||||
return apiRequest<SignupVerifyResponse>('/auth/signup/verify-otp', {
|
||||
method: 'POST',
|
||||
body: { cellNumber, code },
|
||||
})
|
||||
}
|
||||
|
||||
export async function completeSignup(input: {
|
||||
registrationToken: string
|
||||
password?: string
|
||||
}) {
|
||||
const data = await apiRequest<RegisterResponse>('/auth/signup/complete', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
})
|
||||
|
||||
if (data.accessToken && data.refreshToken) {
|
||||
setTokens(data.accessToken, data.refreshToken)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchCurrentUser(signal?: AbortSignal) {
|
||||
return apiRequest<MeResponse>('/auth/me', { auth: true, signal })
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ export type {
|
||||
OtpSendResponse,
|
||||
OtpVerifyResponse,
|
||||
RegisterResponse,
|
||||
SignupStartResponse,
|
||||
SignupVerifyResponse,
|
||||
HandoffCreateResponse,
|
||||
UserProfile,
|
||||
} from '@meshkee/dashboard-core'
|
||||
|
||||
Reference in New Issue
Block a user