277 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|