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}
+6 -1
View File
@@ -1,9 +1,11 @@
import type { InputHTMLAttributes } from 'react';
import clsx from 'clsx';
// A labeled input styled for the frosted dark-glass auth panel.
export function AuthField({
label,
id,
className,
...props
}: { label: string } & InputHTMLAttributes<HTMLInputElement>) {
return (
@@ -14,7 +16,10 @@ export function AuthField({
<input
id={id}
{...props}
className="w-full rounded-xl border border-white/15 bg-white/10 px-4 py-3 text-white placeholder-white/45 outline-none backdrop-blur-md transition focus:border-primary-400 focus:bg-white/[0.16] focus:ring-2 focus:ring-primary-400/30"
className={clsx(
'w-full rounded-xl border border-white/15 bg-white/10 px-4 py-3 text-white placeholder-white/45 outline-none backdrop-blur-md transition focus:border-primary-400 focus:bg-white/[0.16] focus:ring-2 focus:ring-primary-400/30',
className,
)}
/>
</div>
);
+11 -9
View File
@@ -21,9 +21,9 @@ export function AuthShell({
title: string;
subtitle: string;
children: ReactNode;
altPrompt: string;
altHref: string;
altLabel: string;
altPrompt?: string;
altHref?: string;
altLabel?: string;
}) {
const t = useT();
return (
@@ -69,12 +69,14 @@ export function AuthShell({
<div className="abrban-panel rounded-[1.75rem] p-6 sm:p-8">{children}</div>
<p className="abrban-ink mt-6 text-center text-sm text-white/90">
{altPrompt}{' '}
<Link href={altHref} className="font-bold text-primary-200 underline-offset-4 hover:text-white hover:underline">
{altLabel}
</Link>
</p>
{altPrompt && altHref && altLabel && (
<p className="abrban-ink mt-6 text-center text-sm text-white/90">
{altPrompt}{' '}
<Link href={altHref} className="font-bold text-primary-200 underline-offset-4 hover:text-white hover:underline">
{altLabel}
</Link>
</p>
)}
</div>
</main>
</div>
+111
View File
@@ -0,0 +1,111 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { AuthField } from '@/components/auth/AuthField';
import { useT } from '@/i18n/I18nProvider';
/**
* Shared 6-digit OTP entry used by both registration completion and OTP login.
* Manages the code input, a verify action, and a resend button with a cooldown.
*/
export function OtpStep({
destination,
onVerify,
onResend,
onBack,
submitting,
}: {
destination: string;
onVerify: (code: string) => void | Promise<void>;
onResend: () => void | Promise<void>;
onBack: () => void;
submitting: boolean;
}) {
const t = useT().auth.otp;
const [code, setCode] = useState('');
const [cooldown, setCooldown] = useState(60);
const timer = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
timer.current = setInterval(() => {
setCooldown((c) => (c > 0 ? c - 1 : 0));
}, 1000);
return () => {
if (timer.current) clearInterval(timer.current);
};
}, []);
const submit = (e: React.FormEvent) => {
e.preventDefault();
onVerify(code.trim());
};
const resend = async () => {
if (cooldown > 0) return;
await onResend();
setCooldown(60);
};
return (
<form className="space-y-5" onSubmit={submit}>
<p className="text-sm text-white/70 leading-relaxed">
{t.sentTo}{' '}
<span dir="ltr" className="font-mono font-semibold text-white">
{destination}
</span>
</p>
<AuthField
id="otp"
label={t.codeLabel}
type="text"
required
dir="ltr"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
placeholder="------"
className="text-center tracking-[0.5em] font-mono"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
/>
<button
type="submit"
disabled={submitting || code.length < 4}
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"
>
{submitting ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
{t.verifying}
</>
) : (
<>
{t.verify}
<ArrowLeft className="h-4 w-4 ltr:rotate-180" />
</>
)}
</button>
<div className="flex items-center justify-between text-sm">
<button
type="button"
onClick={onBack}
className="text-white/60 transition hover:text-white"
>
{t.back}
</button>
<button
type="button"
onClick={resend}
disabled={cooldown > 0}
className="text-primary-300 transition hover:text-primary-200 disabled:cursor-not-allowed disabled:opacity-50"
>
{cooldown > 0 ? t.resendIn.replace('{s}', String(cooldown)) : t.resend}
</button>
</div>
</form>
);
}
+71 -4
View File
@@ -120,11 +120,20 @@ const en: Dictionary = {
backHome: 'Back to home',
login: {
title: 'Welcome back',
subtitle: 'Sign in to your Abrban account',
subtitle: 'Sign in to your Abrban account with your mobile',
altPrompt: 'Dont have an account yet?',
altLabel: 'Sign up',
email: 'Email',
tabPassword: 'With password',
tabOtp: 'With one-time code',
phone: 'Mobile number',
password: 'Password',
otpHint: 'A verification code will be texted to your mobile.',
sendCode: 'Send code',
sending: 'Sending…',
codeSent: 'Verification code sent',
verifyNeeded: 'Verify your mobile number to continue.',
otpTitle: 'Verify mobile number',
otpSubtitle: 'Enter the 6-digit code we texted you',
submit: 'Sign in',
submitting: 'Signing in…',
success: 'Signed in successfully!',
@@ -139,13 +148,25 @@ const en: Dictionary = {
firstNamePlaceholder: 'e.g. Ali',
lastName: 'Last name',
lastNamePlaceholder: 'e.g. Rezaei',
email: 'Email',
phone: 'Mobile number',
emailOptional: 'Email (optional)',
password: 'Password',
passwordPlaceholder: 'At least 8 characters',
submit: 'Create account',
submitting: 'Creating account…',
success: 'Account created successfully!',
codeSent: 'A verification code was texted to your mobile',
success: 'Account created and verified!',
error: 'Sign up failed',
verifyError: 'Code verification failed',
},
otp: {
sentTo: 'A verification code was texted to:',
codeLabel: 'Verification code',
verify: 'Verify and continue',
verifying: 'Verifying…',
back: 'Back',
resend: 'Resend code',
resendIn: 'Resend in {s}s',
},
},
@@ -391,6 +412,7 @@ const en: Dictionary = {
wallet: 'Wallet',
invoices: 'Invoices',
tickets: 'Tickets',
account: 'My Account',
users: 'Users',
allApplications: 'All Applications',
billingPlans: 'Billing Plans',
@@ -402,6 +424,48 @@ const en: Dictionary = {
},
dashboard: {
account: {
title: 'My Account',
subtitle: 'Manage your account details, mobile number and password.',
personalInfo: 'Personal information',
firstName: 'First name',
lastName: 'Last name',
emailOptional: 'Email (optional)',
emailHint: 'Email is for contact only and is not used to sign in.',
save: 'Save',
saving: 'Saving…',
savedName: 'Profile saved successfully',
verified: 'Verified',
unverified: 'Unverified',
notSet: 'Not set',
verifyNow: 'Verify',
currentPassword: 'Current password',
passwordReason: 'Your current password is required to change your mobile number.',
sendCode: 'Send code',
sending: 'Sending…',
codeSent: 'Verification code sent',
codeSentTo: 'A verification code was texted to:',
enterCode: 'Verification code',
confirm: 'Confirm',
verifying: 'Verifying…',
resend: 'Resend code',
cancel: 'Cancel',
phoneUpdated: 'Mobile number updated successfully',
changePassword: 'Change password',
newPassword: 'New password',
confirmPassword: 'Confirm new password',
passwordMismatch: 'New password and confirmation do not match',
passwordChanged: 'Password changed successfully',
passwordHint: 'At least 8 characters',
errorGeneric: 'Operation failed',
phone: {
label: 'Mobile number',
empty: 'No number set',
add: 'Add number',
change: 'Change number',
newLabel: 'New mobile number',
},
},
status: {
running: 'Running',
pending: 'Pending',
@@ -679,6 +743,9 @@ const en: Dictionary = {
notAvailableMessage: 'Central Elasticsearch is not deployed on the cluster. Enable the logging addon when deploying an app, and ask an administrator to deploy the logging stack.',
checkingConnection: 'Checking connection…',
retryingAuto: 'Retrying automatically every few seconds.',
elasticDisabledTitle: 'Elasticsearch logging is off',
elasticDisabledMessage: 'You did not enable Elasticsearch for this application when creating it, so centralized logs are unavailable. Enable the Elasticsearch addon when deploying a new app to see its logs here.',
elasticDisabledMessageAll: 'None of your applications have Elasticsearch enabled, so centralized logs are unavailable. Enable the Elasticsearch addon when deploying a new app to see its logs here.',
},
users: {
title: 'User Management',
+71 -4
View File
@@ -119,11 +119,20 @@ const fa = {
backHome: 'بازگشت به خانه',
login: {
title: 'خوش آمدی',
subtitle: ه حساب ابربان خود وارد شو',
subtitle: ا شماره موبایل وارد حساب ابربان شو',
altPrompt: 'هنوز حساب نداری؟',
altLabel: 'ثبت‌نام کن',
email: 'ایمیل',
tabPassword: 'با رمز عبور',
tabOtp: 'با رمز یکبارمصرف',
phone: 'شماره موبایل',
password: 'رمز عبور',
otpHint: 'یک کد تأیید به شماره موبایلت پیامک می‌شود.',
sendCode: 'ارسال کد',
sending: 'در حال ارسال…',
codeSent: 'کد تأیید پیامک شد',
verifyNeeded: 'برای ادامه، شماره موبایلت را تأیید کن.',
otpTitle: 'تأیید شماره موبایل',
otpSubtitle: 'کد ۶ رقمی پیامک‌شده را وارد کن',
submit: 'ورود',
submitting: 'در حال ورود…',
success: 'با موفقیت وارد شدی!',
@@ -138,13 +147,25 @@ const fa = {
firstNamePlaceholder: 'مثلاً علی',
lastName: 'نام خانوادگی',
lastNamePlaceholder: 'مثلاً رضایی',
email: 'ایمیل',
phone: 'شماره موبایل',
emailOptional: 'ایمیل (اختیاری)',
password: 'رمز عبور',
passwordPlaceholder: 'حداقل ۸ کاراکتر',
submit: 'ساخت حساب',
submitting: 'در حال ساخت حساب…',
success: 'حساب با موفقیت ساخته شد!',
codeSent: 'کد تأیید به موبایلت پیامک شد',
success: 'حساب با موفقیت ساخته و تأیید شد!',
error: 'ثبت‌نام ناموفق بود',
verifyError: 'تأیید کد ناموفق بود',
},
otp: {
sentTo: 'کد تأیید به این شماره پیامک شد:',
codeLabel: 'کد تأیید',
verify: 'تأیید و ادامه',
verifying: 'در حال بررسی…',
back: 'بازگشت',
resend: 'ارسال مجدد کد',
resendIn: 'ارسال مجدد تا {s} ثانیه',
},
},
@@ -390,6 +411,7 @@ const fa = {
wallet: 'کیف‌پول',
invoices: 'فاکتورها',
tickets: 'تیکت‌ها',
account: 'حساب من',
users: 'کاربران',
allApplications: 'همهٔ اپلیکیشن‌ها',
billingPlans: 'پلن‌های صورت‌حساب',
@@ -401,6 +423,48 @@ const fa = {
},
dashboard: {
account: {
title: 'حساب من',
subtitle: 'اطلاعات حساب، شماره موبایل و رمز عبورت را مدیریت کن.',
personalInfo: 'اطلاعات شخصی',
firstName: 'نام',
lastName: 'نام خانوادگی',
emailOptional: 'ایمیل (اختیاری)',
emailHint: 'ایمیل فقط برای ارتباط است و در ورود استفاده نمی‌شود.',
save: 'ذخیره',
saving: 'در حال ذخیره…',
savedName: 'اطلاعات با موفقیت ذخیره شد',
verified: 'تأیید شده',
unverified: 'تأیید نشده',
notSet: 'ثبت نشده',
verifyNow: 'تأیید',
currentPassword: 'رمز عبور فعلی',
passwordReason: 'برای تغییر شماره موبایل، رمز فعلی لازم است.',
sendCode: 'ارسال کد',
sending: 'در حال ارسال…',
codeSent: 'کد تأیید پیامک شد',
codeSentTo: 'کد تأیید به این شماره پیامک شد:',
enterCode: 'کد تأیید',
confirm: 'تأیید',
verifying: 'در حال بررسی…',
resend: 'ارسال مجدد کد',
cancel: 'انصراف',
phoneUpdated: 'شماره موبایل با موفقیت به‌روزرسانی شد',
changePassword: 'تغییر رمز عبور',
newPassword: 'رمز عبور جدید',
confirmPassword: 'تکرار رمز عبور جدید',
passwordMismatch: 'رمز عبور جدید و تکرار آن یکسان نیستند',
passwordChanged: 'رمز عبور با موفقیت تغییر کرد',
passwordHint: 'حداقل ۸ کاراکتر',
errorGeneric: 'انجام عملیات ناموفق بود',
phone: {
label: 'شماره موبایل',
empty: 'شماره‌ای ثبت نشده',
add: 'افزودن شماره',
change: 'تغییر شماره',
newLabel: 'شماره موبایل جدید',
},
},
status: {
running: 'در حال اجرا',
pending: 'در انتظار',
@@ -678,6 +742,9 @@ const fa = {
notAvailableMessage: 'Elasticsearch مرکزی روی کلاستر مستقر نشده است. هنگام انتشار اپ، افزونهٔ لاگینگ را فعال کن و از مدیر بخواه استک لاگینگ را مستقر کند.',
checkingConnection: 'در حال بررسی اتصال…',
retryingAuto: 'هر چند ثانیه به‌صورت خودکار تلاش می‌شود.',
elasticDisabledTitle: 'لاگ‌گیری Elasticsearch فعال نیست',
elasticDisabledMessage: 'برای این اپلیکیشن هنگام ساخت، گزینهٔ Elasticsearch را فعال نکرده‌ای؛ به همین دلیل لاگ متمرکز در دسترس نیست. هنگام انتشار یک اپ جدید افزونهٔ Elasticsearch را فعال کن تا لاگ‌های آن اینجا نمایش داده شود.',
elasticDisabledMessageAll: 'هیچ‌کدام از اپلیکیشن‌هایت هنگام ساخت، Elasticsearch را فعال نکرده‌اند؛ به همین دلیل لاگ متمرکز در دسترس نیست. هنگام انتشار یک اپ جدید افزونهٔ Elasticsearch را فعال کن تا لاگ‌های آن اینجا نمایش داده شود.',
},
users: {
title: 'مدیریت کاربران',
+55 -9
View File
@@ -4,14 +4,44 @@ import { create } from 'zustand';
import api from '@/lib/api';
import type { User, AuthResponse } from '@/types';
export interface RegisterData {
phone: string;
password: string;
firstName: string;
lastName: string;
email?: string;
}
/** Either the user is fully authenticated, or a phone OTP is required next. */
export type AuthResult =
| { status: 'authenticated' }
| { status: 'verify'; phone: string };
interface VerificationRequired {
requiresVerification: true;
phone: string;
}
interface AuthState {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
register: (data: { email: string; password: string; firstName: string; lastName: string }) => Promise<void>;
/** Password login by mobile. May require OTP verification. */
login: (phone: string, password: string) => Promise<AuthResult>;
/** Register by mobile — always returns a verify step. */
register: (data: RegisterData) => Promise<{ phone: string }>;
/** Send a one-time login code to a mobile number. */
requestOtp: (phone: string) => Promise<void>;
/** Verify a one-time code (completes registration or OTP login). */
verifyOtp: (phone: string, code: string) => Promise<void>;
logout: () => void;
loadUser: () => Promise<void>;
setUser: (user: User) => void;
}
function persistAuth(data: AuthResponse) {
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
}
export const useAuthStore = create<AuthState>((set) => ({
@@ -19,17 +49,31 @@ export const useAuthStore = create<AuthState>((set) => ({
isLoading: true,
isAuthenticated: false,
login: async (email, password) => {
const { data } = await api.post<AuthResponse>('/auth/login', { email, password });
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
login: async (phone, password) => {
const { data } = await api.post<AuthResponse | VerificationRequired>(
'/auth/login',
{ phone, password },
);
if ('requiresVerification' in data) {
return { status: 'verify', phone: data.phone };
}
persistAuth(data);
set({ user: data.user, isAuthenticated: true });
return { status: 'authenticated' };
},
register: async (registerData) => {
const { data } = await api.post<AuthResponse>('/auth/register', registerData);
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
const { data } = await api.post<VerificationRequired>('/auth/register', registerData);
return { phone: data.phone };
},
requestOtp: async (phone) => {
await api.post('/auth/otp/request', { phone });
},
verifyOtp: async (phone, code) => {
const { data } = await api.post<AuthResponse>('/auth/otp/verify', { phone, code });
persistAuth(data);
set({ user: data.user, isAuthenticated: true });
},
@@ -52,4 +96,6 @@ export const useAuthStore = create<AuthState>((set) => ({
set({ user: null, isAuthenticated: false, isLoading: false });
}
},
setUser: (user) => set({ user }),
}));
+3 -1
View File
@@ -1,6 +1,8 @@
export interface User {
id: string;
email: string;
phone?: string | null;
email: string | null;
phoneVerified?: boolean;
firstName: string;
lastName: string;
role: 'user' | 'admin' | 'technical' | 'sales';