Initial commit
This commit is contained in:
539
src/features/auth/LoginPage.tsx
Normal file
539
src/features/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,539 @@
|
||||
import { lazy, Suspense, useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { Navigate, useNavigate } from 'react-router-dom';
|
||||
import { AlertCircle, ArrowRight, Eye, EyeOff, Loader2, Lock, Mail, Sparkles } from 'lucide-react';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { HOME_ROUTE } from '@/auth/roles';
|
||||
import { PasswordSetupRequiredError } from '@/auth/session';
|
||||
import type { ConsoleRole, SessionUser } from '@/auth/roles';
|
||||
|
||||
/**
|
||||
* The demo panel is loaded lazily behind the build-time DEV constant, so in a
|
||||
* production build this expression is `null`, the import is dead code, and
|
||||
* neither the panel nor the accounts behind it are emitted at all.
|
||||
*/
|
||||
const DemoPanel = import.meta.env.DEV
|
||||
? lazy(() => import('@/demo/DemoPanel').then((m) => ({ default: m.DemoPanel })))
|
||||
: null;
|
||||
|
||||
/**
|
||||
* 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, signInAsDemo } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||
const [isRemembered, setIsRemembered] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
|
||||
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) {
|
||||
setError(
|
||||
cause instanceof PasswordSetupRequiredError
|
||||
? 'This account has no password set yet. An administrator needs to set one before you can sign in.'
|
||||
: cause instanceof Error
|
||||
? cause.message
|
||||
: 'Sign-in failed',
|
||||
);
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = email.trim() !== '' && password !== '' && !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 />
|
||||
<FormPanel
|
||||
email={email}
|
||||
password={password}
|
||||
isPasswordVisible={isPasswordVisible}
|
||||
isRemembered={isRemembered}
|
||||
error={error}
|
||||
isBusy={isBusy}
|
||||
canSubmit={canSubmit}
|
||||
onEmail={setEmail}
|
||||
onPassword={setPassword}
|
||||
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
|
||||
onToggleRemember={() => setIsRemembered((remembered) => !remembered)}
|
||||
onSubmit={handleSubmit}
|
||||
onDemo={(role, session) => {
|
||||
signInAsDemo(session);
|
||||
navigate(HOME_ROUTE[role], { replace: true });
|
||||
}}
|
||||
/>
|
||||
</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;
|
||||
isRemembered: boolean;
|
||||
error: string | null;
|
||||
isBusy: boolean;
|
||||
canSubmit: boolean;
|
||||
onEmail: (value: string) => void;
|
||||
onPassword: (value: string) => void;
|
||||
onToggleVisible: () => void;
|
||||
onToggleRemember: () => void;
|
||||
onSubmit: (event: FormEvent) => void;
|
||||
onDemo: (role: ConsoleRole, session: SessionUser) => void;
|
||||
}
|
||||
|
||||
function FormPanel({
|
||||
email,
|
||||
password,
|
||||
isPasswordVisible,
|
||||
isRemembered,
|
||||
error,
|
||||
isBusy,
|
||||
canSubmit,
|
||||
onEmail,
|
||||
onPassword,
|
||||
onToggleVisible,
|
||||
onToggleRemember,
|
||||
onSubmit,
|
||||
onDemo,
|
||||
}: 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={{
|
||||
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',
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
color: 'var(--color-ink-2)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isRemembered}
|
||||
onChange={onToggleRemember}
|
||||
style={{ width: 15, height: 15, accentColor: 'var(--color-brand)', cursor: 'pointer' }}
|
||||
/>
|
||||
Keep me signed in
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
padding: 0,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-brand)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<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 }} />
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<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' }} />
|
||||
Signing in…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Continue
|
||||
<ArrowRight size={16} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</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>
|
||||
|
||||
{DemoPanel ? (
|
||||
<Suspense fallback={null}>
|
||||
<DemoPanel onDemo={onDemo} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user