refactor login page to simplify identifier normalization and remove unused login methods

This commit is contained in:
Marshal
2026-06-23 11:33:04 +00:00
parent 6fd1b68389
commit 6a63ae2fa6
4 changed files with 94 additions and 200 deletions

View File

@@ -1,11 +1,7 @@
import { type FormEvent, useState } from "react"; import { type FormEvent, useState } from "react";
import { parsePhoneNumberFromString } from "libphonenumber-js";
import { import {
Eye, Eye,
EyeOff, EyeOff,
Mail,
Smartphone,
UserRound,
ArrowUpRight, ArrowUpRight,
Globe, Globe,
ChevronDown, ChevronDown,
@@ -14,65 +10,15 @@ import { useNavigate } from "react-router-dom";
import { useAuth } from "@/auth/useAuth"; import { useAuth } from "@/auth/useAuth";
type LoginMode = "email" | "phone" | "username"; /** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */
const normaliseIdentifier = (raw: string): string => {
const loginModes: Array<{ const v = raw.trim();
value: LoginMode; const digits = v.replace(/\D/g, "");
label: string; if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) {
icon: typeof Mail; const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, "");
placeholder: string; return `+251${local}`;
}> = [
{
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();
} }
return v.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 LOGIN_IMAGE = "/assets/login.png"; const LOGIN_IMAGE = "/assets/login.png";
@@ -214,7 +160,6 @@ const FormFooter = () => (
const LoginPage = () => { const LoginPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { login, verifyMfa } = useAuth(); const { login, verifyMfa } = useAuth();
const [mode, setMode] = useState<LoginMode>("email");
const [identifier, setIdentifier] = useState(""); const [identifier, setIdentifier] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [otp, setOtp] = useState(""); const [otp, setOtp] = useState("");
@@ -224,15 +169,13 @@ const LoginPage = () => {
const [normalizedIdentifier, setNormalizedIdentifier] = useState(""); const [normalizedIdentifier, setNormalizedIdentifier] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const currentMode = loginModes.find((item) => item.value === mode)!;
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => { const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
try { try {
const normalized = normalizeIdentifier(mode, identifier); const normalized = normaliseIdentifier(identifier);
setNormalizedIdentifier(normalized); setNormalizedIdentifier(normalized);
const result = await login({ email: normalized, password }); const result = await login({ email: normalized, password });
@@ -284,32 +227,14 @@ const LoginPage = () => {
<div className="flex w-full flex-col gap-4"> <div className="flex w-full flex-col gap-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800"> <label className="text-sm font-medium text-gray-800">
Sign in method Email or Phone <span className="text-red-500">*</span>
</label>
<div className="relative">
<select
value={mode}
onChange={(event) => setMode(event.target.value as LoginMode)}
className={`${fieldClass} appearance-none pr-10`}
>
{loginModes.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">
{currentMode.label} <span className="text-red-500">*</span>
</label> </label>
<input <input
type="text"
value={identifier} value={identifier}
onChange={(event) => setIdentifier(event.target.value)} onChange={(event) => setIdentifier(event.target.value)}
placeholder={currentMode.placeholder} placeholder="name@company.com or 09XXXXXXXX"
autoComplete="username"
className={fieldClass} className={fieldClass}
/> />
</div> </div>

View File

@@ -50,8 +50,8 @@ export default function ETradeInfo({
<Stack gap="md"> <Stack gap="md">
<Group align="flex-start" grow> <Group align="flex-start" grow>
<TextInput <TextInput
label="TIN Number (10 digits)" label={<>TIN Number (10 digits) <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
placeholder="1234567890" placeholder="0012345678"
maxLength={10} maxLength={10}
error={error} error={error}
{...register} {...register}

View File

@@ -450,14 +450,15 @@ export default function CompanyProfileForm({
}); });
// The business owner/manager pulled from eTrade — powers "Use owner as // The business owner/manager pulled from eTrade — powers "Use owner as
// manager" on the General Manager step. Null until a TIN lookup succeeds. // manager" on the General Manager step.
const [etradeOwner, setEtradeOwner] = useState<{ const [etradeOwner, setEtradeOwner] = useState<{
name: string; name: string;
phone: string; phone: string;
email?: string;
} | null>(null); } | null>(null);
// Mirror the two "copy from previous person" checkboxes so they can be // Mirror the three "copy from previous person" checkboxes.
// re-toggled (re-checking re-pulls the latest values). const [ownerIsGm, setOwnerIsGm] = useState(false);
const [gmIsContact, setGmIsContact] = useState(false); const [gmIsContact, setGmIsContact] = useState(false);
const [contactIsPoa, setContactIsPoa] = useState(false); const [contactIsPoa, setContactIsPoa] = useState(false);
@@ -507,15 +508,20 @@ export default function CompanyProfileForm({
phone: toEthiopianE164( phone: toEthiopianE164(
data.managerPhone || data.regularPhone || data.mobilePhone, data.managerPhone || data.regularPhone || data.mobilePhone,
), ),
email: data.managerEmail || undefined,
}); });
}; };
/** Fill the General Manager from the eTrade business owner. */ /** Fill the General Manager from the eTrade business owner. */
const useOwnerAsManager = () => { const toggleOwnerAsGm = (checked: boolean) => {
if (!etradeOwner) return; setOwnerIsGm(checked);
if (!checked || !etradeOwner) return;
const { first, last } = splitName(etradeOwner.name); const { first, last } = splitName(etradeOwner.name);
setValue("generalManagerFirstName", first, { shouldValidate: true }); setValue("generalManagerFirstName", first, { shouldValidate: true });
setValue("generalManagerLastName", last, { shouldValidate: true }); setValue("generalManagerLastName", last, { shouldValidate: true });
setValue("generalManagerEmail", etradeOwner.email ?? "", {
shouldValidate: true,
});
setValue("generalManagerPhone", etradeOwner.phone ?? "", { setValue("generalManagerPhone", etradeOwner.phone ?? "", {
shouldValidate: true, shouldValidate: true,
}); });
@@ -702,13 +708,13 @@ export default function CompanyProfileForm({
<SimpleGrid cols={2} spacing="md"> <SimpleGrid cols={2} spacing="md">
<TextInput <TextInput
label="First Name" label={<>First Name <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
placeholder="Global" placeholder="Global"
error={errors.companyFirstName?.message} error={errors.companyFirstName?.message}
{...register("companyFirstName")} {...register("companyFirstName")}
/> />
<TextInput <TextInput
label="Last Name" label={<>Last Name <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
placeholder="Logistics Ltd" placeholder="Logistics Ltd"
error={errors.companyLastName?.message} error={errors.companyLastName?.message}
{...register("companyLastName")} {...register("companyLastName")}
@@ -716,7 +722,7 @@ export default function CompanyProfileForm({
</SimpleGrid> </SimpleGrid>
<SimpleGrid cols={2} spacing="md"> <SimpleGrid cols={2} spacing="md">
<TextInput <TextInput
label="Company Email" label={<>Company Email <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
type="email" type="email"
placeholder="ops@company.com" placeholder="ops@company.com"
error={errors.companyEmail?.message} error={errors.companyEmail?.message}
@@ -730,21 +736,21 @@ export default function CompanyProfileForm({
/> />
</SimpleGrid> </SimpleGrid>
<TextInput <TextInput
label="Location" label={<>Location <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
placeholder="Addis Ababa, Ethiopia" placeholder="Addis Ababa, Ethiopia"
error={errors.companyLocation?.message} error={errors.companyLocation?.message}
{...register("companyLocation")} {...register("companyLocation")}
/> />
<SimpleGrid cols={2} spacing="md"> <SimpleGrid cols={2} spacing="md">
<TextInput <TextInput
label="VAT Number" label={<>VAT Number <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
placeholder="VAT-12345" placeholder="VAT-12345"
maxLength={10} maxLength={10}
error={errors.vatNumber?.message} error={errors.vatNumber?.message}
{...register("vatNumber")} {...register("vatNumber")}
/> />
<TextInput <TextInput
label="FAN Number (16 digits)" label={<>FAN Number (16 digits) <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
placeholder="1234567890123456" placeholder="1234567890123456"
maxLength={16} maxLength={16}
error={errors.fanNumber?.message} error={errors.fanNumber?.message}
@@ -757,16 +763,21 @@ export default function CompanyProfileForm({
<Text fw={600} size="sm" c="edr-text"> <Text fw={600} size="sm" c="edr-text">
Registration Details Registration Details
</Text> </Text>
<Text size="xs" c="edr-muted">
Auto-filled from eTrade these fields cannot be edited.
</Text>
<SimpleGrid cols={2} spacing="md"> <SimpleGrid cols={2} spacing="md">
<TextInput <TextInput
label="License Number" label="License Number"
placeholder="01/23/01/19786/2006" readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.licenceNumber?.message} error={errors.licenceNumber?.message}
{...register("licenceNumber")} {...register("licenceNumber")}
/> />
<TextInput <TextInput
label="Status" label="Status"
placeholder="Not renewed for 2 years" readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.statusDescription?.message} error={errors.statusDescription?.message}
{...register("statusDescription")} {...register("statusDescription")}
/> />
@@ -774,13 +785,15 @@ export default function CompanyProfileForm({
<SimpleGrid cols={2} spacing="md"> <SimpleGrid cols={2} spacing="md">
<TextInput <TextInput
label="Date Registered" label="Date Registered"
placeholder="12/17/2013" readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.dateRegistered?.message} error={errors.dateRegistered?.message}
{...register("dateRegistered")} {...register("dateRegistered")}
/> />
<TextInput <TextInput
label="Renewal Date" label="Renewal Date"
placeholder="3/17/2016" readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.renewalDate?.message} error={errors.renewalDate?.message}
{...register("renewalDate")} {...register("renewalDate")}
/> />
@@ -788,13 +801,15 @@ export default function CompanyProfileForm({
<SimpleGrid cols={2} spacing="md"> <SimpleGrid cols={2} spacing="md">
<TextInput <TextInput
label="Renewed From" label="Renewed From"
placeholder="3/17/2016" readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.renewedFrom?.message} error={errors.renewedFrom?.message}
{...register("renewedFrom")} {...register("renewedFrom")}
/> />
<TextInput <TextInput
label="Renewed To" label="Renewed To"
placeholder="7/7/2016" readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.renewedTo?.message} error={errors.renewedTo?.message}
{...register("renewedTo")} {...register("renewedTo")}
/> />
@@ -806,13 +821,15 @@ export default function CompanyProfileForm({
<SimpleGrid cols={2} spacing="md"> <SimpleGrid cols={2} spacing="md">
<TextInput <TextInput
label="Region" label="Region"
placeholder="Tigray" readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.region?.message} error={errors.region?.message}
{...register("region")} {...register("region")}
/> />
<TextInput <TextInput
label="Zone" label="Zone"
placeholder="EASTERN TIGRAY" readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.zone?.message} error={errors.zone?.message}
{...register("zone")} {...register("zone")}
/> />
@@ -820,13 +837,15 @@ export default function CompanyProfileForm({
<SimpleGrid cols={2} spacing="md"> <SimpleGrid cols={2} spacing="md">
<TextInput <TextInput
label="Woreda" label="Woreda"
placeholder="EROB" readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.woreda?.message} error={errors.woreda?.message}
{...register("woreda")} {...register("woreda")}
/> />
<TextInput <TextInput
label="Kebele" label="Kebele"
placeholder="ARAS" readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.kebele?.message} error={errors.kebele?.message}
{...register("kebele")} {...register("kebele")}
/> />
@@ -834,7 +853,8 @@ export default function CompanyProfileForm({
<SimpleGrid cols={2} spacing="md"> <SimpleGrid cols={2} spacing="md">
<TextInput <TextInput
label="House No" label="House No"
placeholder="House Number" readOnly
styles={{ input: { backgroundColor: "var(--mantine-color-gray-0)", cursor: "default" } }}
error={errors.houseNo?.message} error={errors.houseNo?.message}
{...register("houseNo")} {...register("houseNo")}
/> />
@@ -850,31 +870,25 @@ export default function CompanyProfileForm({
{step === "personnel" && ( {step === "personnel" && (
<> <>
<Group justify="space-between" align="center"> <Text fw={600} size="sm" c="edr-text">
<Text fw={600} size="sm" c="edr-text"> General Manager
General Manager </Text>
</Text> <Checkbox
{etradeOwner && ( color="edr-green"
<Button label="Use eTrade business owner as General Manager"
variant="light" checked={ownerIsGm}
color="edr-green" disabled={!etradeOwner}
size="xs" onChange={(e) => toggleOwnerAsGm(e.currentTarget.checked)}
leftSection={<UserCheck size={14} />} />
onClick={useOwnerAsManager}
>
Use owner as manager
</Button>
)}
</Group>
<SimpleGrid cols={2} spacing="md"> <SimpleGrid cols={2} spacing="md">
<TextInput <TextInput
label="First Name" label={<>First Name <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
placeholder="Abebe" placeholder="Abebe"
error={errors.generalManagerFirstName?.message} error={errors.generalManagerFirstName?.message}
{...register("generalManagerFirstName")} {...register("generalManagerFirstName")}
/> />
<TextInput <TextInput
label="Last Name" label={<>Last Name <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
placeholder="Bikila" placeholder="Bikila"
error={errors.generalManagerLastName?.message} error={errors.generalManagerLastName?.message}
{...register("generalManagerLastName")} {...register("generalManagerLastName")}
@@ -882,7 +896,7 @@ export default function CompanyProfileForm({
</SimpleGrid> </SimpleGrid>
<SimpleGrid cols={2} spacing="md"> <SimpleGrid cols={2} spacing="md">
<TextInput <TextInput
label="Email" label={<>Email <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
type="email" type="email"
placeholder="gm@company.com" placeholder="gm@company.com"
error={errors.generalManagerEmail?.message} error={errors.generalManagerEmail?.message}
@@ -911,13 +925,13 @@ export default function CompanyProfileForm({
/> />
<SimpleGrid cols={2} spacing="md"> <SimpleGrid cols={2} spacing="md">
<TextInput <TextInput
label="First Name" label={<>First Name <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
placeholder="Jane" placeholder="Jane"
error={errors.contactPersonFirstName?.message} error={errors.contactPersonFirstName?.message}
{...register("contactPersonFirstName")} {...register("contactPersonFirstName")}
/> />
<TextInput <TextInput
label="Last Name" label={<>Last Name <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
placeholder="Smith" placeholder="Smith"
error={errors.contactPersonLastName?.message} error={errors.contactPersonLastName?.message}
{...register("contactPersonLastName")} {...register("contactPersonLastName")}

View File

@@ -1,48 +1,39 @@
import { type FormEvent, useState } from "react"; import { type FormEvent, useState } from "react";
import { ChevronDown, Eye, EyeOff, Mail, Smartphone } from "lucide-react"; import { Eye, EyeOff } from "lucide-react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import RPNInput from "react-phone-number-input";
import "react-phone-number-input/style.css";
import useAuth from "@/hooks/useAuth"; import useAuth from "@/hooks/useAuth";
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell"; import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
import "@/components/phone-field.css";
const EDR_LOGO = "/assets/edr-logo.png"; const EDR_LOGO = "/assets/edr-logo.png";
type LoginMethod = "email" | "phone"; /** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */
function normaliseIdentifier(raw: string): string {
const loginMethods: Array<{ const v = raw.trim();
value: LoginMethod; const digits = v.replace(/\D/g, "");
label: string; if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) {
icon: typeof Mail; const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, "");
placeholder: string; return `+251${local}`;
}> = [ }
{ value: "email", label: "Email", icon: Mail, placeholder: "name@company.com" }, return v.toLowerCase();
{ value: "phone", label: "Phone", icon: Smartphone, placeholder: "09XXXXXXXX" }, }
];
export default function LoginPage() { export default function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { login } = useAuth(); const { login } = useAuth();
const [method, setMethod] = useState<LoginMethod>("email");
const [identifier, setIdentifier] = useState(""); const [identifier, setIdentifier] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const currentMethod = loginMethods.find((item) => item.value === method)!;
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => { const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
setError(null); setError(null);
setLoading(true); setLoading(true);
try { try {
// In phone mode the identifier is already a canonical E.164 string const result = await login({ email: normaliseIdentifier(identifier), password });
// (e.g. +251912345678) from the phone field; email mode passes through.
const result = await login({ email: identifier, password });
if (result.success) { if (result.success) {
const from = (location.state as { from?: { pathname: string } } | null)?.from const from = (location.state as { from?: { pathname: string } } | null)?.from
?.pathname; ?.pathname;
@@ -74,55 +65,19 @@ export default function LoginPage() {
</div> </div>
<div className="flex w-full flex-col gap-4"> <div className="flex w-full flex-col gap-4">
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">Sign in method</label>
<div className="relative">
<select
value={method}
onChange={(event) => {
setMethod(event.target.value as LoginMethod);
setIdentifier("");
}}
disabled={loading}
className={`${fieldClass} appearance-none pr-10`}
>
{loginMethods.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
</div>
</div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800"> <label className="text-sm font-medium text-gray-800">
{currentMethod.label} <span className="text-red-500">*</span> Email or Phone <span className="text-red-500">*</span>
</label> </label>
{method === "phone" ? ( <input
<div className="edr-phone-wrapper"> type="text"
<RPNInput value={identifier}
international onChange={(event) => setIdentifier(event.target.value)}
defaultCountry="ET" placeholder="name@company.com or 09XXXXXXXX"
countryCallingCodeEditable={false} disabled={loading}
addInternationalOption autoComplete="username"
placeholder="912 345 678" className={fieldClass}
disabled={loading} />
value={identifier || undefined}
onChange={(v) => setIdentifier(v ?? "")}
/>
</div>
) : (
<input
type="email"
value={identifier}
onChange={(event) => setIdentifier(event.target.value)}
placeholder={currentMethod.placeholder}
disabled={loading}
className={fieldClass}
/>
)}
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">