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
+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>
);
}