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:
keyhan
2026-06-16 16:40:08 +03:30
parent ce6813db99
commit 37c103fa20
31 changed files with 1756 additions and 143 deletions
@@ -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>
)}
+10 -3
View File
@@ -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>
);
+153 -47
View File
@@ -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>
);
}
+57 -7
View File
@@ -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}