This repository has been archived on 2026-08-11. You can view files and clone it, but cannot push or open issues or pull requests.
Gishen-Admin/src/pages/auth/LoginPage.tsx
kirukib e9a8e0ffc2 Add multi-provider staff login and Rx dosing schedules.
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>
2026-08-07 16:11:36 +03:00

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