import { useState, type FormEvent, type ReactNode } from 'react'; import { Navigate, useNavigate } from 'react-router-dom'; import { AlertCircle, ArrowRight, Eye, EyeOff, Loader2, Lock, Mail, ShieldCheck, Sparkles, } from 'lucide-react'; import { useAuth } from '@/auth/AuthContext'; import { HOME_ROUTE } from '@/auth/roles'; import { MIN_PASSWORD_LENGTH, PasswordSetupRequiredError, setInitialPassword, } from '@/auth/session'; /** * Sign-in — KROW's full-bleed auth archetype. * * A single 1024px card at 28px radius over the ambient canvas, split two-up: * a tinted brand panel on the left carrying an inset product image, and the * form on the right at a 448px measure. Rhythm is 24px between regions, 16px * between fields, 6px inside a field. * * One form for all three roles. The old console had three separate login paths * because the backend compares a client-declared roleid against the stored one; * here the role is read off the login response, so a person signs in once and * lands where their account says they belong. */ export function LoginPage() { const { user, signIn } = useAuth(); const navigate = useNavigate(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [isPasswordVisible, setIsPasswordVisible] = useState(false); const [error, setError] = useState(null); const [isBusy, setIsBusy] = useState(false); /** * The userid `applogin` returned for an account with no password, or null. * * Non-null flips this page into its second state. It is deliberately not a * route: the userid only exists because a sign-in attempt just produced it, * and a `/set-password` URL that could be opened cold would be a way to set * any account's password from nothing. */ const [setupUserid, setSetupUserid] = useState(null); const [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); if (user) return ; async function handleSubmit(event: FormEvent) { event.preventDefault(); setError(null); setIsBusy(true); try { const session = await signIn(email, password); navigate(HOME_ROUTE[session.role], { replace: true }); } catch (cause) { // Not a failure — the first step. The account exists and has no password, // and the backend handed back the userid to set one against. if (cause instanceof PasswordSetupRequiredError) { setSetupUserid(cause.userid); setNewPassword(''); setConfirmPassword(''); } else { setError(cause instanceof Error ? cause.message : 'Sign-in failed'); } } finally { setIsBusy(false); } } /** * Set the password, then sign in with it. * * Signing in afterwards rather than sending the person back to the form: they * have just typed the password twice, and `applogin` is the only proof the * write actually took. */ async function handleSetup(event: FormEvent) { event.preventDefault(); if (setupUserid === null) return; setError(null); if (newPassword !== confirmPassword) { setError('Those two passwords do not match.'); return; } setIsBusy(true); try { await setInitialPassword(setupUserid, newPassword); const session = await signIn(email, newPassword); navigate(HOME_ROUTE[session.role], { replace: true }); } catch (cause) { setError(cause instanceof Error ? cause.message : 'Could not set the password'); } finally { setIsBusy(false); } } const canSubmit = email.trim() !== '' && password !== '' && !isBusy; const canSetup = newPassword.length >= MIN_PASSWORD_LENGTH && confirmPassword !== '' && !isBusy; return (
{setupUserid === null ? ( setIsPasswordVisible((visible) => !visible)} onSubmit={handleSubmit} /> ) : ( setIsPasswordVisible((visible) => !visible)} onSubmit={handleSetup} onBack={() => { setSetupUserid(null); setError(null); }} /> )}
); } /* ──────────────────────────────────────────────────────────────────────────── Left — the brand panel ──────────────────────────────────────────────────────────────────────────── */ /** * A tint, not a slab. * * The reference panel is a soft wash rather than a saturated block, so the card * reads as one surface with a warm side rather than two glued halves. The * gradient runs from the ambient canvas's violet stop into its warm stop, which * is what ties the card to the page behind it. */ function BrandPanel() { return (
Nearle CONSOLE
{/* The inset image card: white, 8px inset, hairline border, soft shadow. Hidden on a phone, where it would push the form below the fold. */}
Retail operations platform

Every store, every till, one console.

Onboard tenants and branches, publish the catalogue, and watch online orders and counter sales land side by side.

); } /* ──────────────────────────────────────────────────────────────────────────── Right — the form ──────────────────────────────────────────────────────────────────────────── */ interface FormPanelProps { email: string; password: string; isPasswordVisible: boolean; error: string | null; isBusy: boolean; canSubmit: boolean; onEmail: (value: string) => void; onPassword: (value: string) => void; onToggleVisible: () => void; onSubmit: (event: FormEvent) => void; } function FormPanel({ email, password, isPasswordVisible, error, isBusy, canSubmit, onEmail, onPassword, onToggleVisible, onSubmit, }: FormPanelProps) { return (

Welcome back

Sign in to manage tenants, branches, catalogue and counter sales.

}> onEmail(event.target.value)} placeholder="you@company.com" autoComplete="username" autoFocus required style={inputStyle} /> } action={ } > onPassword(event.target.value)} placeholder="••••••••" autoComplete="current-password" required style={{ ...inputStyle, paddingRight: 40 }} /> {/* Neither "Keep me signed in" nor "Forgot password?" is here any more. The checkbox was initialised true, toggled, rendered — and never read: `handleSubmit` and `signIn(email, password)` never received it, so ticking or clearing it changed nothing about how long the session lasted. The link had no handler at all, and there is no password-reset endpoint in Fiesta to give it one. Both promised something the system does not do. */}

