Files
catalogue_frontend/src/pages/LoginPage.jsx
2026-08-25 11:06:54 +05:30

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 &amp; 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 &amp; Multi-Store Nutrition Intelligence Engine
</p>
</div>
</div>
);
}