|
|
|
|
@@ -0,0 +1,281 @@
|
|
|
|
|
import { type FormEvent, useState } from "react";
|
|
|
|
|
import { parsePhoneNumberFromString } from "libphonenumber-js";
|
|
|
|
|
import { Eye, EyeOff, Mail, Smartphone, UserRound } from "lucide-react";
|
|
|
|
|
import { useNavigate } from "react-router-dom";
|
|
|
|
|
|
|
|
|
|
import { useAuth } from "@/auth/useAuth";
|
|
|
|
|
|
|
|
|
|
type LoginMode = "email" | "phone" | "username";
|
|
|
|
|
|
|
|
|
|
const loginModes: Array<{
|
|
|
|
|
value: LoginMode;
|
|
|
|
|
label: string;
|
|
|
|
|
icon: typeof Mail;
|
|
|
|
|
placeholder: string;
|
|
|
|
|
}> = [
|
|
|
|
|
{ value: "email", label: "Email", icon: Mail, placeholder: "name@company.com" },
|
|
|
|
|
{ value: "phone", label: "Phone", icon: Smartphone, placeholder: "09XXXXXXXX" },
|
|
|
|
|
{ value: "username", label: "Username", icon: UserRound, placeholder: "username" },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
|
|
const usernamePattern = /^[a-zA-Z0-9._-]{3,32}$/;
|
|
|
|
|
|
|
|
|
|
const normalizeIdentifier = (mode: LoginMode, value: string) => {
|
|
|
|
|
const trimmed = value.trim();
|
|
|
|
|
|
|
|
|
|
if (mode === "email") {
|
|
|
|
|
if (!emailPattern.test(trimmed.toLowerCase())) {
|
|
|
|
|
throw new Error("Enter a valid email address.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return trimmed.toLowerCase();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (mode === "phone") {
|
|
|
|
|
const parsed = parsePhoneNumberFromString(trimmed, "ET");
|
|
|
|
|
|
|
|
|
|
if (!parsed?.isValid()) {
|
|
|
|
|
throw new Error("Enter a valid Ethiopian phone number.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return parsed.number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!usernamePattern.test(trimmed)) {
|
|
|
|
|
throw new Error("Username must be 3-32 characters and use letters, numbers, ., _, or -.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return trimmed;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const LoginPage = () => {
|
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
const { login, verifyMfa } = useAuth();
|
|
|
|
|
const [mode, setMode] = useState<LoginMode>("email");
|
|
|
|
|
const [identifier, setIdentifier] = useState("");
|
|
|
|
|
const [password, setPassword] = useState("");
|
|
|
|
|
const [otp, setOtp] = useState("");
|
|
|
|
|
const [needsMfa, setNeedsMfa] = useState(false);
|
|
|
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
|
|
|
const [normalizedIdentifier, setNormalizedIdentifier] = useState("");
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
const currentMode = loginModes.find((item) => item.value === mode)!;
|
|
|
|
|
const ModeIcon = currentMode.icon;
|
|
|
|
|
|
|
|
|
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
setSubmitting(true);
|
|
|
|
|
setError(null);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const normalized = normalizeIdentifier(mode, identifier);
|
|
|
|
|
setNormalizedIdentifier(normalized);
|
|
|
|
|
|
|
|
|
|
const result = await login({ email: normalized, password });
|
|
|
|
|
if (result.mfaRequired) {
|
|
|
|
|
setNeedsMfa(true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
navigate("/dashboard/overview", { replace: true });
|
|
|
|
|
} catch {
|
|
|
|
|
setError("Unable to sign in with those credentials.");
|
|
|
|
|
} finally {
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleVerifyMfa = async (event: FormEvent<HTMLFormElement>) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
setSubmitting(true);
|
|
|
|
|
setError(null);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await verifyMfa({ email: normalizedIdentifier, otp: otp.trim() });
|
|
|
|
|
navigate("/dashboard/overview", { replace: true });
|
|
|
|
|
} catch {
|
|
|
|
|
setError("Unable to verify the one-time code.");
|
|
|
|
|
} finally {
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(15,118,110,0.14),_transparent_44%),linear-gradient(180deg,_var(--background),_color-mix(in_oklab,_var(--background)_92%,_#0f766e_8%))] px-6 py-10">
|
|
|
|
|
<div className="mx-auto grid min-h-[calc(100vh-5rem)] max-w-6xl gap-8 lg:grid-cols-[1.1fr_0.9fr]">
|
|
|
|
|
<section className="flex flex-col justify-between rounded-[2rem] border border-border/60 bg-card/85 p-8 shadow-[0_24px_80px_rgba(15,23,42,0.10)] backdrop-blur md:p-10">
|
|
|
|
|
<div>
|
|
|
|
|
<div className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#0f766e] text-lg font-semibold text-white shadow-lg shadow-[#0f766e]/20">
|
|
|
|
|
EDR
|
|
|
|
|
</div>
|
|
|
|
|
<p className="mt-6 text-sm font-medium uppercase tracking-[0.3em] text-[#0f766e]">
|
|
|
|
|
Freight operations
|
|
|
|
|
</p>
|
|
|
|
|
<h1 className="mt-4 max-w-xl text-4xl font-semibold tracking-tight text-foreground md:text-5xl">
|
|
|
|
|
Backoffice access for internal freight administration.
|
|
|
|
|
</h1>
|
|
|
|
|
<p className="mt-5 max-w-xl text-base leading-7 text-muted-foreground">
|
|
|
|
|
Review operational activity, manage access, and coordinate internal railway workflows from a single dashboard.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid gap-4 rounded-3xl border border-border/60 bg-background/80 p-5 md:grid-cols-3">
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Region</p>
|
|
|
|
|
<p className="mt-2 text-sm font-medium text-foreground">Ethiopia default phone normalization</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Session</p>
|
|
|
|
|
<p className="mt-2 text-sm font-medium text-foreground">7-day persistent token cookie</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Access</p>
|
|
|
|
|
<p className="mt-2 text-sm font-medium text-foreground">Overview and user management</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section className="flex items-center">
|
|
|
|
|
<div className="w-full rounded-[2rem] border border-border/60 bg-card p-8 shadow-[0_20px_60px_rgba(15,23,42,0.12)] md:p-10">
|
|
|
|
|
{!needsMfa ? (
|
|
|
|
|
<form className="space-y-6" onSubmit={handleSubmit}>
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm font-medium uppercase tracking-[0.2em] text-[#0f766e]">
|
|
|
|
|
Sign in
|
|
|
|
|
</p>
|
|
|
|
|
<h2 className="mt-3 text-3xl font-semibold text-foreground">
|
|
|
|
|
EDR Backoffice
|
|
|
|
|
</h2>
|
|
|
|
|
<p className="mt-2 text-sm text-muted-foreground">
|
|
|
|
|
Use your email, phone number, or username to access the internal freight dashboard.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid gap-4">
|
|
|
|
|
<label className="grid gap-2 text-sm font-medium text-foreground">
|
|
|
|
|
Sign in method
|
|
|
|
|
<select
|
|
|
|
|
value={mode}
|
|
|
|
|
onChange={(event) => setMode(event.target.value as LoginMode)}
|
|
|
|
|
className="h-12 rounded-xl border border-input bg-background px-4 text-sm outline-none transition focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/20"
|
|
|
|
|
>
|
|
|
|
|
{loginModes.map((item) => (
|
|
|
|
|
<option key={item.value} value={item.value}>
|
|
|
|
|
{item.label}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
<label className="grid gap-2 text-sm font-medium text-foreground">
|
|
|
|
|
{currentMode.label}
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<ModeIcon className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
|
|
|
<input
|
|
|
|
|
value={identifier}
|
|
|
|
|
onChange={(event) => setIdentifier(event.target.value)}
|
|
|
|
|
placeholder={currentMode.placeholder}
|
|
|
|
|
className="h-12 w-full rounded-xl border border-input bg-background pl-11 pr-4 text-sm outline-none transition focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/20"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
<label className="grid gap-2 text-sm font-medium text-foreground">
|
|
|
|
|
Password
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<input
|
|
|
|
|
type={showPassword ? "text" : "password"}
|
|
|
|
|
value={password}
|
|
|
|
|
onChange={(event) => setPassword(event.target.value)}
|
|
|
|
|
placeholder="Enter your password"
|
|
|
|
|
className="h-12 w-full rounded-xl border border-input bg-background px-4 pr-12 text-sm outline-none transition focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/20"
|
|
|
|
|
/>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setShowPassword((current) => !current)}
|
|
|
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground transition hover:text-foreground"
|
|
|
|
|
aria-label={showPassword ? "Hide password" : "Show password"}
|
|
|
|
|
>
|
|
|
|
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{error ? (
|
|
|
|
|
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
|
|
|
|
{error}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
type="submit"
|
|
|
|
|
disabled={submitting}
|
|
|
|
|
className="inline-flex h-12 w-full items-center justify-center rounded-xl bg-[#0f766e] px-4 text-sm font-semibold text-white transition hover:bg-[#115e59] disabled:cursor-not-allowed disabled:opacity-60"
|
|
|
|
|
>
|
|
|
|
|
{submitting ? "Signing in..." : "Sign in"}
|
|
|
|
|
</button>
|
|
|
|
|
</form>
|
|
|
|
|
) : (
|
|
|
|
|
<form className="space-y-6" onSubmit={handleVerifyMfa}>
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm font-medium uppercase tracking-[0.2em] text-[#0f766e]">
|
|
|
|
|
Multi-factor verification
|
|
|
|
|
</p>
|
|
|
|
|
<h2 className="mt-3 text-3xl font-semibold text-foreground">
|
|
|
|
|
Confirm one-time code
|
|
|
|
|
</h2>
|
|
|
|
|
<p className="mt-2 text-sm text-muted-foreground">
|
|
|
|
|
We sent a verification code for {normalizedIdentifier}. Enter it below to complete sign in.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<label className="grid gap-2 text-sm font-medium text-foreground">
|
|
|
|
|
Verification code
|
|
|
|
|
<input
|
|
|
|
|
value={otp}
|
|
|
|
|
onChange={(event) => setOtp(event.target.value)}
|
|
|
|
|
placeholder="Enter the code"
|
|
|
|
|
className="h-12 w-full rounded-xl border border-input bg-background px-4 text-sm outline-none transition focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/20"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
{error ? (
|
|
|
|
|
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
|
|
|
|
{error}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="inline-flex h-12 items-center justify-center rounded-xl border border-input bg-background px-4 text-sm font-medium text-foreground transition hover:bg-accent"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setNeedsMfa(false);
|
|
|
|
|
setOtp("");
|
|
|
|
|
setError(null);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Back
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="submit"
|
|
|
|
|
disabled={submitting}
|
|
|
|
|
className="inline-flex h-12 items-center justify-center rounded-xl bg-[#0f766e] px-4 text-sm font-semibold text-white transition hover:bg-[#115e59] disabled:cursor-not-allowed disabled:opacity-60"
|
|
|
|
|
>
|
|
|
|
|
{submitting ? "Verifying..." : "Verify code"}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default LoginPage;
|