223 lines
9.0 KiB
JavaScript
223 lines
9.0 KiB
JavaScript
import React, { useState } from 'react';
|
|
import { useLocation, useNavigate } from 'react-router-dom';
|
|
import { AlertCircle, Check, Loader2, Eye, EyeOff, CheckCircle2 } from 'lucide-react';
|
|
import { DOORMILE_WORDMARK_URL } from '@/assets/brand';
|
|
import { Checkbox } from '@/components/ds';
|
|
import { useAuth } from '@/lib/AuthContext';
|
|
import { readStoredUser } from '@/api/doormile';
|
|
import './Login.css';
|
|
|
|
/**
|
|
* Console sign-in — `POST /admin/login`.
|
|
*
|
|
* The credential is checked by the Doormile API, so there is no client-side
|
|
* password policy here beyond "you typed something": a length rule invented on
|
|
* this side would reject a valid account whose password predates the rule, and
|
|
* the server is the only thing that actually knows.
|
|
*
|
|
* The look is the Doormile Express console's sign-in design — lit brand panel
|
|
* on the left, one elevated card on the right — so the two consoles open the
|
|
* same way. It is expressed in a page-scoped stylesheet (Login.css) rather than
|
|
* utility classes because the panel needs a layered gradient, an oversized arc
|
|
* and a masked line pattern, none of which are Tailwind's to give. Nothing in
|
|
* that stylesheet is reachable from any other page.
|
|
*/
|
|
|
|
const FEATURES = ['Real-time fleet visibility', 'AI-optimised dispatch routes', 'Tenant, pricing & invoice control'];
|
|
|
|
export default function Login() {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const { login } = useAuth();
|
|
|
|
const from = location.state?.from;
|
|
const returnTo = typeof from === 'string' && from.startsWith('/doormile') ? from : '/doormile/dispatch';
|
|
|
|
const [form, setForm] = useState({ email: '', password: '', remember: true });
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const [errors, setErrors] = useState(/** @type {{ email?: string, password?: string }} */({}));
|
|
const [authError, setAuthError] = useState('');
|
|
const [status, setStatus] = useState('idle');
|
|
|
|
const validate = () => {
|
|
const next = {};
|
|
if (!form.email.trim()) next.email = 'Enter your work email';
|
|
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) next.email = 'Enter a valid email address';
|
|
if (!form.password) next.password = 'Enter your password';
|
|
setErrors(next);
|
|
return Object.keys(next).length === 0;
|
|
};
|
|
|
|
const submit = async (event) => {
|
|
event.preventDefault();
|
|
setAuthError('');
|
|
if (!validate()) return;
|
|
|
|
setStatus('loading');
|
|
const result = await login(form.email.trim(), form.password);
|
|
|
|
if (!result.success) {
|
|
setStatus('idle');
|
|
setAuthError(result.message);
|
|
return;
|
|
}
|
|
|
|
/* The data layer scopes some requests off the signed-in identity read
|
|
straight from localStorage rather than through React context — the zone
|
|
picker, for one, narrows the hub list to the tenant's own city. Those
|
|
keys are written here, at the one moment the identity is known. */
|
|
const user = readStoredUser() || {};
|
|
localStorage.setItem('authname', user.email || user.name || form.email.trim());
|
|
localStorage.setItem('firstname', user.name || '');
|
|
localStorage.setItem('userid', user.id ?? '');
|
|
localStorage.setItem('roleid', user.role ?? '');
|
|
localStorage.setItem('tenantid', user.tenantid ?? '');
|
|
|
|
setStatus('success');
|
|
setTimeout(() => navigate(returnTo, { replace: true }), 320);
|
|
};
|
|
|
|
const busy = status !== 'idle';
|
|
|
|
return (
|
|
<div className="dm-login">
|
|
{/* ---- Brand panel. The ONLY Doormile logo on this page. ---- */}
|
|
<aside className="dm-login__brand">
|
|
<img className="dm-login__logo" src={DOORMILE_WORDMARK_URL} alt="Doormile Express" />
|
|
|
|
<div className="dm-login__copy">
|
|
<h1 className="dm-login__headline">Operate your dispatch, end to end.</h1>
|
|
<p className="dm-login__lede">
|
|
Orders, AI route optimisation, live rider tracking and billing — all in the Doormile Express operator
|
|
console.
|
|
</p>
|
|
<ul className="dm-login__features">
|
|
{FEATURES.map((feature) => (
|
|
<li key={feature} className="dm-login__feature">
|
|
<span className="dm-login__tick" aria-hidden="true">
|
|
<Check size={14} strokeWidth={2.5} />
|
|
</span>
|
|
{feature}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* ---- Form panel ---- */}
|
|
<main className="dm-login__panel">
|
|
<div className="dm-login__stack">
|
|
<div className="dm-login__card">
|
|
<h2 className="dm-login__title">Welcome back</h2>
|
|
<p className="dm-login__subtitle">Sign in to the Doormile Express console</p>
|
|
|
|
<hr className="dm-login__divider" />
|
|
|
|
{authError && (
|
|
<div role="alert" className="dm-login__alert">
|
|
<AlertCircle size={16} aria-hidden="true" />
|
|
<p>{authError}</p>
|
|
</div>
|
|
)}
|
|
|
|
<form onSubmit={submit} noValidate className="dm-login__fields">
|
|
<div className="dm-login__field">
|
|
<label className="dm-login__label" htmlFor="dm-login-email">
|
|
Email address
|
|
</label>
|
|
<input
|
|
id="dm-login-email"
|
|
className="dm-login__input"
|
|
type="email"
|
|
autoComplete="username"
|
|
autoFocus
|
|
disabled={busy}
|
|
aria-invalid={Boolean(errors.email)}
|
|
aria-describedby={errors.email ? 'dm-login-email-error' : undefined}
|
|
value={form.email}
|
|
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
|
placeholder="you@company.com"
|
|
/>
|
|
{errors.email && (
|
|
<p id="dm-login-email-error" className="dm-login__error">
|
|
{errors.email}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="dm-login__field">
|
|
<label className="dm-login__label" htmlFor="dm-login-password">
|
|
Password
|
|
</label>
|
|
<div className="dm-login__pw">
|
|
<input
|
|
id="dm-login-password"
|
|
className="dm-login__input"
|
|
type={showPassword ? 'text' : 'password'}
|
|
autoComplete="current-password"
|
|
disabled={busy}
|
|
aria-invalid={Boolean(errors.password)}
|
|
aria-describedby={errors.password ? 'dm-login-password-error' : undefined}
|
|
value={form.password}
|
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
|
placeholder="Enter your password"
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="dm-login__pw-toggle"
|
|
onClick={() => setShowPassword((v) => !v)}
|
|
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
|
aria-pressed={showPassword}
|
|
>
|
|
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
|
</button>
|
|
</div>
|
|
{errors.password && (
|
|
<p id="dm-login-password-error" className="dm-login__error">
|
|
{errors.password}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="dm-login__meta">
|
|
<label className="dm-login__remember">
|
|
<Checkbox
|
|
checked={form.remember}
|
|
onCheckedChange={(v) => setForm({ ...form, remember: Boolean(v) })}
|
|
disabled={busy}
|
|
/>
|
|
Remember me
|
|
</label>
|
|
<button
|
|
type="button"
|
|
className="dm-login__forgot"
|
|
disabled={busy}
|
|
onClick={() => setAuthError('Ask a platform administrator to reset your password.')}
|
|
>
|
|
Forgot password?
|
|
</button>
|
|
</div>
|
|
|
|
<button type="submit" className="dm-login__submit" disabled={busy}>
|
|
{status === 'loading' && <Loader2 size={16} className="dm-login__spin" aria-hidden="true" />}
|
|
{status === 'success' && <CheckCircle2 size={16} aria-hidden="true" />}
|
|
<span>{status === 'loading' ? 'Signing in…' : status === 'success' ? 'Signed in' : 'Sign in'}</span>
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div className="dm-login__footer">
|
|
<span>© {new Date().getFullYear()} Doormile</span>
|
|
<a href="https://doormile.com/terms-of-service" target="_blank" rel="noreferrer">
|
|
Terms & Conditions
|
|
</a>
|
|
<a href="https://doormile.com/privacy-policy" target="_blank" rel="noreferrer">
|
|
Privacy Policy
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|