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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user