Finance-Accounts/ar-aging-app/frontend/src/pages/Login.tsx

370 lines
15 KiB
TypeScript

import { FormEvent, useState } from "react";
import { BookOpenCheck, Eye, EyeOff, FileSpreadsheet, Globe, Landmark, Lock, LogIn, Scale, ShieldCheck, User } from "lucide-react";
import { api } from "../api/client";
import { Spinner } from "../components/ui";
import { useAuth } from "../auth";
/**
* Sign-in screen: navy brand panel (desktop) + form on the app canvas.
* Uses the app's "ledgr" tokens — periwinkle primary, navy, lavender canvas — so the
* login feels like the first screen of the dashboard, not a bolt-on.
*/
export default function Login() {
const { login, emailEnabled } = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPw, setShowPw] = useState(false);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [forgot, setForgot] = useState(false);
const submit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setBusy(true);
try {
await login(username.trim(), password);
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
};
return (
<div className="min-h-full flex bg-canvas">
{/* ---------------- brand panel (desktop) ---------------- */}
<aside
className="hidden lg:flex flex-col justify-between w-[44%] max-w-xl p-12 text-white relative overflow-hidden bg-navy"
style={{
backgroundImage: `
radial-gradient(52rem 30rem at -10% -20%, rgba(109,93,232,0.45), transparent 60%),
radial-gradient(40rem 26rem at 110% 115%, rgba(201,195,245,0.22), transparent 60%),
linear-gradient(rgba(255,255,255,0.045) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.045) 1px, transparent 1px)`,
backgroundSize: "auto, auto, 100% 3.25rem, 3.25rem 100%",
}}
>
<div className="flex items-center gap-3">
<span className="inline-flex items-center justify-center w-11 h-11 rounded-2xl bg-primary text-white shadow-pop">
<Landmark size={22} />
</span>
<div>
<div className="text-base font-semibold leading-tight">Amazon A/R Aging</div>
<div className="text-xs text-lavender">Month-End Closing · Utopia Brands Finance</div>
</div>
</div>
<div className="max-w-md">
<h1 className="text-3xl xl:text-4xl font-semibold leading-tight [text-wrap:balance]">
Every month closed, checked, and accounted for.
</h1>
<p className="mt-4 text-sm leading-relaxed text-lavender-soft/90">
Upload the month's Amazon transaction files and get the full receivable
position — settlements, aging, journal entry, and the audit trail behind
every figure.
</p>
<ul className="mt-8 space-y-3 text-sm">
{[
{ Icon: FileSpreadsheet,
text: "Parses the month's Amazon transaction files 13 marketplaces, millions of rows" },
{ Icon: Scale,
text: "Classifies every settlement paid vs receivable and computes the closing position" },
{ Icon: BookOpenCheck,
text: "AR roll-forward and month-end journal entry, reconciled to the cent" },
{ Icon: Globe,
text: "Each marketplace converted at confirmed month-end ECB rates (local USD)" },
{ Icon: ShieldCheck,
text: "Six month-end controls and a SHA-256 audit trail gate every published figure" },
].map(({ Icon, text }) => (
<li key={text} className="flex items-start gap-3">
<span className="mt-0.5 inline-flex items-center justify-center w-6 h-6 rounded-lg bg-white/10 text-lavender shrink-0">
<Icon size={14} />
</span>
<span className="text-lavender-soft/90 leading-snug">{text}</span>
</li>
))}
</ul>
</div>
<p className="text-[11px] text-lavender/60">
Amazon-only · 13 marketplaces · reconciles to the cent
</p>
</aside>
{/* ---------------- sign-in form ---------------- */}
<main
className="flex-1 flex items-center justify-center p-6 sm:p-10"
style={{
backgroundImage:
"radial-gradient(38rem 24rem at 85% -10%, rgba(109,93,232,0.10), transparent 65%)",
}}
>
<div className="w-full max-w-sm motion-safe:animate-[login-in_.45s_ease-out]">
{/* compact brand header for mobile, where the panel is hidden */}
<div className="lg:hidden flex items-center gap-2.5 mb-8">
<span className="inline-flex items-center justify-center w-9 h-9 rounded-xl bg-primary text-white shadow-card">
<Landmark size={18} />
</span>
<div>
<div className="text-sm font-semibold leading-tight text-ink">Amazon A/R Aging</div>
<div className="text-[11px] text-muted">Month-End Closing</div>
</div>
</div>
{forgot ? (
<ForgotPassword
initialUsername={username}
onDone={() => setForgot(false)}
/>
) : (
<>
<h2 className="text-2xl font-semibold text-ink">Welcome back</h2>
<p className="mt-1 text-sm text-subink">Sign in to continue to this month's closing.</p>
<form onSubmit={submit} className="mt-8 space-y-4">
<div>
<label className="label" htmlFor="login-user">Username</label>
<div className="relative">
<User size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-faint pointer-events-none" />
<input
id="login-user"
className="input pl-10 py-2.5"
placeholder="you@utopiabrands.com"
autoComplete="username"
autoFocus
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
</div>
<div>
<label className="label" htmlFor="login-pw">Password</label>
<div className="relative">
<Lock size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-faint pointer-events-none" />
<input
id="login-pw"
className="input pl-10 pr-11 py-2.5"
type={showPw ? "text" : "password"}
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button
type="button"
aria-label={showPw ? "Hide password" : "Show password"}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded-lg text-faint hover:text-subink hover:bg-neutralbg transition-colors"
onClick={() => setShowPw((v) => !v)}
tabIndex={-1}
>
{showPw ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
{error && (
<div className="rounded-xl border border-bad/30 bg-badbg/60 px-3.5 py-2.5 text-sm text-bad">
{error}
</div>
)}
<button
className="btn-primary w-full justify-center py-2.5 text-[15px]"
type="submit"
disabled={busy || !username.trim() || !password}
>
{busy ? <Spinner /> : <LogIn size={16} />} Sign in
</button>
{emailEnabled && (
<div className="text-right">
<button type="button"
className="text-xs font-medium text-primary hover:underline"
onClick={() => { setError(null); setForgot(true); }}>
Forgot password?
</button>
</div>
)}
</form>
<p className="mt-8 text-xs text-muted leading-relaxed">
{emailEnabled
? "No account? Ask the administrator accounts are created on the server."
: "No account or forgot your password? Ask the administrator accounts are created and reset on the server."}
</p>
</>
)}
</div>
</main>
{/* one-time entrance; browsers honouring reduced motion skip it via motion-safe */}
<style>{`@keyframes login-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }`}</style>
</div>
);
}
/** Forgot-password, one step at a time:
* 1. email → send the code 2. enter + verify the code 3. set the new password. */
function ForgotPassword({ initialUsername, onDone }: {
initialUsername: string; onDone: () => void;
}) {
const [step, setStep] = useState<"email" | "code" | "password" | "done">("email");
const [email, setEmail] = useState(initialUsername);
const [code, setCode] = useState("");
const [next, setNext] = useState("");
const [repeat, setRepeat] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const run = async (fn: () => Promise<void>) => {
setError(null); setBusy(true);
try {
await fn();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
};
const sendCode = () => run(async () => {
await api.requestPasswordCode(email.trim());
setCode("");
setStep("code");
});
const verifyCode = (e: FormEvent) => {
e.preventDefault();
run(async () => {
await api.verifyPasswordCode(code.trim(), email.trim());
setStep("password");
});
};
const reset = (e: FormEvent) => {
e.preventDefault();
run(async () => {
await api.resetPassword(code.trim(), next, email.trim());
setStep("done");
});
};
const Err = () => error && (
<div className="rounded-xl border border-bad/30 bg-badbg/60 px-3.5 py-2.5 text-sm text-bad">
{error}
</div>
);
const Back = () => (
<div className="text-center">
<button type="button" className="text-xs font-medium text-subink hover:text-ink"
onClick={onDone}>
← Back to sign in
</button>
</div>
);
if (step === "done")
return (
<div>
<h2 className="text-2xl font-semibold text-ink">Password updated</h2>
<p className="mt-2 text-sm text-subink">Sign in with your new password.</p>
<button className="btn-primary w-full justify-center py-2.5 mt-6" onClick={onDone}>
<LogIn size={16} /> Back to sign in
</button>
</div>
);
if (step === "email")
return (
<div>
<h2 className="text-2xl font-semibold text-ink">Reset password</h2>
<p className="mt-1 text-sm text-subink">
Step 1 of 3 — we'll email a 6-digit code to your account's address.
</p>
<form className="mt-8 space-y-4" onSubmit={(e) => { e.preventDefault(); sendCode(); }}>
<div>
<label className="label" htmlFor="fp-user">Username (email)</label>
<div className="relative">
<User size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-faint pointer-events-none" />
<input id="fp-user" className="input pl-10 py-2.5" autoComplete="username"
autoFocus placeholder="you@utopiabrands.com"
value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<p className="mt-1.5 text-xs text-muted">
Your <b>@utopiabrands.com</b> account address — codes only go to registered accounts.
</p>
</div>
<Err />
<button className="btn-primary w-full justify-center py-2.5" type="submit"
disabled={busy || !email.trim()}>
{busy ? <Spinner /> : null} Email me a code
</button>
<Back />
</form>
</div>
);
if (step === "code")
return (
<div>
<h2 className="text-2xl font-semibold text-ink">Enter the code</h2>
<p className="mt-1 text-sm text-subink">
Step 2 of 3 — sent to <b className="text-ink">{email.trim()}</b>, valid 10 minutes.
</p>
<form className="mt-8 space-y-4" onSubmit={verifyCode}>
<div>
<label className="label" htmlFor="fp-code">6-digit code from the email</label>
<input id="fp-code" className="input py-2.5 num tracking-[0.35em] text-center"
inputMode="numeric" maxLength={6} autoFocus placeholder=""
value={code} onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))} />
</div>
<Err />
<button className="btn-primary w-full justify-center py-2.5" type="submit"
disabled={busy || code.length !== 6}>
{busy ? <Spinner /> : null} Verify code
</button>
<div className="flex items-center justify-between text-xs">
<button type="button" className="font-medium text-subink hover:text-ink"
onClick={() => setStep("email")}>
← Different email
</button>
<button type="button" className="font-medium text-primary hover:underline"
disabled={busy} onClick={sendCode}>
Resend code
</button>
</div>
<Back />
</form>
</div>
);
return (
<div>
<h2 className="text-2xl font-semibold text-ink">Choose a new password</h2>
<p className="mt-1 text-sm text-subink">Step 3 of 3 — code verified ✓</p>
<form className="mt-8 space-y-4" onSubmit={reset}>
<div>
<label className="label" htmlFor="fp-new">New password (min 8)</label>
<input id="fp-new" className="input py-2.5" type="password" autoFocus
autoComplete="new-password"
value={next} onChange={(e) => setNext(e.target.value)} />
</div>
<div>
<label className="label" htmlFor="fp-rep">Repeat new password</label>
<input id="fp-rep" className="input py-2.5" type="password"
autoComplete="new-password"
value={repeat} onChange={(e) => setRepeat(e.target.value)} />
{repeat.length > 0 && next !== repeat &&
<p className="mt-1.5 text-xs text-bad">Passwords don't match.</p>}
</div>
<Err />
<button className="btn-primary w-full justify-center py-2.5" type="submit"
disabled={busy || next.length < 8 || next !== repeat}>
{busy ? <Spinner /> : null} Set new password
</button>
<Back />
</form>
</div>
);
}