Files
krow_talent_app/src/pages/admin/Login.tsx
Aravind 1ef0c6fc8a
Some checks failed
CI / check (push) Failing after 5m6s
layout change ui fix
2026-09-29 16:59:13 +05:30

277 lines
14 KiB
TypeScript

import React, { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { AlertCircle, ArrowRight, Loader2, Eye, EyeOff, Sparkles, CheckCircle2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { KROW_LOGO_URL } from '@/assets/brand';
import { Checkbox } from '@/components/ds';
import { useAuth } from '@/lib/AuthContext';
import { safeReturnTo } from '@/lib/authReturnTo';
const DEMO_EMAIL = 'demo@krow.app';
/**
* The one message every failed sign-in shows.
*
* The API answers a wrong password, an unknown address, an account with no
* password and a suspended account with the same 401 and the same body, so that
* a stranger cannot learn which addresses are registered. Rendering the server's
* message verbatim would be equally correct; this is the same statement in the
* product's own voice, and — importantly — it is used for every failure, so this
* page does not reintroduce the distinction the API removes.
*/
const GENERIC_AUTH_ERROR =
'That email and password do not match an account. Check both and try again.';
export default function AdminLogin() {
const navigate = useNavigate();
const location = useLocation();
const { login } = useAuth();
/* Two kinds of "where was I going", and they are not interchangeable.
A ?returnTo= in the QUERY is put there by the API, not by this app. The
OAuth authorization endpoint redirects here when nobody is signed in,
carrying its own path and query so the authorization request survives the
round trip. It names a route on the BACKEND (/oauth/authorize), which React
Router does not have and must not be given — routing to it client-side
renders the not-found page and the connector never finishes. So it is
followed with a real navigation, which safeReturnTo reports as `browser`.
`location.state.from` is the in-app case: a guard bounced someone off a
page in this bundle (ProtectedRoute and AdminRoute both set it). That is a
router destination and stays one, unchanged.
The query wins when both exist. It is the more specific instruction, and it
is the one the person is actually in the middle of. */
const fromQuery = safeReturnTo(location.search);
const from = location.state?.from;
const returnTo = typeof from === 'string' && from.startsWith('/admin') && from !== '/admin/login'
? from
: '/admin';
const [form, setForm] = useState({ email: DEMO_EMAIL, password: '', remember: true });
const [showPassword, setShowPassword] = useState(false);
const [errors, setErrors] = useState<{ email?: string; password?: string }>({});
const [authError, setAuthError] = useState('');
const [status, setStatus] = useState('idle');
const validate = () => {
/* Same shape the state carries; built empty and filled per failed rule, so
inference alone fixes it at `{}` and neither assignment compiles. */
const next: { email?: string; password?: string } = {};
if (!form.email.trim()) next.email = 'Enter your work email';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) next.email = 'Enter a valid email address';
// Only "did you type anything". A client-side length rule here would be a
// guess at the server's policy that goes stale the moment the policy moves,
// and it tells someone with a valid short legacy password that their own
// password is invalid.
if (!form.password) next.password = 'Enter your password';
setErrors(next);
return Object.keys(next).length === 0;
};
const submit = async (event) => {
event.preventDefault();
setAuthError('');
if (!validate()) return;
setStatus('loading');
try {
// The session is created server-side and arrives as an HttpOnly cookie.
// Nothing on this page sees, stores or forwards a token — there is no
// token here to mishandle.
await login({
email: form.email.trim(),
password: form.password,
rememberMe: form.remember,
});
} catch (error) {
setStatus('idle');
// One message for every rejection. A rate-limited attempt is the single
// exception worth distinguishing, because "wait a moment" is advice the
// person can act on and it reveals nothing about any account.
setAuthError(error?.status === 429
? (error.message || 'Too many sign-in attempts. Wait a few minutes and try again.')
: GENERIC_AUTH_ERROR);
return;
}
setStatus('success');
setTimeout(() => {
// `replace`, not `assign`: the login should not sit in history between
// the authorization request and the consent screen, or Back from consent
// returns to a login the person has already completed.
if (fromQuery?.via === 'browser') window.location.replace(fromQuery.path);
else navigate(fromQuery?.path ?? returnTo, { replace: true });
}, 320);
};
const busy = status !== 'idle';
return (
<main className="min-h-screen w-full bg-[#F8FAFC] flex items-center justify-center p-4 sm:p-6 lg:p-8 font-sans selection:bg-[#F9E547] selection:text-[#0F172A]">
<div className="w-full max-w-5xl bg-white border border-[#E2E8F0] rounded-3xl shadow-xl shadow-slate-900/5 overflow-hidden grid grid-cols-1 md:grid-cols-2 min-h-[580px]">
{/* Left Visual Section — Clean White & KROW Yellow Theme */}
<div className="relative hidden md:flex flex-col justify-between p-8 sm:p-10 bg-[#FAFAFA] overflow-hidden border-r border-[#E2E8F0]">
{/* Subtle Decorative Accents */}
<div className="absolute -top-20 -left-20 w-64 h-64 rounded-full bg-[#0838E0]/5 blur-3xl pointer-events-none" />
<div className="absolute -bottom-20 -right-20 w-80 h-80 rounded-full bg-[#F9E547]/10 blur-3xl pointer-events-none" />
{/* Top Brand & Badge */}
<div className="relative z-10 flex items-center justify-between">
<div className="flex items-center gap-3">
<img src={KROW_LOGO_URL} alt="KROW" className="h-8 w-auto object-contain" />
<span className="text-[11px] font-extrabold uppercase tracking-widest px-3 py-1 rounded-full bg-[#F9E547] text-[#0F172A] border border-[#FACC15] shadow-sm">
Enterprise
</span>
</div>
</div>
{/* Central KROW Visual Image */}
<div className="relative z-10 my-auto py-6 flex flex-col items-center">
<div className="relative rounded-2xl overflow-hidden shadow-md border border-[#E2E8F0] bg-white p-2 max-w-xs">
<img
src="/krow-logo-visual.jpeg"
alt="KROW Logo Visual"
className="w-full h-auto object-cover rounded-xl transition-transform duration-500 hover:scale-105"
/>
</div>
</div>
{/* Bottom Hero Tagline */}
<div className="relative z-10 space-y-2">
<div className="flex items-center gap-1.5 text-xs font-bold text-[#0838E0] uppercase tracking-wider">
<Sparkles className="w-3.5 h-3.5 text-[#0838E0]" />
Recruiting Intelligence Platform
</div>
<h2 className="text-xl font-bold text-[#0F172A] tracking-tight leading-snug">
Workforce decisions powered by intelligence
</h2>
<p className="text-xs text-[#64748B] leading-relaxed">
AI-driven candidate screening, verified score dimensions, and talent matching.
</p>
</div>
</div>
{/* Right Login Form Section */}
<div className="flex flex-col justify-center p-6 sm:p-10 lg:p-12 bg-white">
<div className="w-full max-w-md mx-auto space-y-6">
{/* Header */}
<div>
<div className="flex items-center gap-2 mb-3 md:hidden">
<img src={KROW_LOGO_URL} alt="KROW" className="h-7 w-auto object-contain" />
<span className="text-[10px] font-bold uppercase tracking-widest px-2 py-0.5 rounded bg-[#F9E547] text-[#0F172A]">
Admin
</span>
</div>
<h1 className="text-2xl font-extrabold text-[#0F172A] tracking-tight">Welcome back</h1>
<p className="mt-1.5 text-xs text-[#64748B] leading-relaxed">
Sign in to manage hiring, candidates, talent intelligence, and workforce decisions.
</p>
</div>
{/* Error Alert */}
{authError && (
<div role="alert" className="flex items-start gap-2.5 rounded-xl border border-red-200 bg-red-50 p-3.5 text-red-800">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-600" aria-hidden="true" />
<p className="text-xs leading-relaxed">{authError}</p>
</div>
)}
{/* Form */}
<form onSubmit={submit} noValidate className="space-y-4">
{/* Email */}
<div className="space-y-1.5">
<label className="block text-xs font-bold text-[#334155] uppercase tracking-wider">
Work email
</label>
<input
type="email"
autoComplete="username"
autoFocus
disabled={busy}
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
className={cn(
"w-full h-11 px-3.5 rounded-xl bg-[#FAF9F5] border text-sm text-[#0F172A] placeholder-slate-400 focus:outline-none transition-all",
errors.email ? "border-red-500 focus:border-red-500" : "border-[#E2E8F0] focus:border-[#F59E0B] focus:ring-2 focus:ring-[#FEF3C7]"
)}
placeholder="name@company.com"
/>
{errors.email && <p className="text-xs text-red-600 font-medium">{errors.email}</p>}
</div>
{/* Password */}
<div className="space-y-1.5">
<label className="block text-xs font-bold text-[#334155] uppercase tracking-wider">
Password
</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
autoComplete="current-password"
disabled={busy}
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
className={cn(
"w-full h-11 pl-3.5 pr-10 rounded-xl bg-[#FAF9F5] border text-sm text-[#0F172A] placeholder-slate-400 focus:outline-none transition-all",
errors.password ? "border-red-500 focus:border-red-500" : "border-[#E2E8F0] focus:border-[#F59E0B] focus:ring-2 focus:ring-[#FEF3C7]"
)}
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-[#0F172A] transition-colors"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{errors.password && <p className="text-xs text-red-600 font-medium">{errors.password}</p>}
</div>
{/* Remember & Forgot */}
<div className="flex items-center justify-between text-xs pt-1">
<label className="flex cursor-pointer items-center gap-2 text-[#475569] font-medium">
<Checkbox
checked={form.remember}
onCheckedChange={(v) => setForm({ ...form, remember: Boolean(v) })}
disabled={busy}
/>
Remember me
</label>
<button
type="button"
disabled={busy}
onClick={() => setAuthError('Password resets are not available yet — ask an administrator to set a new password.')}
className="text-[#D97706] hover:text-[#B45309] font-semibold transition-colors"
>
Forgot password?
</button>
</div>
{/* Primary CTA Button — KROW Yellow Theme */}
<button
type="submit"
disabled={busy}
className="w-full h-11 mt-3 rounded-xl bg-[#F9E547] hover:bg-[#FACC15] active:bg-[#EAB308] disabled:opacity-50 text-[#0F172A] text-sm font-bold flex items-center justify-center gap-2 border border-[#EAB308]/40 shadow-md shadow-amber-300/40 transition-all cursor-pointer"
>
{status === 'loading' && <Loader2 className="w-4 h-4 animate-spin text-[#0F172A]" />}
{status === 'success' && <CheckCircle2 className="w-4 h-4 text-[#0F172A]" />}
<span>{status === 'loading' ? 'Signing in...' : status === 'success' ? 'Signed in' : 'Continue'}</span>
{status === 'idle' && <ArrowRight className="w-4 h-4" />}
</button>
</form>
<p className="text-center text-xs text-[#94A3B8] leading-relaxed pt-2">
Sign in with your Krow account. Passwords are set by an administrator with
the <span className="text-[#64748B] font-semibold">setpassword</span> utility.
</p>
</div>
</div>
</div>
</main>
);
}