Google, phone OTP, and Telegram join email auth; prescription lines carry frequency, intervalHours, and times, with backend spec updated for this batch. Co-authored-by: Cursor <cursoragent@cursor.com>
651 lines
27 KiB
TypeScript
651 lines
27 KiB
TypeScript
import { useMemo, useState, type FormEvent } from 'react'
|
|
import { Navigate, useNavigate } from 'react-router-dom'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
AlertCircle,
|
|
ArrowLeft,
|
|
ChevronDown,
|
|
Eye,
|
|
EyeOff,
|
|
FlaskConical,
|
|
Loader2,
|
|
Lock,
|
|
Mail,
|
|
Phone,
|
|
Send,
|
|
ShieldCheck,
|
|
Truck,
|
|
} from 'lucide-react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { InitialsAvatar } from '@/components/ui/avatar'
|
|
import { statusLabel } from '@/components/ui/status-badge'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu'
|
|
import { useAuthStore } from '@/zustand/authStore'
|
|
import { demoAccounts } from '@/config/permissions'
|
|
import type { AuthProvider, StaffRole } from '@/types'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
const REMEMBER_KEY = 'gishen_remember_email'
|
|
/** Demo OTP accepted for any phone (mock SMS provider). */
|
|
const MOCK_OTP = '123456'
|
|
|
|
const ROLE_ORDER: StaffRole[] = [
|
|
'super_admin',
|
|
'pharmacist',
|
|
'stock_manager',
|
|
'procurement',
|
|
'finance',
|
|
'marketing_manager',
|
|
'operations',
|
|
]
|
|
|
|
const VALUE_POINTS = [
|
|
{ icon: ShieldCheck, text: 'Prescription review with a full controlled-substance audit trail' },
|
|
{ icon: Truck, text: 'Live dispatch across every Gishen branch and rider' },
|
|
{ icon: Lock, text: 'Role-scoped access so each team only sees its own work' },
|
|
]
|
|
|
|
type CredentialMode = 'email' | 'phone'
|
|
type PhoneStep = 'enter' | 'verify'
|
|
|
|
function GoogleMark({ className }: { className?: string }) {
|
|
return (
|
|
<svg className={className} viewBox="0 0 24 24" aria-hidden="true">
|
|
<path
|
|
fill="#4285F4"
|
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
|
/>
|
|
<path
|
|
fill="#34A853"
|
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
|
/>
|
|
<path
|
|
fill="#FBBC05"
|
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
|
/>
|
|
<path
|
|
fill="#EA4335"
|
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
|
/>
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function TelegramMark({ className }: { className?: string }) {
|
|
return (
|
|
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" fill="currentColor">
|
|
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function normalizePhone(value: string) {
|
|
return value.replace(/[\s\-()]/g, '')
|
|
}
|
|
|
|
function isLikelyPhone(value: string) {
|
|
const digits = normalizePhone(value).replace(/^\+/, '')
|
|
return /^\d{9,15}$/.test(digits)
|
|
}
|
|
|
|
export function LoginPage() {
|
|
const { t } = useTranslation()
|
|
const navigate = useNavigate()
|
|
const login = useAuthStore((s) => s.login)
|
|
const loginWithProvider = useAuthStore((s) => s.loginWithProvider)
|
|
const token = useAuthStore((s) => s.token) || localStorage.getItem('access_token')
|
|
|
|
const [credentialMode, setCredentialMode] = useState<CredentialMode>('email')
|
|
const [email, setEmail] = useState(() => localStorage.getItem(REMEMBER_KEY) ?? '')
|
|
const [password, setPassword] = useState('')
|
|
const [showPassword, setShowPassword] = useState(false)
|
|
const [remember, setRemember] = useState(() => Boolean(localStorage.getItem(REMEMBER_KEY)))
|
|
const [fieldErrors, setFieldErrors] = useState<{
|
|
email?: string
|
|
password?: string
|
|
phone?: string
|
|
otp?: string
|
|
}>({})
|
|
const [formError, setFormError] = useState<string | null>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [providerBusy, setProviderBusy] = useState<Exclude<AuthProvider, 'email'> | null>(null)
|
|
const [demoEmail, setDemoEmail] = useState<string | null>(null)
|
|
|
|
const [phone, setPhone] = useState('')
|
|
const [phoneStep, setPhoneStep] = useState<PhoneStep>('enter')
|
|
const [otp, setOtp] = useState('')
|
|
const [otpSending, setOtpSending] = useState(false)
|
|
|
|
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME as string | undefined
|
|
const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID as string | undefined
|
|
|
|
const accountsByRole = useMemo(
|
|
() =>
|
|
ROLE_ORDER.map((role) => ({
|
|
role,
|
|
label: statusLabel('role', role),
|
|
accounts: demoAccounts.filter((a) => a.role === role),
|
|
})).filter((group) => group.accounts.length > 0),
|
|
[],
|
|
)
|
|
|
|
if (token) return <Navigate to="/" replace />
|
|
|
|
const finishSignIn = (nextEmail: string) => {
|
|
if (remember) localStorage.setItem(REMEMBER_KEY, nextEmail)
|
|
else localStorage.removeItem(REMEMBER_KEY)
|
|
toast.success(t('auth.signedIn'))
|
|
navigate('/')
|
|
}
|
|
|
|
const signInEmail = (nextEmail: string, nextPassword: string) => {
|
|
const result = login(nextEmail, nextPassword)
|
|
if (!result.ok) {
|
|
setFormError(result.error ?? t('auth.error.invalidCredentials'))
|
|
toast.error(result.error ?? t('auth.error.invalidCredentials'))
|
|
return false
|
|
}
|
|
finishSignIn(nextEmail)
|
|
return true
|
|
}
|
|
|
|
const signInProvider = (provider: Exclude<AuthProvider, 'email'>) => {
|
|
const result = loginWithProvider(provider)
|
|
if (!result.ok) {
|
|
setFormError(result.error ?? t('auth.error.invalidCredentials'))
|
|
toast.error(result.error ?? t('auth.error.invalidCredentials'))
|
|
return false
|
|
}
|
|
toast.success(t('auth.signedIn'), {
|
|
description: t('auth.providerSignedIn', { provider: t(`auth.providers.${provider}`) }),
|
|
})
|
|
navigate('/')
|
|
return true
|
|
}
|
|
|
|
const onSubmitEmail = (e: FormEvent) => {
|
|
e.preventDefault()
|
|
const errors: { email?: string; password?: string } = {}
|
|
if (!email.trim()) errors.email = t('auth.error.emailRequired')
|
|
if (!password) errors.password = t('auth.error.passwordRequired')
|
|
setFieldErrors(errors)
|
|
if (Object.keys(errors).length > 0) return
|
|
|
|
setFormError(null)
|
|
setLoading(true)
|
|
window.setTimeout(() => {
|
|
const ok = signInEmail(email.trim(), password)
|
|
if (!ok) setLoading(false)
|
|
}, 450)
|
|
}
|
|
|
|
const onSendOtp = () => {
|
|
if (!isLikelyPhone(phone)) {
|
|
setFieldErrors((prev) => ({ ...prev, phone: t('auth.error.phoneInvalid') }))
|
|
return
|
|
}
|
|
setFieldErrors((prev) => ({ ...prev, phone: undefined, otp: undefined }))
|
|
setFormError(null)
|
|
setOtpSending(true)
|
|
window.setTimeout(() => {
|
|
setOtpSending(false)
|
|
setPhoneStep('verify')
|
|
setOtp('')
|
|
toast.message(t('auth.otpSent'), {
|
|
description: t('auth.otpSentHint', { code: MOCK_OTP }),
|
|
})
|
|
}, 600)
|
|
}
|
|
|
|
const onVerifyOtp = (e: FormEvent) => {
|
|
e.preventDefault()
|
|
if (otp.trim() !== MOCK_OTP) {
|
|
setFieldErrors((prev) => ({ ...prev, otp: t('auth.error.otpInvalid') }))
|
|
return
|
|
}
|
|
setFieldErrors((prev) => ({ ...prev, otp: undefined }))
|
|
setFormError(null)
|
|
setLoading(true)
|
|
window.setTimeout(() => {
|
|
const ok = signInProvider('phone')
|
|
if (!ok) setLoading(false)
|
|
}, 450)
|
|
}
|
|
|
|
const onProviderClick = (provider: 'google' | 'telegram') => {
|
|
setFormError(null)
|
|
setProviderBusy(provider)
|
|
window.setTimeout(() => {
|
|
const ok = signInProvider(provider)
|
|
if (!ok) setProviderBusy(null)
|
|
}, 700)
|
|
}
|
|
|
|
const onPickDemoAccount = (account: (typeof demoAccounts)[number]) => {
|
|
setEmail(account.email)
|
|
setPassword(account.password)
|
|
setCredentialMode('email')
|
|
setFieldErrors({})
|
|
setFormError(null)
|
|
setDemoEmail(account.email)
|
|
window.setTimeout(() => {
|
|
const ok = signInEmail(account.email, account.password)
|
|
if (!ok) setDemoEmail(null)
|
|
}, 450)
|
|
}
|
|
|
|
const busy = loading || demoEmail !== null || providerBusy !== null || otpSending
|
|
|
|
return (
|
|
<div className="grid min-h-screen bg-cream lg:grid-cols-[1.05fr_1fr]">
|
|
<aside className="relative hidden overflow-hidden bg-forest lg:flex lg:flex-col lg:justify-between lg:p-12">
|
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_18%,rgba(168,199,58,0.38),transparent_46%),radial-gradient(circle_at_82%_72%,rgba(226,138,26,0.24),transparent_42%)]" />
|
|
<div
|
|
className="absolute inset-0 opacity-[0.35]"
|
|
style={{
|
|
backgroundImage: 'radial-gradient(rgba(246,247,242,0.10) 1px, transparent 1px)',
|
|
backgroundSize: '22px 22px',
|
|
}}
|
|
aria-hidden="true"
|
|
/>
|
|
<div className="relative">
|
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-500 font-display text-xl font-bold text-forest">
|
|
G
|
|
</div>
|
|
<h1 className="mt-10 font-display text-4xl font-bold leading-tight text-white">{t('app.name')}</h1>
|
|
<p className="mt-3 max-w-md text-lg text-white/75">{t('app.tagline')}</p>
|
|
<p className="mt-6 max-w-md text-sm leading-relaxed text-white/55">
|
|
One console for prescriptions, stock, dispatch and finance — built for the way Ethiopian pharmacies
|
|
actually run.
|
|
</p>
|
|
</div>
|
|
<ul className="relative mt-10 space-y-4">
|
|
{VALUE_POINTS.map(({ icon: Icon, text }) => (
|
|
<li key={text} className="flex items-start gap-3 text-sm text-white/70">
|
|
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-white/10 text-brand-300">
|
|
<Icon className="h-4 w-4" aria-hidden="true" />
|
|
</span>
|
|
<span className="max-w-sm leading-relaxed">{text}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
<p className="relative text-sm text-white/45">{t('app.poweredBy')}</p>
|
|
</aside>
|
|
|
|
<main className="flex items-center justify-center px-5 py-10 sm:px-8">
|
|
<div className="w-full max-w-[26rem] space-y-5">
|
|
<div className="flex items-center gap-3 lg:hidden">
|
|
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-brand-500 font-display text-lg font-bold text-forest">
|
|
G
|
|
</div>
|
|
<span className="font-display text-lg font-bold text-forest">{t('app.name')}</span>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-brand-700">Admin console</p>
|
|
<h2 className="mt-2 font-display text-3xl font-bold tracking-tight text-forest">{t('auth.welcome')}</h2>
|
|
<p className="mt-1.5 text-sm text-muted-foreground">{t('auth.subtitle')}</p>
|
|
</div>
|
|
|
|
{formError ? (
|
|
<div
|
|
role="alert"
|
|
className="flex items-start gap-2.5 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"
|
|
>
|
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
|
<span>{formError}</span>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="space-y-3">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
disabled={busy}
|
|
className="w-full justify-center gap-2.5 bg-white"
|
|
onClick={() => onProviderClick('google')}
|
|
>
|
|
{providerBusy === 'google' ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
|
) : (
|
|
<GoogleMark className="h-4 w-4" />
|
|
)}
|
|
{providerBusy === 'google' ? t('auth.connecting') : t('auth.continueGoogle')}
|
|
</Button>
|
|
{!googleClientId ? (
|
|
<p className="text-center text-[11px] text-muted-foreground">{t('auth.googleMockHint')}</p>
|
|
) : null}
|
|
|
|
<Button
|
|
type="button"
|
|
disabled={busy}
|
|
className="w-full justify-center gap-2.5 bg-[#229ED9] text-white hover:bg-[#1b8bc0]"
|
|
onClick={() => onProviderClick('telegram')}
|
|
>
|
|
{providerBusy === 'telegram' ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
|
) : (
|
|
<TelegramMark className="h-4 w-4" />
|
|
)}
|
|
{providerBusy === 'telegram'
|
|
? t('auth.connecting')
|
|
: t('auth.continueTelegram', {
|
|
bot: botUsername ? `@${botUsername}` : 'Telegram',
|
|
})}
|
|
</Button>
|
|
<p className="text-center text-[11px] text-muted-foreground">
|
|
{botUsername ? t('auth.telegramWidgetHint', { bot: botUsername }) : t('auth.telegramMockHint')}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
|
<span className="h-px flex-1 bg-border" />
|
|
<span>{t('auth.orContinueWith')}</span>
|
|
<span className="h-px flex-1 bg-border" />
|
|
</div>
|
|
|
|
<div
|
|
role="tablist"
|
|
aria-label={t('auth.credentialTabs')}
|
|
className="grid grid-cols-2 rounded-xl border bg-white p-1 shadow-soft"
|
|
>
|
|
{(
|
|
[
|
|
{ id: 'email' as const, icon: Mail, label: t('auth.email') },
|
|
{ id: 'phone' as const, icon: Phone, label: t('auth.phone') },
|
|
] as const
|
|
).map(({ id, icon: Icon, label }) => (
|
|
<button
|
|
key={id}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={credentialMode === id}
|
|
disabled={busy}
|
|
onClick={() => {
|
|
setCredentialMode(id)
|
|
setFormError(null)
|
|
setFieldErrors({})
|
|
}}
|
|
className={cn(
|
|
'flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
|
credentialMode === id
|
|
? 'bg-forest text-white shadow-sm'
|
|
: 'text-muted-foreground hover:bg-grayScale-100 hover:text-foreground',
|
|
)}
|
|
>
|
|
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{credentialMode === 'email' ? (
|
|
<form onSubmit={onSubmitEmail} noValidate className="space-y-4 rounded-2xl border bg-white p-6 shadow-soft">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="email">{t('auth.email')}</Label>
|
|
<Input
|
|
id="email"
|
|
name="email"
|
|
type="email"
|
|
autoComplete="email"
|
|
placeholder="you@gishen.et"
|
|
value={email}
|
|
aria-invalid={Boolean(fieldErrors.email)}
|
|
aria-describedby={fieldErrors.email ? 'email-error' : undefined}
|
|
onChange={(e) => {
|
|
setEmail(e.target.value)
|
|
if (fieldErrors.email) setFieldErrors((prev) => ({ ...prev, email: undefined }))
|
|
}}
|
|
className={fieldErrors.email ? 'border-red-400 focus-visible:ring-red-400' : undefined}
|
|
/>
|
|
{fieldErrors.email ? (
|
|
<p id="email-error" className="text-xs text-red-600">
|
|
{fieldErrors.email}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label htmlFor="password">{t('auth.password')}</Label>
|
|
<button
|
|
type="button"
|
|
className="rounded-sm text-xs font-medium text-brand-700 underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
onClick={() =>
|
|
toast.message(t('auth.resetTitle'), {
|
|
description: t('auth.resetBody'),
|
|
})
|
|
}
|
|
>
|
|
{t('auth.forgotPassword')}
|
|
</button>
|
|
</div>
|
|
<div className="relative">
|
|
<Input
|
|
id="password"
|
|
name="password"
|
|
type={showPassword ? 'text' : 'password'}
|
|
autoComplete="current-password"
|
|
placeholder="••••••••"
|
|
value={password}
|
|
aria-invalid={Boolean(fieldErrors.password)}
|
|
aria-describedby={fieldErrors.password ? 'password-error' : undefined}
|
|
onChange={(e) => {
|
|
setPassword(e.target.value)
|
|
if (fieldErrors.password) setFieldErrors((prev) => ({ ...prev, password: undefined }))
|
|
}}
|
|
className={`pr-11 ${fieldErrors.password ? 'border-red-400 focus-visible:ring-red-400' : ''}`}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPassword((prev) => !prev)}
|
|
aria-label={showPassword ? t('auth.hidePassword') : t('auth.showPassword')}
|
|
aria-pressed={showPassword}
|
|
className="absolute right-2 top-1/2 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
</button>
|
|
</div>
|
|
{fieldErrors.password ? (
|
|
<p id="password-error" className="text-xs text-red-600">
|
|
{fieldErrors.password}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
|
|
<label className="flex w-fit cursor-pointer items-center gap-2 text-sm text-muted-foreground">
|
|
<input
|
|
type="checkbox"
|
|
checked={remember}
|
|
onChange={(e) => setRemember(e.target.checked)}
|
|
className="h-4 w-4 rounded border-input accent-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
/>
|
|
{t('auth.rememberMe')}
|
|
</label>
|
|
|
|
<Button type="submit" className="w-full" disabled={busy}>
|
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> : null}
|
|
{loading ? t('auth.signingIn') : t('auth.login')}
|
|
</Button>
|
|
</form>
|
|
) : (
|
|
<div className="space-y-4 rounded-2xl border bg-white p-6 shadow-soft">
|
|
{phoneStep === 'enter' ? (
|
|
<>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="phone">{t('auth.phone')}</Label>
|
|
<Input
|
|
id="phone"
|
|
name="phone"
|
|
type="tel"
|
|
autoComplete="tel"
|
|
placeholder="+251 9XX XXX XXX"
|
|
value={phone}
|
|
aria-invalid={Boolean(fieldErrors.phone)}
|
|
aria-describedby={fieldErrors.phone ? 'phone-error' : 'phone-hint'}
|
|
onChange={(e) => {
|
|
setPhone(e.target.value)
|
|
if (fieldErrors.phone) setFieldErrors((prev) => ({ ...prev, phone: undefined }))
|
|
}}
|
|
className={fieldErrors.phone ? 'border-red-400 focus-visible:ring-red-400' : undefined}
|
|
/>
|
|
{fieldErrors.phone ? (
|
|
<p id="phone-error" className="text-xs text-red-600">
|
|
{fieldErrors.phone}
|
|
</p>
|
|
) : (
|
|
<p id="phone-hint" className="text-xs text-muted-foreground">
|
|
{t('auth.phoneHint')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<Button type="button" className="w-full" disabled={busy} onClick={onSendOtp}>
|
|
{otpSending ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> : <Send className="h-4 w-4" />}
|
|
{otpSending ? t('auth.sendingCode') : t('auth.sendCode')}
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<form onSubmit={onVerifyOtp} className="space-y-4">
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center gap-1.5 text-xs font-medium text-brand-700 underline-offset-2 hover:underline"
|
|
onClick={() => {
|
|
setPhoneStep('enter')
|
|
setOtp('')
|
|
setFieldErrors((prev) => ({ ...prev, otp: undefined }))
|
|
}}
|
|
disabled={busy}
|
|
>
|
|
<ArrowLeft className="h-3.5 w-3.5" aria-hidden="true" />
|
|
{t('auth.changePhone')}
|
|
</button>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="otp">{t('auth.otp')}</Label>
|
|
<Input
|
|
id="otp"
|
|
name="otp"
|
|
inputMode="numeric"
|
|
autoComplete="one-time-code"
|
|
placeholder="123456"
|
|
maxLength={6}
|
|
value={otp}
|
|
aria-invalid={Boolean(fieldErrors.otp)}
|
|
aria-describedby={fieldErrors.otp ? 'otp-error' : 'otp-hint'}
|
|
onChange={(e) => {
|
|
setOtp(e.target.value.replace(/\D/g, '').slice(0, 6))
|
|
if (fieldErrors.otp) setFieldErrors((prev) => ({ ...prev, otp: undefined }))
|
|
}}
|
|
className={cn(
|
|
'tracking-[0.35em] font-mono text-center text-lg',
|
|
fieldErrors.otp ? 'border-red-400 focus-visible:ring-red-400' : undefined,
|
|
)}
|
|
/>
|
|
{fieldErrors.otp ? (
|
|
<p id="otp-error" className="text-xs text-red-600">
|
|
{fieldErrors.otp}
|
|
</p>
|
|
) : (
|
|
<p id="otp-hint" className="text-xs text-muted-foreground">
|
|
{t('auth.otpHint', { phone: normalizePhone(phone), code: MOCK_OTP })}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<Button type="submit" className="w-full" disabled={busy || otp.length < 6}>
|
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> : null}
|
|
{loading ? t('auth.signingIn') : t('auth.verifyAndSignIn')}
|
|
</Button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<section
|
|
aria-labelledby="demo-heading"
|
|
className="rounded-2xl border border-dashed border-brand-300 bg-brand-50/60 p-5"
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-brand-100 text-brand-700">
|
|
<FlaskConical className="h-4 w-4" aria-hidden="true" />
|
|
</span>
|
|
<div className="min-w-0">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<h3 id="demo-heading" className="font-display text-sm font-semibold text-forest">
|
|
{t('auth.demoAccounts')}
|
|
</h3>
|
|
<Badge variant="warning">Demo only</Badge>
|
|
</div>
|
|
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
|
{t('auth.demoPickHint')} {t('auth.demoHint')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
disabled={busy}
|
|
className="mt-4 w-full justify-between bg-white"
|
|
>
|
|
<span className="flex min-w-0 items-center gap-2">
|
|
{demoEmail ? (
|
|
<Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden="true" />
|
|
) : null}
|
|
<span className="truncate">
|
|
{demoEmail ? t('auth.signingInAs', { email: demoEmail }) : t('auth.chooseTestUser')}
|
|
</span>
|
|
</span>
|
|
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="start"
|
|
className="max-h-[22rem] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto p-1.5"
|
|
>
|
|
{accountsByRole.map((group) => (
|
|
<div key={group.role} className="pb-1 last:pb-0">
|
|
<p className="px-2 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
{group.label}
|
|
</p>
|
|
{group.accounts.map((account) => (
|
|
<DropdownMenuItem
|
|
key={account.email}
|
|
className="items-start gap-3 py-2.5"
|
|
onSelect={() => onPickDemoAccount(account)}
|
|
>
|
|
<InitialsAvatar name={account.name} src={account.avatarUrl} size="sm" />
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-sm font-semibold text-foreground">
|
|
{account.name}
|
|
</span>
|
|
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
|
|
{account.branchName ?? 'All branches'} · {account.email}
|
|
</span>
|
|
</span>
|
|
</DropdownMenuItem>
|
|
))}
|
|
</div>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</section>
|
|
|
|
<p className="text-center text-xs text-muted-foreground">
|
|
{t('app.poweredBy')} · © {new Date().getFullYear()}
|
|
</p>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|