chore(ts-migration): migrate the non-agent pages to TypeScript

Phase 9, first batch: 18 pages. Seventeen emit byte-identical JavaScript, the
eighteenth differs only by a JSDoc cast becoming a real annotation, and the
production bundle is byte-identical to be49184.

SCOPE, decided by imports. Twenty-two of the forty pages import `lib/skills`,
`lib/agents`, `ai-assistant`, `components/skills`, `ui-tree` or `ui-editor`, and
are deferred to Phase 11 with the rest of that region. `Owliver.jsx` is in THIS
batch despite the name: it imports only `krowHooks`, `krowScore`, `krowAi` and
`ui/button`, and is a profile-building conversation page rather than any part of
the agent runtime.

`DesignSystem` is the headline result. It was the single worst file in Phase 6 -
111 errors when the design system was first renamed - and it arrived here with
none, because those were never its errors: they were the primitives' inferred
props, and fixing them at the source fixed every consumer.

Three pages needed real work, and each was the author's own intent made
explicit:

  - `admin/Login` carried `useState(/** @type {{email?: string, password?: string}} */ ({}))`.
    JSDoc casts stop applying in a `.tsx` file, so that became a real type
    argument, and `validate`'s accumulator - built empty and filled per failed
    rule - needed the same shape. The login flow itself is untouched: the
    generic 401 message, the 429 branch and the `remember` field all stand.

  - `Owliver` gets `new Promise<void>`, because its `resolve()` takes no
    argument.

  - `Candidates` names the element type of a `Set` built from `any[]`, which
    otherwise infers `Set<unknown>` and makes every option a `ReactNode` error.

LINT CAUGHT A REGRESSION THE OTHER CHECKS DID NOT. The generator adds an entity
import when it sees the type NAME anywhere in the file, which for four pages
with no props at all left an import nothing used - four `unused-imports` ERRORS,
taking `npm run lint` from exit 0 to exit 1 while typecheck, tests, the bundle
and seventeen of eighteen emit comparisons all stayed green. Removed. The
generator's import rule is too eager and wants narrowing before the next batch.

Verified: tsc 21 -> 21, set-difference showing zero introduced and zero removed;
zero errors in any of the 18 pages; lint back to 0 errors and 289 warnings;
npm test 1684/1691 with the same seven failures; Owliver baseline 59/59;
production bundle byte-identical; baseline artifacts untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
This commit is contained in:
2026-09-18 12:47:04 +05:30
parent be49184006
commit fcdaa32f4d
18 changed files with 89 additions and 20 deletions

251
src/pages/admin/Login.tsx Normal file
View File

@@ -0,0 +1,251 @@
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';
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();
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(() => navigate(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>
);
}