152 lines
7.3 KiB
JavaScript
152 lines
7.3 KiB
JavaScript
import React, { useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useAuth } from '../context/useAuth';
|
|
import { ShieldCheck, Lock, User, ArrowRight, Sparkles, Eye, EyeOff } from 'lucide-react';
|
|
|
|
/*
|
|
* There is exactly one interactive account: `admin`.
|
|
*
|
|
* This screen used to offer an Admin/User tab pair, which was always more
|
|
* suggestion than control - the server assigns the role from the account the
|
|
* password belongs to (see AuthContext.login), so the tab never affected the
|
|
* outcome. With the `user` account switched off in the backend config, the
|
|
* tabs would have offered a sign-in that could only ever fail, so they are gone
|
|
* and this is a single admin form.
|
|
*/
|
|
export function LoginPage() {
|
|
const navigate = useNavigate();
|
|
const { login } = useAuth();
|
|
|
|
const [username, setUsername] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const handleFormSubmit = async (e) => {
|
|
e.preventDefault();
|
|
setError('');
|
|
if (!username.trim() || !password.trim()) {
|
|
setError('Please enter both username and password.');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
|
|
try {
|
|
const user = await login(username, password);
|
|
setLoading(false);
|
|
// Only `admin` can sign in, and every route is admin-only, so there is
|
|
// one destination. Sending a non-admin to /login instead would loop.
|
|
navigate(user.role === 'admin' ? '/admin' : '/login');
|
|
} catch (err) {
|
|
setLoading(false);
|
|
// Surface the server's own message: it distinguishes bad credentials
|
|
// from the lockout after repeated failures, which a generic string hides.
|
|
setError(err?.message || 'Login failed. Check your username and password.');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen w-full bg-white text-ink-950 flex items-center justify-center p-4 relative overflow-hidden font-sans">
|
|
{/* Background Glow Accents - warmed up to read against a white ground */}
|
|
<div className="absolute -top-40 -left-40 h-96 w-96 rounded-full bg-amber-400/25 blur-3xl pointer-events-none" />
|
|
<div className="absolute -bottom-40 -right-40 h-96 w-96 rounded-full bg-leaf-500/15 blur-3xl pointer-events-none" />
|
|
|
|
<div className="w-full max-w-md relative z-10">
|
|
{/* Header Branding */}
|
|
<div className="text-center mb-8">
|
|
<div className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full bg-amber-500/15 border border-amber-500/40 text-amber-600 text-xs font-semibold mb-3">
|
|
<Sparkles className="h-3.5 w-3.5" /> Brand Catalog & Store Intelligence
|
|
</div>
|
|
<h1 className="text-3xl font-bold tracking-tight text-ink-950 font-display">System Authentication</h1>
|
|
<p className="text-sm text-slate-500 mt-1">Please enter your username and password to sign in</p>
|
|
</div>
|
|
|
|
{/* Main Card */}
|
|
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-lg">
|
|
{/* Single account banner - replaces the old Admin/User tab pair */}
|
|
<div className="flex items-center justify-center gap-2 py-3 px-4 mb-6 rounded-xl bg-amber-500/10 border border-amber-500/30 text-xs font-bold text-amber-700">
|
|
<ShieldCheck className="h-4 w-4" /> Administrator Sign-In
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="mb-4 rounded-lg bg-maroon-100 border border-maroon-500/30 p-3 text-xs text-maroon-600">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Login Form */}
|
|
<form onSubmit={handleFormSubmit} className="space-y-4">
|
|
<div>
|
|
<label className="block text-xs font-medium text-slate-600 mb-1.5">Username</label>
|
|
<div className="relative">
|
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
|
<input
|
|
type="text"
|
|
required
|
|
value={username}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
placeholder="Enter admin username"
|
|
className="w-full rounded-xl border border-ink-900/15 bg-white py-2.5 pl-9 pr-4 text-sm text-ink-950 placeholder-slate-400 focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/25"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-medium text-slate-600 mb-1.5">Password</label>
|
|
<div className="relative">
|
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
|
{/* pr-11, unlike the username field's pr-4, keeps a long
|
|
password from running underneath the toggle button. */}
|
|
<input
|
|
type={showPassword ? 'text' : 'password'}
|
|
required
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
placeholder="Enter password"
|
|
className="w-full rounded-xl border border-ink-900/15 bg-white py-2.5 pl-9 pr-11 text-sm text-ink-950 placeholder-slate-400 focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/25"
|
|
/>
|
|
{/*
|
|
type="button" is load-bearing: a <button> inside a <form>
|
|
defaults to type="submit", so peeking at the password would
|
|
fire a half-typed login attempt instead of revealing it.
|
|
|
|
The icon shows the ACTION, not the state - an open eye means
|
|
"reveal", which is the convention every browser and password
|
|
manager uses. aria-pressed carries the state for screen
|
|
readers, which the glyph alone cannot.
|
|
*/}
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPassword((shown) => !shown)}
|
|
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
|
aria-pressed={showPassword}
|
|
title={showPassword ? 'Hide password' : 'Show password'}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-lg p-1.5 text-slate-400 hover:text-slate-600 hover:bg-ink-900/5 focus:outline-none focus:ring-2 focus:ring-amber-500/25 transition-colors cursor-pointer"
|
|
>
|
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="w-full flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-600 hover:to-amber-700 text-white py-3 text-sm font-bold shadow-md shadow-amber-500/25 transition-all duration-200 active:scale-98 disabled:opacity-50 cursor-pointer"
|
|
>
|
|
{loading ? 'Authenticating...' : 'Sign In as ADMIN'}
|
|
<ArrowRight className="h-4 w-4" />
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
{/* Footer info */}
|
|
<p className="text-center text-xs text-slate-500 mt-6">
|
|
Powered by pgvector RAG Model & Multi-Store Nutrition Intelligence Engine
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|