437 lines
17 KiB
React
437 lines
17 KiB
React
import { useState, useEffect, useRef } from 'react'
|
|
import { adminService } from '../services/adminService'
|
|
import './LoginPage.css'
|
|
|
|
/* ── Animated floating orbs background ── */
|
|
function Orbs() {
|
|
return (
|
|
<div className="orbs-container" aria-hidden="true">
|
|
<div className="orb orb-1" />
|
|
<div className="orb orb-2" />
|
|
<div className="orb orb-3" />
|
|
<div className="orb orb-4" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* ── Particle grid ── */
|
|
function GridLines() {
|
|
return <div className="grid-lines" aria-hidden="true" />
|
|
}
|
|
|
|
/* ── Eye icon for password toggle ── */
|
|
function EyeIcon({ open }) {
|
|
return open ? (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
|
<circle cx="12" cy="12" r="3"/>
|
|
</svg>
|
|
) : (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/>
|
|
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/>
|
|
<line x1="1" y1="23" x2="23" y2="1"/>
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
/* ── Shield logo SVG ── */
|
|
function ShieldLogo() {
|
|
return (
|
|
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<defs>
|
|
<linearGradient id="shieldGrad" x1="0" y1="0" x2="44" y2="44" gradientUnits="userSpaceOnUse">
|
|
<stop offset="0%" stopColor="#3b82f6"/>
|
|
<stop offset="100%" stopColor="#14b8a6"/>
|
|
</linearGradient>
|
|
</defs>
|
|
<path d="M22 2L4 10v12c0 10.5 7.7 20.3 18 22.6C32.3 42.3 40 32.5 40 22V10L22 2z" fill="url(#shieldGrad)" opacity="0.15"/>
|
|
<path d="M22 2L4 10v12c0 10.5 7.7 20.3 18 22.6C32.3 42.3 40 32.5 40 22V10L22 2z" stroke="url(#shieldGrad)" strokeWidth="1.5" fill="none"/>
|
|
<line x1="22" y1="13" x2="22" y2="31" stroke="url(#shieldGrad)" strokeWidth="2.5" strokeLinecap="round"/>
|
|
<line x1="13" y1="22" x2="31" y2="22" stroke="url(#shieldGrad)" strokeWidth="2.5" strokeLinecap="round"/>
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
/* ── Spinner ── */
|
|
function Spinner() {
|
|
return (
|
|
<span className="spinner" aria-hidden="true">
|
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
|
<circle cx="10" cy="10" r="8" stroke="rgba(255,255,255,0.3)" strokeWidth="2"/>
|
|
<path d="M10 2a8 8 0 0 1 8 8" stroke="#fff" strokeWidth="2" strokeLinecap="round"/>
|
|
</svg>
|
|
</span>
|
|
)
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────── */
|
|
/* Main Login Page Component */
|
|
/* ─────────────────────────────────────────────── */
|
|
export default function LoginPage({ onLoginSuccess }) {
|
|
const [form, setForm] = useState({ email: 'admin@cureselect.com', password: 'CureSelect@2025', remember: true })
|
|
const [showPwd, setShowPwd] = useState(false)
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState('')
|
|
const [shake, setShake] = useState(false)
|
|
const [mounted, setMounted] = useState(false)
|
|
const [requires2fa, setRequires2fa] = useState(false)
|
|
const [sessionToken, setSessionToken] = useState('')
|
|
const [totpCode, setTotpCode] = useState('')
|
|
const emailRef = useRef(null)
|
|
const totpRef = useRef(null)
|
|
|
|
useEffect(() => {
|
|
const t = setTimeout(() => setMounted(true), 50)
|
|
emailRef.current?.focus()
|
|
return () => clearTimeout(t)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (requires2fa) {
|
|
totpRef.current?.focus()
|
|
}
|
|
}, [requires2fa])
|
|
|
|
const handleChange = (e) => {
|
|
const { name, value, type, checked } = e.target
|
|
setForm(prev => ({ ...prev, [name]: type === 'checkbox' ? checked : value }))
|
|
if (error) setError('')
|
|
}
|
|
|
|
const triggerShake = () => {
|
|
setShake(true)
|
|
setTimeout(() => setShake(false), 600)
|
|
}
|
|
|
|
const handleSubmit = async (e) => {
|
|
e.preventDefault()
|
|
if (!form.email || !form.password) {
|
|
setError('Please fill in all fields.')
|
|
triggerShake()
|
|
return
|
|
}
|
|
|
|
setLoading(true)
|
|
setError('')
|
|
|
|
try {
|
|
const res = await adminService.auth.login(form.email, form.password)
|
|
if (res?.data?.requires2fa) {
|
|
setRequires2fa(true)
|
|
setSessionToken(res.data.sessionToken)
|
|
setLoading(false)
|
|
} else if (res?.data?.user) {
|
|
onLoginSuccess(res.data.user)
|
|
} else {
|
|
throw new Error('Invalid response structure received from server.')
|
|
}
|
|
} catch (err) {
|
|
setLoading(false)
|
|
setError(err.message || 'Login failed. Please check credentials.')
|
|
triggerShake()
|
|
}
|
|
}
|
|
|
|
const handle2faSubmit = async (e) => {
|
|
e.preventDefault()
|
|
if (!totpCode || totpCode.length < 6) {
|
|
setError('Please enter a valid 6-digit authentication code.')
|
|
triggerShake()
|
|
return
|
|
}
|
|
|
|
setLoading(true)
|
|
setError('')
|
|
|
|
try {
|
|
const res = await adminService.auth.verifyTotp(form.email, totpCode, sessionToken)
|
|
if (res?.data?.user) {
|
|
onLoginSuccess(res.data.user)
|
|
} else {
|
|
throw new Error('2FA verification failed.')
|
|
}
|
|
} catch (err) {
|
|
setLoading(false)
|
|
setError(err.message || 'Invalid 2FA code. Please try again.')
|
|
triggerShake()
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="login-root">
|
|
<GridLines />
|
|
<Orbs />
|
|
|
|
{/* ── Left panel — brand hero ── */}
|
|
<aside className={`login-hero ${mounted ? 'mounted' : ''}`} aria-hidden="true">
|
|
<div className="hero-content">
|
|
<div className="hero-badge">
|
|
<span className="badge-dot" />
|
|
System Live & Connected
|
|
</div>
|
|
|
|
<h1 className="hero-title">
|
|
CureSelect<br />
|
|
<span className="hero-title-accent">Healthcare LLP</span>
|
|
</h1>
|
|
<p className="hero-subtitle">
|
|
Super Admin Control Center — manage patients, caregivers, teleconsults, hospital networks and enterprise operations from one unified dashboard.
|
|
</p>
|
|
|
|
<div className="hero-stats">
|
|
<div className="stat-card">
|
|
<span className="stat-value">2,400+</span>
|
|
<span className="stat-label">Active Patients</span>
|
|
</div>
|
|
<div className="stat-card">
|
|
<span className="stat-value">340+</span>
|
|
<span className="stat-label">Caregivers</span>
|
|
</div>
|
|
<div className="stat-card">
|
|
<span className="stat-value">99.9%</span>
|
|
<span className="stat-label">System Uptime</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="hero-graphic">
|
|
<div className="pulse-ring pulse-ring-1" />
|
|
<div className="pulse-ring pulse-ring-2" />
|
|
<div className="pulse-ring pulse-ring-3" />
|
|
<div className="hero-icon-center">
|
|
<svg width="64" height="64" viewBox="0 0 44 44" fill="none">
|
|
<defs>
|
|
<linearGradient id="hg1" x1="0" y1="0" x2="44" y2="44" gradientUnits="userSpaceOnUse">
|
|
<stop offset="0%" stopColor="#3b82f6"/>
|
|
<stop offset="100%" stopColor="#14b8a6"/>
|
|
</linearGradient>
|
|
</defs>
|
|
<path d="M22 2L4 10v12c0 10.5 7.7 20.3 18 22.6C32.3 42.3 40 32.5 40 22V10L22 2z"
|
|
fill="url(#hg1)" opacity="0.2"/>
|
|
<path d="M22 2L4 10v12c0 10.5 7.7 20.3 18 22.6C32.3 42.3 40 32.5 40 22V10L22 2z"
|
|
stroke="url(#hg1)" strokeWidth="1.5" fill="none"/>
|
|
<line x1="22" y1="13" x2="22" y2="31" stroke="url(#hg1)" strokeWidth="2.5" strokeLinecap="round"/>
|
|
<line x1="13" y1="22" x2="31" y2="22" stroke="url(#hg1)" strokeWidth="2.5" strokeLinecap="round"/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="hero-footer">
|
|
<span>CureSelect Healthcare LLP © 2025</span>
|
|
<span>·</span>
|
|
<span>Version 1.0.0</span>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* ── Right panel — login form ── */}
|
|
<main className={`login-panel ${mounted ? 'mounted' : ''}`}>
|
|
<div className={`login-card ${shake ? 'shake' : ''}`}>
|
|
|
|
{/* Card header */}
|
|
<div className="card-header">
|
|
<div className="card-logo">
|
|
<ShieldLogo />
|
|
</div>
|
|
<div className="card-title-block">
|
|
<h2 className="card-title">Super Admin</h2>
|
|
<p className="card-subtitle">Secure administrative access</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Divider */}
|
|
<div className="card-divider">
|
|
<span>{requires2fa ? 'Two-Factor Authentication' : 'Sign in to your account'}</span>
|
|
</div>
|
|
|
|
{/* Error banner */}
|
|
{error && (
|
|
<div className="error-banner" role="alert">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<circle cx="12" cy="12" r="10"/>
|
|
<line x1="12" y1="8" x2="12" y2="12"/>
|
|
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
|
</svg>
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{requires2fa ? (
|
|
/* ── 2FA TOTP Form ── */
|
|
<form onSubmit={handle2faSubmit} className="login-form" noValidate>
|
|
<p style={{ color: '#94a3b8', fontSize: '0.88rem', marginBottom: '1rem', lineHeight: '1.5' }}>
|
|
Enter the 6-digit verification code from your Authenticator app for <strong>{form.email}</strong>.
|
|
</p>
|
|
|
|
<div className="field-group">
|
|
<label htmlFor="totpCode" className="field-label">
|
|
Authentication Code
|
|
</label>
|
|
<div className="field-wrapper">
|
|
<span className="field-icon">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
|
</svg>
|
|
</span>
|
|
<input
|
|
id="totpCode"
|
|
ref={totpRef}
|
|
type="text"
|
|
name="totpCode"
|
|
maxLength={6}
|
|
value={totpCode}
|
|
onChange={(e) => setTotpCode(e.target.value.trim())}
|
|
placeholder="123456"
|
|
className="field-input"
|
|
autoComplete="one-time-code"
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
className={`login-btn ${loading ? 'loading' : ''}`}
|
|
disabled={loading}
|
|
>
|
|
{loading ? <><Spinner /> Verifying…</> : 'Verify & Sign In'}
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
className="forgot-btn"
|
|
style={{ marginTop: '0.75rem', width: '100%', textAlign: 'center' }}
|
|
onClick={() => setRequires2fa(false)}
|
|
>
|
|
← Back to Login
|
|
</button>
|
|
</form>
|
|
) : (
|
|
/* ── Standard Login Form ── */
|
|
<form onSubmit={handleSubmit} className="login-form" noValidate>
|
|
|
|
{/* Email field */}
|
|
<div className="field-group">
|
|
<label htmlFor="email" className="field-label">
|
|
Email Address
|
|
</label>
|
|
<div className="field-wrapper">
|
|
<span className="field-icon">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
|
|
<polyline points="22,6 12,13 2,6"/>
|
|
</svg>
|
|
</span>
|
|
<input
|
|
id="email"
|
|
ref={emailRef}
|
|
type="email"
|
|
name="email"
|
|
value={form.email}
|
|
onChange={handleChange}
|
|
placeholder="admin@cureselect.com"
|
|
className="field-input"
|
|
autoComplete="username"
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Password field */}
|
|
<div className="field-group">
|
|
<label htmlFor="password" className="field-label">
|
|
Password
|
|
</label>
|
|
<div className="field-wrapper">
|
|
<span className="field-icon">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
|
</svg>
|
|
</span>
|
|
<input
|
|
id="password"
|
|
type={showPwd ? 'text' : 'password'}
|
|
name="password"
|
|
value={form.password}
|
|
onChange={handleChange}
|
|
placeholder="Enter your password"
|
|
className="field-input"
|
|
autoComplete="current-password"
|
|
required
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="pwd-toggle"
|
|
onClick={() => setShowPwd(v => !v)}
|
|
aria-label={showPwd ? 'Hide password' : 'Show password'}
|
|
>
|
|
<EyeIcon open={showPwd} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Remember + Forgot */}
|
|
<div className="form-options">
|
|
<label className="remember-label" htmlFor="remember">
|
|
<input
|
|
type="checkbox"
|
|
id="remember"
|
|
name="remember"
|
|
checked={form.remember}
|
|
onChange={handleChange}
|
|
className="remember-check"
|
|
/>
|
|
<span className="custom-check" aria-hidden="true" />
|
|
Remember me
|
|
</label>
|
|
<button type="button" className="forgot-btn">Forgot password?</button>
|
|
</div>
|
|
|
|
{/* Submit */}
|
|
<button
|
|
id="login-submit-btn"
|
|
type="submit"
|
|
className={`login-btn ${loading ? 'loading' : ''}`}
|
|
disabled={loading}
|
|
>
|
|
{loading ? (
|
|
<>
|
|
<Spinner />
|
|
Authenticating…
|
|
</>
|
|
) : (
|
|
<>
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>
|
|
<polyline points="10 17 15 12 10 7"/>
|
|
<line x1="15" y1="12" x2="3" y2="12"/>
|
|
</svg>
|
|
Sign In to Dashboard
|
|
</>
|
|
)}
|
|
</button>
|
|
</form>
|
|
)}
|
|
|
|
{/* Demo hint */}
|
|
<div className="demo-hint">
|
|
<span className="hint-icon">💡</span>
|
|
<span>Default: <strong>admin@cureselect.com</strong> / <strong>CureSelect@2025</strong></span>
|
|
</div>
|
|
|
|
{/* Security badge */}
|
|
<div className="security-badge">
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<rect x="3" y="11" width="18" height="11" rx="2"/>
|
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
|
</svg>
|
|
256-bit SSL encrypted · Direct API Integration
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|