Files
daily_console_web/src/features/auth/LoginPage.tsx
2026-08-26 18:37:28 +05:30

774 lines
24 KiB
TypeScript

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<string | null>(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<number | null>(null);
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
if (user) return <Navigate to={HOME_ROUTE[user.role]} replace />;
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 (
<div
className="login-shell"
style={{ minHeight: '100dvh', display: 'grid', placeItems: 'center' }}
>
<div
className="login-split"
style={{
width: '100%',
maxWidth: 1024,
background: 'var(--color-surface)',
border: '1px solid var(--color-line)',
borderRadius: 28,
boxShadow: '0 24px 56px -12px rgb(15 23 42 / .10), 0 8px 20px -8px rgb(15 23 42 / .05)',
overflow: 'hidden',
}}
>
<BrandPanel />
{setupUserid === null ? (
<FormPanel
email={email}
password={password}
isPasswordVisible={isPasswordVisible}
error={error}
isBusy={isBusy}
canSubmit={canSubmit}
onEmail={setEmail}
onPassword={setPassword}
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
onSubmit={handleSubmit}
/>
) : (
<SetupPanel
email={email}
newPassword={newPassword}
confirmPassword={confirmPassword}
isPasswordVisible={isPasswordVisible}
error={error}
isBusy={isBusy}
canSubmit={canSetup}
onNewPassword={setNewPassword}
onConfirmPassword={setConfirmPassword}
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
onSubmit={handleSetup}
onBack={() => {
setSetupUserid(null);
setError(null);
}}
/>
)}
</div>
</div>
);
}
/* ────────────────────────────────────────────────────────────────────────────
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 (
<div
className="login-brand"
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
background: 'linear-gradient(155deg, #F4EEF8 0%, #F7F3F9 45%, #FBF8F2 100%)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<img
src="/logo-wordmark.png"
alt="Nearle"
style={{ height: 28, width: 'auto', display: 'block' }}
/>
<span
style={{
background: 'var(--color-brand)',
color: '#fff',
borderRadius: 999,
padding: '3px 10px',
fontSize: 10.5,
fontWeight: 700,
letterSpacing: '0.1em',
}}
>
CONSOLE
</span>
</div>
{/* The inset image card: white, 8px inset, hairline border, soft shadow.
Hidden on a phone, where it would push the form below the fold. */}
<div
className="login-hero"
style={{
alignSelf: 'center',
maxWidth: 320,
width: '100%',
background: '#fff',
border: '1px solid var(--color-line)',
borderRadius: 20,
padding: 8,
boxShadow: '0 4px 16px -2px rgb(16 24 32 / .06), 0 2px 6px -3px rgb(16 24 32 / .04)',
}}
>
<div
style={{
borderRadius: 14,
overflow: 'hidden',
background: 'var(--color-brand)',
display: 'grid',
placeItems: 'center',
aspectRatio: '1',
}}
>
<img
src="/logo-512.png"
alt=""
width={280}
height={280}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
</div>
</div>
<div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 7,
color: 'var(--color-brand)',
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.11em',
textTransform: 'uppercase',
marginBottom: 10,
}}
>
<Sparkles size={13} />
Retail operations platform
</div>
<h2
style={{
margin: 0,
fontFamily: 'var(--font-display)',
fontSize: 24,
lineHeight: 1.28,
fontWeight: 600,
letterSpacing: '-0.015em',
color: 'var(--color-ink-1)',
}}
>
Every store, every till, one console.
</h2>
<p
style={{
margin: '10px 0 0',
fontSize: 13.5,
lineHeight: 1.65,
color: 'var(--color-ink-3)',
maxWidth: 380,
}}
>
Onboard tenants and branches, publish the catalogue, and watch online orders and counter
sales land side by side.
</p>
</div>
</div>
);
}
/* ────────────────────────────────────────────────────────────────────────────
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 (
<div className="login-form" style={{ display: 'grid', placeItems: 'center' }}>
<div style={{ width: '100%', maxWidth: 448, display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<h1
style={{
margin: 0,
fontFamily: 'var(--font-display)',
fontSize: 26,
lineHeight: 1.2,
fontWeight: 700,
letterSpacing: '-0.02em',
color: 'var(--color-ink-1)',
}}
>
Welcome back
</h1>
<p style={{ margin: '6px 0 0', fontSize: 13.5, lineHeight: 1.6, color: 'var(--color-ink-3)' }}>
Sign in to manage tenants, branches, catalogue and counter sales.
</p>
</div>
<form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Field label="Work email" htmlFor="email" icon={<Mail size={15} />}>
<input
id="email"
type="email"
value={email}
onChange={(event) => onEmail(event.target.value)}
placeholder="you@company.com"
autoComplete="username"
autoFocus
required
style={inputStyle}
/>
</Field>
<Field
label="Password"
htmlFor="password"
icon={<Lock size={15} />}
action={
<button
type="button"
onClick={onToggleVisible}
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
style={eyeButtonStyle}
>
{isPasswordVisible ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
}
>
<input
id="password"
type={isPasswordVisible ? 'text' : 'password'}
value={password}
onChange={(event) => onPassword(event.target.value)}
placeholder="••••••••"
autoComplete="current-password"
required
style={{ ...inputStyle, paddingRight: 40 }}
/>
</Field>
{/* 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. */}
<ErrorNote message={error} />
<SubmitButton
canSubmit={canSubmit}
isBusy={isBusy}
busyLabel="Signing in…"
label="Continue"
/>
</form>
<p
style={{
margin: 0,
fontSize: 12,
lineHeight: 1.6,
color: 'var(--color-ink-4)',
textAlign: 'center',
}}
>
Use the account your administrator set up for you. Your role decides which workspace
opens — you do not pick one.
</p>
</div>
</div>
);
}
/* ────────────────────────────────────────────────────────────────────────────
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 (
<div className="login-form" style={{ display: 'grid', placeItems: 'center' }}>
<div style={{ width: '100%', maxWidth: 448, display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 7,
marginBottom: 12,
padding: '4px 10px',
borderRadius: 999,
background: 'var(--color-surface-subtle)',
border: '1px solid var(--color-line)',
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.09em',
textTransform: 'uppercase',
color: 'var(--color-brand)',
}}
>
<ShieldCheck size={13} />
First sign-in
</div>
<h1
style={{
margin: 0,
fontFamily: 'var(--font-display)',
fontSize: 26,
lineHeight: 1.2,
fontWeight: 700,
letterSpacing: '-0.02em',
color: 'var(--color-ink-1)',
}}
>
Choose a password
</h1>
<p style={{ margin: '6px 0 0', fontSize: 13.5, lineHeight: 1.6, color: 'var(--color-ink-3)' }}>
{email} has no password yet. Set one now and we will sign you straight in.
</p>
</div>
<form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Field
label="New password"
htmlFor="new-password"
icon={<Lock size={15} />}
action={
<button
type="button"
onClick={onToggleVisible}
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
style={eyeButtonStyle}
>
{isPasswordVisible ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
}
>
<input
id="new-password"
type={isPasswordVisible ? 'text' : 'password'}
value={newPassword}
onChange={(event) => onNewPassword(event.target.value)}
placeholder={`At least ${MIN_PASSWORD_LENGTH} characters`}
autoComplete="new-password"
autoFocus
required
aria-invalid={isTooShort}
style={{ ...inputStyle, paddingRight: 40 }}
/>
</Field>
<Field label="Confirm password" htmlFor="confirm-password" icon={<Lock size={15} />}>
<input
id="confirm-password"
type={isPasswordVisible ? 'text' : 'password'}
value={confirmPassword}
onChange={(event) => 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)',
}}
/>
</Field>
<Hint>
{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.'}
</Hint>
<ErrorNote message={error} />
<SubmitButton
canSubmit={canSubmit}
isBusy={isBusy}
busyLabel="Setting it…"
label="Set password and sign in"
/>
</form>
<button
type="button"
onClick={onBack}
style={{
alignSelf: 'center',
border: 0,
background: 'transparent',
padding: 0,
fontSize: 12.5,
fontFamily: 'inherit',
color: 'var(--color-ink-3)',
cursor: 'pointer',
textDecoration: 'underline',
textUnderlineOffset: 3,
}}
>
Use a different account
</button>
</div>
</div>
);
}
/** A quiet line under the fields — advisory, never an error. */
function Hint({ children }: { children: ReactNode }) {
return (
<p style={{ margin: 0, fontSize: 12.5, lineHeight: 1.55, color: 'var(--color-ink-4)' }}>
{children}
</p>
);
}
/* ────────────────────────────────────────────────────────────────────────────
Shared form furniture
──────────────────────────────────────────────────────────────────────────── */
function ErrorNote({ message }: { message: string | null }) {
if (!message) return null;
return (
<div
role="alert"
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 9,
padding: '11px 13px',
borderRadius: 12,
background: 'var(--color-error-muted, #FCEEEE)',
border: '1px solid rgba(214,69,69,.22)',
color: '#B03434',
fontSize: 13,
lineHeight: 1.55,
}}
>
<AlertCircle size={16} style={{ flex: 'none', marginTop: 1 }} />
{message}
</div>
);
}
function SubmitButton({
canSubmit,
isBusy,
label,
busyLabel,
}: {
canSubmit: boolean;
isBusy: boolean;
label: string;
busyLabel: string;
}) {
return (
<button
type="submit"
disabled={!canSubmit}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 9,
height: 46,
width: '100%',
borderRadius: 14,
border: 0,
background: canSubmit
? 'var(--color-brand)'
: 'color-mix(in oklab, var(--color-brand) 45%, #fff)',
color: '#fff',
fontSize: 14.5,
fontWeight: 600,
cursor: canSubmit ? 'pointer' : 'default',
boxShadow: canSubmit ? '0 10px 30px -6px rgb(102 37 130 / .28)' : 'none',
transition: 'background .2s cubic-bezier(.16,1,.3,1), box-shadow .2s',
}}
>
{isBusy ? (
<>
<Loader2 size={16} style={{ animation: 'spin 1s linear infinite' }} />
{busyLabel}
</>
) : (
<>
{label}
<ArrowRight size={16} />
</>
)}
</button>
);
}
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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label
htmlFor={htmlFor}
style={{
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.09em',
textTransform: 'uppercase',
color: 'var(--color-ink-3)',
}}
>
{label}
</label>
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<span
style={{
position: 'absolute',
left: 13,
display: 'flex',
color: 'var(--color-ink-4)',
pointerEvents: 'none',
}}
>
{icon}
</span>
{children}
{action}
</div>
</div>
);
}