Use the account your administrator set up for you. Your role decides which workspace opens — you do not pick one.

); } /* ──────────────────────────────────────────────────────────────────────────── Right — first sign-in, setting the password ──────────────────────────────────────────────────────────────────────────── */ interface SetupPanelProps { email: string; newPassword: string; confirmPassword: string; isPasswordVisible: boolean; error: string | null; isBusy: boolean; canSubmit: boolean; onNewPassword: (value: string) => void; onConfirmPassword: (value: string) => void; onToggleVisible: () => void; onSubmit: (event: FormEvent) => void; onBack: () => void; } /** * The second state of this page, not a second page. * * An account created by `createtenantuser` or `createtenantlocation` is spawned * with an empty password, so its owner's first sign-in cannot succeed and there * is no reset email to fall back on. Before this existed the page detected the * condition and then told the person to go and find an administrator — for an * account that was working as designed. */ function SetupPanel({ email, newPassword, confirmPassword, isPasswordVisible, error, isBusy, canSubmit, onNewPassword, onConfirmPassword, onToggleVisible, onSubmit, onBack, }: SetupPanelProps) { const isTooShort = newPassword !== '' && newPassword.length < MIN_PASSWORD_LENGTH; const isMismatched = confirmPassword !== '' && newPassword !== confirmPassword; return (
First sign-in

Choose a password

{email} has no password yet. Set one now and we will sign you straight in.

} action={ } > onNewPassword(event.target.value)} placeholder={`At least ${MIN_PASSWORD_LENGTH} characters`} autoComplete="new-password" autoFocus required aria-invalid={isTooShort} style={{ ...inputStyle, paddingRight: 40 }} /> }> onConfirmPassword(event.target.value)} placeholder="Type it again" autoComplete="new-password" required aria-invalid={isMismatched} style={{ ...inputStyle, borderColor: isMismatched ? 'rgba(214,69,69,.45)' : 'var(--color-line)', }} /> {isTooShort ? `A few more characters — ${MIN_PASSWORD_LENGTH} is the minimum.` : isMismatched ? 'Those two do not match yet.' : 'Passwords on this backend are stored as typed. Do not reuse one from elsewhere.'}
); } /** A quiet line under the fields — advisory, never an error. */ function Hint({ children }: { children: ReactNode }) { return (

{children}

); } /* ──────────────────────────────────────────────────────────────────────────── Shared form furniture ──────────────────────────────────────────────────────────────────────────── */ function ErrorNote({ message }: { message: string | null }) { if (!message) return null; return (
{message}
); } function SubmitButton({ canSubmit, isBusy, label, busyLabel, }: { canSubmit: boolean; isBusy: boolean; label: string; busyLabel: string; }) { return ( ); } const eyeButtonStyle: React.CSSProperties = { position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)', width: 28, height: 28, display: 'grid', placeItems: 'center', borderRadius: 8, border: 0, background: 'transparent', color: 'var(--color-ink-4)', cursor: 'pointer', }; /* ──────────────────────────────────────────────────────────────────────────── Field ──────────────────────────────────────────────────────────────────────────── */ const inputStyle: React.CSSProperties = { height: 44, width: '100%', borderRadius: 12, border: '1px solid var(--color-line)', background: 'var(--color-surface-subtle)', padding: '0 12px 0 38px', fontSize: 14, fontFamily: 'inherit', color: 'var(--color-ink-1)', outline: 'none', transition: 'border-color .2s, background .2s, box-shadow .2s', }; /** * A labelled field. * * The label is a small-caps overline rather than sentence case — the auth page * is the one screen with only two inputs on it, and the extra weight there * reads as deliberate rather than shouty. */ function Field({ label, htmlFor, icon, action, children, }: { label: string; htmlFor: string; icon: ReactNode; action?: ReactNode; children: ReactNode; }) { return (
{icon} {children} {action}
); }