370 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|