feat(auth): mobile-only register/login with OTP verification
- Register and login by mobile number; email is now an optional contact field only (never used to authenticate) - After registration, the phone is verified via a 6-digit SMS code - Login supports both password and one-time-code (OTP) methods - Phone OTP delivered via Kavenegar (verify/lookup); API key in env - Account page: edit name/optional email, change password, and change mobile number with OTP re-verification - Codes are hashed, expire in 5m, capped at 5 attempts, rate-limited - Seed gives the admin a verified phone so mobile login still works Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import api from '@/lib/api';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { useT } from '@/i18n/I18nProvider';
|
||||
import type { User } from '@/types';
|
||||
import {
|
||||
UserCircle,
|
||||
Phone,
|
||||
Lock,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
|
||||
type Step = 'idle' | 'request' | 'confirm';
|
||||
|
||||
export default function AccountPage() {
|
||||
const t = useT();
|
||||
const a = t.dashboard.account;
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const setUser = useAuthStore((s) => s.setUser);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<UserCircle className="w-6 h-6 text-primary-600" />
|
||||
{a.title}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">{a.subtitle}</p>
|
||||
</div>
|
||||
|
||||
<ProfileSection user={user} onSaved={setUser} />
|
||||
<PhoneSection user={user} onChanged={setUser} />
|
||||
<PasswordSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Name + optional email ────────────────────────────── */
|
||||
|
||||
function ProfileSection({
|
||||
user,
|
||||
onSaved,
|
||||
}: {
|
||||
user: User;
|
||||
onSaved: (u: User) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const a = t.dashboard.account;
|
||||
const [firstName, setFirstName] = useState(user.firstName);
|
||||
const [lastName, setLastName] = useState(user.lastName);
|
||||
const [email, setEmail] = useState(user.email || '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const dirty =
|
||||
firstName !== user.firstName ||
|
||||
lastName !== user.lastName ||
|
||||
email !== (user.email || '');
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const { data } = await api.patch<User>('/users/me', {
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
});
|
||||
onSaved(data);
|
||||
notify.success(a.savedName);
|
||||
} catch (err) {
|
||||
notify.error(err, a.errorGeneric);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="card space-y-4">
|
||||
<h2 className="font-semibold text-gray-900 flex items-center gap-2">
|
||||
<UserCircle className="w-4 h-4 text-gray-400" />
|
||||
{a.personalInfo}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">{a.firstName}</label>
|
||||
<input className="input-field" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">{a.lastName}</label>
|
||||
<input className="input-field" value={lastName} onChange={(e) => setLastName(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">{a.emailOptional}</label>
|
||||
<input
|
||||
className="input-field"
|
||||
dir="ltr"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">{a.emailHint}</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
className="btn-primary"
|
||||
disabled={!dirty || saving || !firstName.trim() || !lastName.trim()}
|
||||
onClick={save}
|
||||
>
|
||||
{saving ? a.saving : a.save}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Phone (login identifier) with OTP verification ───── */
|
||||
|
||||
function PhoneSection({
|
||||
user,
|
||||
onChanged,
|
||||
}: {
|
||||
user: User;
|
||||
onChanged: (u: User) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const a = t.dashboard.account;
|
||||
const c = a.phone;
|
||||
|
||||
const current = user.phone;
|
||||
const verified = user.phoneVerified;
|
||||
|
||||
const [step, setStep] = useState<Step>('idle');
|
||||
const [value, setValue] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [masked, setMasked] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const reset = () => {
|
||||
setStep('idle');
|
||||
setValue('');
|
||||
setPassword('');
|
||||
setCode('');
|
||||
setMasked('');
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
setValue(current || '');
|
||||
setStep('request');
|
||||
};
|
||||
|
||||
const requestCode = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const { data } = await api.post('/users/me/phone/request', {
|
||||
phone: value.trim(),
|
||||
currentPassword: password,
|
||||
});
|
||||
setMasked(data.destination);
|
||||
setStep('confirm');
|
||||
notify.success(a.codeSent);
|
||||
} catch (err) {
|
||||
notify.error(err, a.errorGeneric);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmCode = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const { data } = await api.post<User>('/users/me/phone/confirm', {
|
||||
code: code.trim(),
|
||||
});
|
||||
onChanged(data);
|
||||
notify.success(a.phoneUpdated);
|
||||
reset();
|
||||
} catch (err) {
|
||||
notify.error(err, a.errorGeneric);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="card space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="font-semibold text-gray-900 flex items-center gap-2">
|
||||
<Phone className="w-4 h-4 text-gray-400" />
|
||||
{c.label}
|
||||
</h2>
|
||||
{current ? (
|
||||
verified ? (
|
||||
<span className="badge-green inline-flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3" /> {a.verified}
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge-yellow inline-flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" /> {a.unverified}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="badge-gray">{a.notSet}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{step === 'idle' && (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span dir="ltr" className="text-sm text-gray-700 font-mono">
|
||||
{current || <span className="text-gray-400">{c.empty}</span>}
|
||||
</span>
|
||||
<button className="btn-secondary" onClick={start}>
|
||||
{current ? (verified ? c.change : a.verifyNow) : c.add}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'request' && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">{c.newLabel}</label>
|
||||
<input
|
||||
className="input-field"
|
||||
dir="ltr"
|
||||
type="tel"
|
||||
placeholder="09123456789"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">{a.currentPassword}</label>
|
||||
<input
|
||||
className="input-field"
|
||||
dir="ltr"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">{a.passwordReason}</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button className="btn-ghost" onClick={reset}>
|
||||
{a.cancel}
|
||||
</button>
|
||||
<button
|
||||
className="btn-primary"
|
||||
disabled={busy || !value.trim() || !password}
|
||||
onClick={requestCode}
|
||||
>
|
||||
{busy ? a.sending : a.sendCode}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'confirm' && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-gray-600">
|
||||
{a.codeSentTo} <span dir="ltr" className="font-mono font-semibold">{masked}</span>
|
||||
</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">{a.enterCode}</label>
|
||||
<input
|
||||
className="input-field tracking-[0.4em] text-center font-mono"
|
||||
dir="ltr"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
placeholder="------"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button className="btn-ghost text-xs" onClick={requestCode} disabled={busy}>
|
||||
{a.resend}
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button className="btn-ghost" onClick={reset}>
|
||||
{a.cancel}
|
||||
</button>
|
||||
<button
|
||||
className="btn-primary"
|
||||
disabled={busy || code.length < 4}
|
||||
onClick={confirmCode}
|
||||
>
|
||||
{busy ? a.verifying : a.confirm}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Password ─────────────────────────────────────────── */
|
||||
|
||||
function PasswordSection() {
|
||||
const t = useT();
|
||||
const a = t.dashboard.account;
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
if (newPassword !== confirm) {
|
||||
notify.error(a.passwordMismatch);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.post('/users/me/password', { currentPassword, newPassword });
|
||||
notify.success(a.passwordChanged);
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirm('');
|
||||
} catch (err) {
|
||||
notify.error(err, a.errorGeneric);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="card space-y-4">
|
||||
<h2 className="font-semibold text-gray-900 flex items-center gap-2">
|
||||
<Lock className="w-4 h-4 text-gray-400" />
|
||||
{a.changePassword}
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">{a.currentPassword}</label>
|
||||
<input
|
||||
className="input-field"
|
||||
dir="ltr"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">{a.newPassword}</label>
|
||||
<input
|
||||
className="input-field"
|
||||
dir="ltr"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">{a.confirmPassword}</label>
|
||||
<input
|
||||
className="input-field"
|
||||
dir="ltr"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-gray-400 inline-flex items-center gap-1">
|
||||
<ShieldCheck className="w-3.5 h-3.5" /> {a.passwordHint}
|
||||
</span>
|
||||
<button
|
||||
className="btn-primary"
|
||||
disabled={busy || !currentPassword || newPassword.length < 8 || !confirm}
|
||||
onClick={submit}
|
||||
>
|
||||
{busy ? a.saving : a.changePassword}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -331,7 +331,7 @@ export default function AdminAppsPage() {
|
||||
<TruncatedText className="text-sm font-medium text-gray-900">
|
||||
{`${app.user.firstName} ${app.user.lastName}`}
|
||||
</TruncatedText>
|
||||
<TruncatedText className="text-xs text-gray-400">{app.user.email}</TruncatedText>
|
||||
<TruncatedText className="text-xs text-gray-400">{app.user.email || app.user.phone || ''}</TruncatedText>
|
||||
<TruncatedText className="text-xs text-gray-300 font-mono">{app.userId}</TruncatedText>
|
||||
</div>
|
||||
) : (
|
||||
@@ -486,7 +486,7 @@ export default function AdminAppsPage() {
|
||||
<User className="w-3 h-3 shrink-0" />
|
||||
{app.user.firstName} {app.user.lastName}
|
||||
</p>
|
||||
<TruncatedText>{app.user.email}</TruncatedText>
|
||||
<TruncatedText>{app.user.email || app.user.phone || ''}</TruncatedText>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
ScrollText,
|
||||
FileText,
|
||||
Database,
|
||||
UserCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
type NavKey = keyof Dictionary['nav'];
|
||||
@@ -46,6 +47,7 @@ const userNavItems: NavItem[] = [
|
||||
{ href: '/dashboard/wallet', labelKey: 'wallet', icon: <Wallet className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/invoices', labelKey: 'invoices', icon: <FileText className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/tickets', labelKey: 'tickets', icon: <Ticket className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/account', labelKey: 'account', icon: <UserCircle className="w-4 h-4" /> },
|
||||
];
|
||||
|
||||
const adminNavItems: NavItem[] = [
|
||||
@@ -203,12 +205,17 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
|
||||
{/* Sidebar footer */}
|
||||
<div className="pt-4 mt-4 border-t border-gray-200 shrink-0">
|
||||
<div className="px-3 py-2">
|
||||
<Link
|
||||
href="/dashboard/account"
|
||||
className="block px-3 py-2 rounded-xl hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<p className="text-xs font-semibold text-gray-700 truncate">
|
||||
{user?.firstName} {user?.lastName}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 truncate">{user?.email}</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 truncate" dir="ltr">
|
||||
{user?.email || user?.phone}
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,24 +6,42 @@ import { notify } from '@/lib/notify';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { AuthShell } from '@/components/auth/AuthShell';
|
||||
import { AuthField } from '@/components/auth/AuthField';
|
||||
import { OtpStep } from '@/components/auth/OtpStep';
|
||||
import { useT } from '@/i18n/I18nProvider';
|
||||
import { useLocalizedRouter } from '@/i18n/navigation';
|
||||
|
||||
type Method = 'password' | 'otp';
|
||||
|
||||
export default function LoginPage() {
|
||||
const tl = useT().auth.login;
|
||||
const [email, setEmail] = useState('');
|
||||
const [method, setMethod] = useState<Method>('password');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// When set, we're on the OTP entry step (masked destination to display).
|
||||
const [otpStep, setOtpStep] = useState<string | null>(null);
|
||||
|
||||
const login = useAuthStore((s) => s.login);
|
||||
const requestOtp = useAuthStore((s) => s.requestOtp);
|
||||
const verifyOtp = useAuthStore((s) => s.verifyOtp);
|
||||
const router = useLocalizedRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
const finish = () => {
|
||||
notify.success(tl.success);
|
||||
router.push('/dashboard');
|
||||
};
|
||||
|
||||
const handlePasswordLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
notify.success(tl.success);
|
||||
router.push('/dashboard');
|
||||
const res = await login(phone, password);
|
||||
if (res.status === 'verify') {
|
||||
setOtpStep(res.phone);
|
||||
notify.info(tl.verifyNeeded);
|
||||
} else {
|
||||
finish();
|
||||
}
|
||||
} catch (err: any) {
|
||||
notify.error(err, tl.error);
|
||||
} finally {
|
||||
@@ -31,6 +49,46 @@ export default function LoginPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOtpRequest = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await requestOtp(phone);
|
||||
setOtpStep(phone);
|
||||
notify.success(tl.codeSent);
|
||||
} catch (err: any) {
|
||||
notify.error(err, tl.error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerify = async (code: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await verifyOtp(phone, code);
|
||||
finish();
|
||||
} catch (err: any) {
|
||||
notify.error(err, tl.error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (otpStep) {
|
||||
return (
|
||||
<AuthShell title={tl.otpTitle} subtitle={tl.otpSubtitle}>
|
||||
<OtpStep
|
||||
destination={otpStep}
|
||||
submitting={isLoading}
|
||||
onVerify={handleVerify}
|
||||
onResend={() => requestOtp(phone)}
|
||||
onBack={() => setOtpStep(null)}
|
||||
/>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
title={tl.title}
|
||||
@@ -39,49 +97,97 @@ export default function LoginPage() {
|
||||
altHref="/register"
|
||||
altLabel={tl.altLabel}
|
||||
>
|
||||
<form className="space-y-5" onSubmit={handleSubmit}>
|
||||
<AuthField
|
||||
id="email"
|
||||
label={tl.email}
|
||||
type="email"
|
||||
required
|
||||
dir="ltr"
|
||||
autoComplete="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
{/* Method tabs */}
|
||||
<div className="mb-5 grid grid-cols-2 gap-1 rounded-xl border border-white/15 bg-white/5 p-1">
|
||||
{(['password', 'otp'] as Method[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setMethod(m)}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-semibold transition ${
|
||||
method === m
|
||||
? 'bg-primary-600 text-white shadow'
|
||||
: 'text-white/70 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{m === 'password' ? tl.tabPassword : tl.tabOtp}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AuthField
|
||||
id="password"
|
||||
label={tl.password}
|
||||
type="password"
|
||||
required
|
||||
dir="ltr"
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-primary-600 px-6 py-3.5 font-bold text-white shadow-lg shadow-primary-600/30 transition hover:bg-primary-500 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
{tl.submitting}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{tl.submit}
|
||||
<ArrowLeft className="h-4 w-4 ltr:rotate-180" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
{method === 'password' ? (
|
||||
<form className="space-y-5" onSubmit={handlePasswordLogin}>
|
||||
<AuthField
|
||||
id="phone"
|
||||
label={tl.phone}
|
||||
type="tel"
|
||||
required
|
||||
dir="ltr"
|
||||
autoComplete="tel"
|
||||
placeholder="09123456789"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
/>
|
||||
<AuthField
|
||||
id="password"
|
||||
label={tl.password}
|
||||
type="password"
|
||||
required
|
||||
dir="ltr"
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<SubmitButton loading={isLoading} label={tl.submit} loadingLabel={tl.submitting} />
|
||||
</form>
|
||||
) : (
|
||||
<form className="space-y-5" onSubmit={handleOtpRequest}>
|
||||
<AuthField
|
||||
id="phone"
|
||||
label={tl.phone}
|
||||
type="tel"
|
||||
required
|
||||
dir="ltr"
|
||||
autoComplete="tel"
|
||||
placeholder="09123456789"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-white/55">{tl.otpHint}</p>
|
||||
<SubmitButton loading={isLoading} label={tl.sendCode} loadingLabel={tl.sending} />
|
||||
</form>
|
||||
)}
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
function SubmitButton({
|
||||
loading,
|
||||
label,
|
||||
loadingLabel,
|
||||
}: {
|
||||
loading: boolean;
|
||||
label: string;
|
||||
loadingLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-primary-600 px-6 py-3.5 font-bold text-white shadow-lg shadow-primary-600/30 transition hover:bg-primary-500 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
{loadingLabel}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{label}
|
||||
<ArrowLeft className="h-4 w-4 ltr:rotate-180" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,23 +6,35 @@ import { notify } from '@/lib/notify';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { AuthShell } from '@/components/auth/AuthShell';
|
||||
import { AuthField } from '@/components/auth/AuthField';
|
||||
import { OtpStep } from '@/components/auth/OtpStep';
|
||||
import { useT } from '@/i18n/I18nProvider';
|
||||
import { useLocalizedRouter } from '@/i18n/navigation';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const tr = useT().auth.register;
|
||||
const [form, setForm] = useState({ email: '', password: '', firstName: '', lastName: '' });
|
||||
const tl = useT().auth.login;
|
||||
const [form, setForm] = useState({ firstName: '', lastName: '', phone: '', email: '', password: '' });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [otpStep, setOtpStep] = useState<string | null>(null);
|
||||
|
||||
const register = useAuthStore((s) => s.register);
|
||||
const requestOtp = useAuthStore((s) => s.requestOtp);
|
||||
const verifyOtp = useAuthStore((s) => s.verifyOtp);
|
||||
const router = useLocalizedRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await register(form);
|
||||
notify.success(tr.success);
|
||||
router.push('/dashboard');
|
||||
const { phone } = await register({
|
||||
firstName: form.firstName,
|
||||
lastName: form.lastName,
|
||||
phone: form.phone.trim(),
|
||||
password: form.password,
|
||||
...(form.email.trim() ? { email: form.email.trim() } : {}),
|
||||
});
|
||||
setOtpStep(phone);
|
||||
notify.success(tr.codeSent);
|
||||
} catch (err: any) {
|
||||
notify.error(err, tr.error);
|
||||
} finally {
|
||||
@@ -30,6 +42,33 @@ export default function RegisterPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerify = async (code: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await verifyOtp(form.phone.trim(), code);
|
||||
notify.success(tr.success);
|
||||
router.push('/dashboard');
|
||||
} catch (err: any) {
|
||||
notify.error(err, tr.verifyError);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (otpStep) {
|
||||
return (
|
||||
<AuthShell title={tl.otpTitle} subtitle={tl.otpSubtitle}>
|
||||
<OtpStep
|
||||
destination={otpStep}
|
||||
submitting={isLoading}
|
||||
onVerify={handleVerify}
|
||||
onResend={() => requestOtp(form.phone.trim())}
|
||||
onBack={() => setOtpStep(null)}
|
||||
/>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
title={tr.title}
|
||||
@@ -63,11 +102,22 @@ export default function RegisterPage() {
|
||||
</div>
|
||||
|
||||
<AuthField
|
||||
id="email"
|
||||
label={tr.email}
|
||||
type="email"
|
||||
id="phone"
|
||||
label={tr.phone}
|
||||
type="tel"
|
||||
required
|
||||
dir="ltr"
|
||||
autoComplete="tel"
|
||||
placeholder="09123456789"
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||
/>
|
||||
|
||||
<AuthField
|
||||
id="email"
|
||||
label={tr.emailOptional}
|
||||
type="email"
|
||||
dir="ltr"
|
||||
autoComplete="email"
|
||||
placeholder="you@example.com"
|
||||
value={form.email}
|
||||
|
||||
Reference in New Issue
Block a user