Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement

This commit is contained in:
Marshal
2026-07-09 21:14:09 +00:00
115 changed files with 11050 additions and 1459 deletions

View File

@@ -0,0 +1,105 @@
import { Button, Modal, Radio, Stack, Text } from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { KeyRound } from "lucide-react";
import { useState } from "react";
import { useAuth } from "@/auth/useAuth";
import { useToast } from "@/hooks/use-toast";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import type { Company, ResetChannel } from "@/types/customer";
export interface ResetPasswordActionProps {
company: Pick<Company, "id" | "email" | "phone">;
}
/**
* Staff-triggered password reset. Sends a one-time code to the customer's
* primary contact; the customer picks their own new password. No credential is
* ever shown to or handled by staff.
*/
export default function ResetPasswordAction({ company }: ResetPasswordActionProps) {
const { user } = useAuth();
const { toast } = useToast();
const [opened, setOpened] = useState(false);
const [channel, setChannel] = useState<ResetChannel>("phone");
const { mutate, isPending } = useMutation(
api.customers.resetPassword.mutationOptions({
onSuccess: (result) => {
setOpened(false);
toast({
title: "Reset code sent",
description: `The customer can now reset their password using the code sent to ${result.maskedTarget}.`,
});
},
onError: (error) => {
toast({
title: "Could not send reset code",
description: error.message,
variant: "destructive",
});
},
}),
);
if (!hasPermission(user, FREIGHT_PERMS.customers.resetPassword)) return null;
return (
<>
<Button
variant="default"
leftSection={<KeyRound size={16} />}
onClick={() => setOpened(true)}
>
Reset password
</Button>
<Modal
opened={opened}
onClose={() => setOpened(false)}
title="Send a password-reset code"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
We&apos;ll send a one-time code to this customer&apos;s primary contact.
They choose their own new password you will not see it.
</Text>
<Radio.Group
value={channel}
onChange={(v) => setChannel(v as ResetChannel)}
label="Send the code via"
>
<Stack gap="xs" mt="xs">
<Radio
value="phone"
label="SMS"
description={company.phone ?? "No phone on the company record"}
/>
<Radio
value="email"
label="Email"
description={company.email ?? "No email on the company record"}
/>
</Stack>
</Radio.Group>
<Text size="xs" c="dimmed">
The code goes to the primary contact&apos;s own email or phone, which
may differ from the company contact details shown above.
</Text>
<Button
color="edr-green"
loading={isPending}
onClick={() => mutate({ companyId: company.id, channel })}
>
Send reset code
</Button>
</Stack>
</Modal>
</>
);
}

View File

@@ -13,5 +13,9 @@ export {
ChangeRequestReview,
ChangeRequestPendingBadge,
} from "./ChangeRequestReview";
export {
default as ResetPasswordAction,
type ResetPasswordActionProps,
} from "./ResetPasswordAction";
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
export { TableCard, type TableCardProps } from "./TableCard";

View File

@@ -250,7 +250,10 @@ const FreightSidebar = ({
<AppShell.Section
grow
component={ScrollArea}
type="never"
type="hover"
scrollbars="y"
scrollbarSize={6}
scrollHideDelay={500}
px="sm"
pb="md"
>

View File

@@ -7,7 +7,7 @@ import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import { warehouseService } from '@/services/warehouse.service';
import { extractErrorMessage } from './options';
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
import type { FeePreview, WarehouseInventoryItem, WarehouseInvoiceStatus } from '@/types/warehouse';
import { openPdfBlob } from './pdf';
@@ -157,7 +157,7 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
pdfWindow?.close();
toast({
title: 'Gate clearance recorded',
description: `Release paper could not be opened: ${extractErrorMessage(documentError)}`,
description: `Release paper could not be opened: ${await extractDownloadErrorMessage(documentError)}`,
});
}
onClose();

View File

@@ -18,7 +18,7 @@ import { LoadInventoryModal } from './LoadInventoryModal';
import { MoveInventoryModal } from './MoveInventoryModal';
import { ReleaseOrderModal } from './ReleaseOrderModal';
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
import { extractErrorMessage } from './options';
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
import { openPdfBlob } from './pdf';
interface InventoryWorkbenchProps {
@@ -111,7 +111,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
toast({
variant: 'destructive',
title: 'Release paper preview failed',
description: extractErrorMessage(error),
description: await extractDownloadErrorMessage(error),
});
} finally {
setBusyId(null);
@@ -131,7 +131,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
toast({
variant: 'destructive',
title: 'Handover document failed',
description: extractErrorMessage(error),
description: await extractDownloadErrorMessage(error),
});
} finally {
setBusyId(null);

View File

@@ -76,7 +76,7 @@ import { MoveInventoryModal } from './MoveInventoryModal';
import { ReleaseOrderModal } from './ReleaseOrderModal';
import { StoreInventoryModal } from './StoreInventoryModal';
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
import { openPdfBlob } from './pdf';
import '@/components/overview/overview.css';
@@ -112,7 +112,7 @@ function GrnDocumentButton({ inventoryId, grnNumber }: { inventoryId: string; gr
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
} finally {
setLoading(false);
}
@@ -900,7 +900,7 @@ function EligibleTab({
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
}
}
setSelected(new Set());
@@ -1697,7 +1697,6 @@ function LoadedExportTab({
<Table.Th>Weight</Table.Th>
<Table.Th>Route</Table.Th>
<Table.Th>Status</Table.Th>
{dispatchable && <Table.Th ta="right">Actions</Table.Th>}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -1739,19 +1738,6 @@ function LoadedExportTab({
{r.status}
</Badge>
</Table.Td>
{dispatchable && (
<Table.Td ta="right">
<Button
size="compact-xs"
variant="light"
color="green"
loading={bulkDispatch.isPending}
onClick={() => dispatch([r.id])}
>
Dispatch
</Button>
</Table.Td>
)}
</Table.Tr>
))}
</Table.Tbody>
@@ -2245,6 +2231,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
handoverDocumentReference: row.handoverDocumentReference,
handoverDocumentDate: row.handoverDocumentDate,
deliveredAt: row.deliveredAt,
// Carries the saved [Exit Inspection] block so Truck Leaving opens with the
// arrival details (plate, driver, tare, gate-in) read-only instead of blank.
notes: row.notes,
booking: row.bookingId
? {
id: row.bookingId,
@@ -2282,7 +2271,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'Handover document failed', description: extractErrorMessage(error) });
toast({ variant: 'destructive', title: 'Handover document failed', description: await extractDownloadErrorMessage(error) });
} finally {
setBusyId(null);
}
@@ -2296,7 +2285,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
openPdfBlob(response.data, `release-${row.bookingReference ?? row.id}.pdf`, pdfWindow);
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'Exit paper failed', description: extractErrorMessage(error) });
toast({ variant: 'destructive', title: 'Exit paper failed', description: await extractDownloadErrorMessage(error) });
} finally {
setBusyId(null);
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, MultiSelect, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
import { Alert, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
import { Info, Scale } from 'lucide-react';
import { useMutation, useQuery } from '@tanstack/react-query';
@@ -105,6 +105,7 @@ const parseInspectionNote = (notes: string | null | undefined) => {
grossWeight: lineNumber(note, 'Gross Weight'),
netWeight: lineNumber(note, 'Net Weight'),
gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')),
weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note ?? ''),
};
};
@@ -135,6 +136,8 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
const [containerNumbers, setContainerNumbers] = useState<string[]>(['']);
const [gateInTime, setGateInTime] = useState('');
const [tareWeight, setTareWeight] = useState<number | ''>('');
// Containers may skip the weighbridge (decided at arrival, sticks for exit). Bulk always weighs.
const [weighTruck, setWeighTruck] = useState<'yes' | 'no'>('yes');
const [grossWeight, setGrossWeight] = useState<number | ''>('');
const [netWeight, setNetWeight] = useState<number | ''>('');
const [gateOutTime, setGateOutTime] = useState('');
@@ -158,6 +161,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber || prefillContainerNumber || assignedContainerNumber));
setGateInTime(inspection.gateInTime);
setTareWeight(inspection.tareWeight);
setWeighTruck(inspection.weighingSkipped ? 'no' : 'yes');
setGrossWeight(inspection.grossWeight);
setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight));
setGateOutTime(inspection.gateOutTime);
@@ -165,7 +169,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
}, [opened, item, truckPrefill]);
const savedInspection = parseInspectionNote(item?.notes);
const isExitStep = savedInspection.tareWeight !== '';
const isExitStep = savedInspection.tareWeight !== '' || savedInspection.weighingSkipped;
const isEntranceLocked = isExitStep;
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber);
@@ -220,7 +224,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
.reduce((sum, n) => sum + (containerWeightByNumber.get(n.toUpperCase()) ?? 0), 0)
.toFixed(3),
);
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0;
// Skip is only offered for container bookings; bulk always weighs.
const skipWeighing = hasContainerWeights && weighTruck === 'no';
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0 && !skipWeighing;
const systemNetWeight = useContainerNet
? selectedCargoWeight
@@ -230,6 +236,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
const computedNetWeight =
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
const weightMismatch =
!skipWeighing &&
computedNetWeight != null && systemNetWeight !== '' && Math.abs(Number(systemNetWeight) - computedNetWeight) > 0.001;
const title = isExitStep ? 'Customer truck leaving and exit weighing' : 'Customer truck arrival weighing';
@@ -239,19 +246,25 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
toast({ variant: 'destructive', title: 'Truck plate and driver name are required' });
return;
}
if (!gateInTime || tareWeight === '') {
toast({ variant: 'destructive', title: 'Gate in time and tare weight are required' });
if (!gateInTime || (!skipWeighing && tareWeight === '')) {
toast({
variant: 'destructive',
title: skipWeighing ? 'Gate in time is required' : 'Gate in time and tare weight are required',
});
return;
}
if (isExitStep && (!gateOutTime || grossWeight === '')) {
toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' });
if (isExitStep && (!gateOutTime || (!skipWeighing && grossWeight === ''))) {
toast({
variant: 'destructive',
title: skipWeighing ? 'Gate out time is required' : 'Gate out time and gross weight are required',
});
return;
}
if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) {
toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' });
return;
}
if (isExitStep && systemNetWeight === '') {
if (isExitStep && !skipWeighing && systemNetWeight === '') {
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
return;
}
@@ -279,9 +292,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
truckType: truckType.trim() || undefined,
containerNumber: containerNumbers.map((number) => number.trim()).filter(Boolean).join(', ') || undefined,
gateInTime: toIsoDateTime(gateInTime),
tareWeight: Number(tareWeight),
grossWeight: grossWeight === '' ? undefined : Number(grossWeight),
netWeight: isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
weighingSkipped: skipWeighing || undefined,
tareWeight: skipWeighing ? undefined : Number(tareWeight),
grossWeight: skipWeighing || grossWeight === '' ? undefined : Number(grossWeight),
netWeight: !skipWeighing && isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined,
},
});
@@ -421,9 +435,24 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
)}
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
</Group>
{hasContainerWeights && (
<Group gap="md" align="center">
<Text size="sm" fw={600}>Weigh truck?</Text>
<SegmentedControl
size="xs"
data={[{ value: 'yes', label: 'Yes — weigh' }, { value: 'no', label: 'No — pass' }]}
value={weighTruck}
onChange={(v) => setWeighTruck((v as 'yes' | 'no') ?? 'yes')}
disabled={isEntranceLocked}
/>
{skipWeighing && (
<Text size="xs" c="dimmed">Weighbridge skipped container passes without tare/gross.</Text>
)}
</Group>
)}
<Group grow>
<NumberInput label="Tare weight (t)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} />
<NumberInput label="Gross weight (t)" required={isExitStep} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} />
<NumberInput label="Tare weight (t)" required={!skipWeighing} min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} disabled={skipWeighing} />
<NumberInput label="Gross weight (t)" required={isExitStep && !skipWeighing} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep || skipWeighing} />
<NumberInput
label={useContainerNet ? 'Selected cargo net (t)' : 'Recorded net weight (system t)'}
min={0}

View File

@@ -5,7 +5,7 @@ import { useState } from 'react';
import { useToast } from '@/hooks/use-toast';
import { warehouseService } from '@/services/warehouse.service';
import { extractErrorMessage } from './options';
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
import { openPdfBlob } from './pdf';
interface TruckDispatchModalProps {
@@ -56,7 +56,7 @@ export function TruckDispatchModal({ opened, onClose, bookingId, bookingReferenc
const res = await warehouseService.downloadTruckExitPaper(assignmentId);
openPdfBlob(res.data, `exit-${plate}.pdf`);
} catch (e) {
toast({ variant: 'destructive', title: 'Exit paper not ready', description: extractErrorMessage(e) });
toast({ variant: 'destructive', title: 'Exit paper not ready', description: await extractDownloadErrorMessage(e) });
}
};

View File

@@ -10,7 +10,7 @@ import {
type WarehouseInventoryItem,
} from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
import { extractErrorMessage, formatDate, formatNumber, humanizeEnum } from './options';
import { extractDownloadErrorMessage, formatDate, formatNumber, humanizeEnum } from './options';
import { openPdfBlob } from './pdf';
interface WarehouseInventoryTableProps {
@@ -78,7 +78,7 @@ function GrnDocumentButton({ item }: { item: WarehouseInventoryItem }) {
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
} finally {
setLoading(false);
}
@@ -160,7 +160,12 @@ export function WarehouseInventoryTable({
{items.map((item) => {
const kind = itemKind(item);
const busy = busyId === item.id;
const nextAction = getNextInventoryAction(item);
// Per-booking Load and Dispatch are retired: wagon loading happens in
// the train flow and dispatch at the train level (which already
// advances inventory). Only the remaining lifecycle actions render.
const rawNextAction = getNextInventoryAction(item);
const nextAction =
rawNextAction === 'load' || rawNextAction === 'dispatch' ? null : rawNextAction;
const canGenerateHandover =
item.inspectionStatus === 'PASSED' &&
Boolean(item.bookingId) &&
@@ -232,26 +237,15 @@ export function WarehouseInventoryTable({
</Button>
)}
{item.status === 'READY_FOR_PICKUP' && (
<>
<Button
size="compact-xs"
variant="light"
color="blue"
loading={busy}
onClick={() => onAdvance(item, 'store')}
>
Store
</Button>
<Button
size="compact-xs"
variant="light"
color="green"
loading={busy}
onClick={() => onAdvance(item, 'dispatch')}
>
Dispatch
</Button>
</>
<Button
size="compact-xs"
variant="light"
color="blue"
loading={busy}
onClick={() => onAdvance(item, 'store')}
>
Store
</Button>
)}
{item.status !== 'DISPATCHED' && (
<Tooltip label="Move" withArrow>

View File

@@ -86,6 +86,8 @@ export const URL_CONSTANTS = {
`/bookings/by-company/${id}/customer-view`,
PAYMENTS_CUSTOMER_VIEW: (id: string) =>
`/payments/by-company/${id}/customer-view`,
RESET_PASSWORD: (companyId: string) =>
`/backoffice/customers/${companyId}/reset-password`,
},
BILLING: {

View File

@@ -62,6 +62,7 @@ export const FREIGHT_PERMS = {
update: "edr_freight_app:customers:update",
deactivate: "edr_freight_app:customers:deactivate",
verify: "edr_freight_app:customers:verify",
resetPassword: "edr_freight_app:customers:reset-password",
},
payments: {
view: "edr_freight_app:payments:view",

View File

@@ -43,6 +43,7 @@ import {
ProfileChips,
ProfileStatusBadge,
ProfileTypeBadge,
ResetPasswordAction,
TableCard,
formatBytes,
formatDate,
@@ -573,6 +574,7 @@ export default function CustomerDetailPage() {
<ChangeRequestPendingBadge companyId={company.id} />
</Group>
}
action={<ResetPasswordAction company={company} />}
/>
<Tabs defaultValue="overview">

View File

@@ -12,6 +12,8 @@ import type {
CustomerPayment,
PaginatedCompanies,
ProfileStatus,
ResetChannel,
ResetPasswordResult,
} from "@/types/customer";
import {
CreateDropdownOptionDto,
@@ -2269,6 +2271,16 @@ export const api = {
({ id }) => QUERY_KEYS.CUSTOMERS.payments(id),
),
resetPassword: endpoint<
{ companyId: string; channel: ResetChannel },
ResetPasswordResult
>(
"customers",
"resetPassword",
({ companyId, channel }) =>
customersService.resetPassword(companyId, channel),
),
setProfileStatus: endpoint<
{ profileId: string; status: ProfileStatus; note?: string },
CompanyProfile

View File

@@ -11,6 +11,8 @@ import type {
CustomerPayment,
PaginatedCompanies,
ProfileStatus,
ResetChannel,
ResetPasswordResult,
} from "@/types/customer";
const cleanParams = (params: object) =>
@@ -81,6 +83,22 @@ export const customersService = {
.then((r) => r.data);
},
/**
* Send a password-reset code to the company's primary contact. Staff never
* receive a credential — the customer sets their own password from the code.
*/
resetPassword(
companyId: string,
channel: ResetChannel,
): Promise<ResetPasswordResult> {
return apiClient
.post<ResetPasswordResult>(
URL_CONSTANTS.COMPANIES.RESET_PASSWORD(companyId),
{ channel },
)
.then((r) => r.data);
},
setProfileStatus(
profileId: string,
status: ProfileStatus,

View File

@@ -99,6 +99,15 @@ export interface CompanyChangeRequest {
updatedAt: string;
}
/** The channel a customer's password-reset code is delivered over. */
export type ResetChannel = "email" | "phone";
export interface ResetPasswordResult {
channel: ResetChannel;
/** Where the code went, e.g. `+251•••4821` — safe to show to staff. */
maskedTarget: string;
}
/** Mirrors backend `Company` (+ its `companyProfiles`). */
export interface Company {
id: string;

View File

@@ -86,8 +86,9 @@ export function getNextInventoryAction(item: WarehouseInventoryItem): InventoryA
return 'store';
case 'STORED':
// Reserve is retired: a stored export item goes straight to loading prep
// once inspection passes. Import STORED is handled via the import queue.
if (isImport) return null;
// once inspection passes. An import item parked back into storage returns
// to pickup — otherwise Store would strand it with no action.
if (isImport) return inspected ? 'ready-for-pickup' : null;
return inspected ? 'ready-for-loading' : null;
case 'RESERVED':
// Export loading is gated on a passed inspection.
@@ -367,6 +368,8 @@ export interface ReleaseOrderPayload {
grossWeight?: number;
netWeight?: number;
gateOutTime?: string;
/** Container bookings only: operator chose not to weigh — tare/gross omitted, match skipped. */
weighingSkipped?: boolean;
}
/** Import branch: proof of delivery captured on customer pickup. */

View File

@@ -32,6 +32,7 @@ import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
import MyPortalPage from "./pages/MyPortalPage";
import MySignaturePage from "./pages/MySignaturePage";
import SettingsPage from "./pages/SettingsPage";
import ForgotPasswordPage from "./pages/accounts/ForgotPasswordPage";
import LoginPage from "./pages/accounts/LoginPage";
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
import SignupPage from "./pages/accounts/SignupPage";
@@ -252,6 +253,7 @@ const App = () => {
<Route element={<RedirectIfAuthed />}>
<Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
</Route>
{/* Signup-flow pages; reached while a session already exists */}

View File

@@ -0,0 +1,179 @@
import { Alert, Button, PinInput, SegmentedControl, Stack, Text } from "@mantine/core";
import {
AlertCircle,
ArrowLeft,
Mail,
RotateCw,
ShieldCheck,
Smartphone,
} from "lucide-react";
import { maskEmail, maskPhone } from "@/utils/identifier";
export type OtpChannel = "phone" | "email";
export const OTP_LENGTH = 6;
export interface OtpChannelSelectProps {
value: OtpChannel;
onChange: (channel: OtpChannel) => void;
disabled?: boolean;
label?: string;
}
/** Phone/email toggle deciding where the verification code is sent. */
export function OtpChannelSelect({
value,
onChange,
disabled,
label = "Send verification code via",
}: OtpChannelSelectProps) {
return (
<div className="space-y-1.5">
<Text size="sm" fw={500} c="edr-text">
{label}
</Text>
<SegmentedControl
fullWidth
disabled={disabled}
value={value}
onChange={(v) => onChange(v as OtpChannel)}
data={[
{
value: "phone",
label: (
<span className="flex items-center justify-center gap-1.5">
<Smartphone size={14} /> Phone
</span>
),
},
{
value: "email",
label: (
<span className="flex items-center justify-center gap-1.5">
<Mail size={14} /> Email
</span>
),
},
]}
/>
</div>
);
}
export interface OtpChannelStepProps {
channel: OtpChannel;
/** Raw email or phone the code went to; masked before display. */
target: string;
value: string;
onChange: (otp: string) => void;
onVerify: () => void;
onBack: () => void;
onResend: () => void;
/** Seconds until resend is allowed; 0 enables the button. */
resendIn: number;
sending: boolean;
verifying: boolean;
error: string | null;
title?: string;
description?: string;
submitLabel: string;
}
/**
* The "enter the code we sent you" stage. Shared by signup and the
* forgot-password flow — both send through the same `/api/otp/*` service.
*/
export default function OtpChannelStep({
channel,
target,
value,
onChange,
onVerify,
onBack,
onResend,
resendIn,
sending,
verifying,
error,
title,
description,
submitLabel,
}: OtpChannelStepProps) {
const maskedTarget = channel === "email" ? maskEmail(target) : maskPhone(target);
const busy = sending || verifying;
return (
<Stack gap="md">
<div className="mb-1 flex justify-center">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<ShieldCheck size={22} />
</span>
</div>
<div className="space-y-1.5 text-center">
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
{title ?? `Verify your ${channel === "email" ? "email" : "phone"}`}
</h1>
<p className="text-sm leading-relaxed text-gray-500">
We sent a {OTP_LENGTH}-digit code to{" "}
<span className="font-medium text-gray-700">{maskedTarget}</span>.{" "}
{description ?? "Enter it to continue."}
</p>
</div>
{error ? (
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
{error}
</Alert>
) : null}
<Stack gap={6} align="center">
<Text size="sm" fw={500} c="edr-text">
Verification code
</Text>
<PinInput
length={OTP_LENGTH}
type="number"
oneTimeCode
value={value}
placeholder="0"
disabled={verifying}
styles={{ input: { textAlign: "center" } }}
onChange={onChange}
/>
</Stack>
<Button
color="edr-green"
fullWidth
loading={verifying}
disabled={verifying || value.trim().length !== OTP_LENGTH}
onClick={onVerify}
>
{submitLabel}
</Button>
<div className="flex items-center justify-between">
<Button
variant="subtle"
color="gray"
leftSection={<ArrowLeft size={14} />}
disabled={busy}
onClick={onBack}
>
Back
</Button>
<Button
variant="subtle"
color="edr-green"
leftSection={<RotateCw size={14} />}
disabled={resendIn > 0 || busy}
onClick={onResend}
>
{resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
</Button>
</div>
</Stack>
);
}

View File

@@ -0,0 +1,41 @@
import { Check, X } from "lucide-react";
import { passwordRequirements } from "@/utils/passwordSchema";
export interface PasswordChecklistProps {
/** The current password value; the checklist hides itself when empty. */
value: string;
}
/** Live pass/fail list of the password rules, shown under a password field. */
export default function PasswordChecklist({ value }: PasswordChecklistProps) {
if (!value) return null;
return (
<div className="mt-2 space-y-1">
{passwordRequirements.map((req) => {
const met = req.test(value);
return (
<div key={req.label} className="flex items-center gap-2">
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${
met
? "bg-primary text-primary-foreground"
: "bg-gray-200 text-gray-500"
}`}
>
{met ? (
<Check className="h-2.5 w-2.5" />
) : (
<X className="h-2.5 w-2.5" />
)}
</span>
<span className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}>
{req.label}
</span>
</div>
);
})}
</div>
);
}

View File

@@ -286,9 +286,13 @@ export default function OnboardingWizardDialog({
});
}, [roles, nationality, startMutation]);
// Note: no "back to role selection" — once the draft is created the role(s)
// are fixed; the form's first-step Back is a no-op so progress never resets.
const handleBackToRoles = useCallback(() => { }, []);
// Back from the form's first step returns to nationality/role selection.
// Safe to re-enter: startOnboarding is idempotent — it reuses the existing
// draft, refreshes the nationality and creates only roles that don't exist yet.
const handleBackToRoles = useCallback(() => {
setStartError(null);
setPhase("nationality-role");
}, []);
// Save the current step's fields to the draft (PATCH /profile). Returns the
// server error message on failure so the form can show it (e.g. duplicate TIN).
@@ -359,7 +363,6 @@ export default function OnboardingWizardDialog({
// The active step across the whole journey, driving the header + progress pill.
const activeStep: WizardStep = phase === "form" ? formStep : phase;
const stepMeta = STEP_META[activeStep];
console.log({ stepMeta, activeStep, STEP_META });
const activeIdx = WIZARD_STEPS.indexOf(activeStep);
// Closing from the congratulations panel also clears the completed flag so a
@@ -403,7 +406,6 @@ export default function OnboardingWizardDialog({
onSubmit: handleSubmit,
isPending: finishMutation.isPending,
onBack: handleBackToRoles,
hideFirstStepBack: true,
initialStep: effectiveResumeStep,
resyncOpen: opened,
onStepChange: handleStepChange,

View File

@@ -5,6 +5,8 @@ export const URL_CONSTANTS = {
REFRESH_TOKEN: "/api/auth/refresh-token",
LOGOUT: "/api/auth/logout",
PROFILE: "/auth/profile",
FORGOT_PASSWORD_REQUEST: "/api/auth/forgot-password/request",
FORGOT_PASSWORD_VERIFY: "/api/auth/forgot-password/verify",
},
USERS: {

View File

@@ -0,0 +1,24 @@
import { useEffect, useState } from "react";
/** Seconds a user must wait before another OTP can be requested. */
const DEFAULT_COOLDOWN_SECONDS = 60;
/**
* Countdown that gates the "Resend code" button. Ticks with setTimeout rather
* than wall-clock arithmetic, so it needs no Date.now().
*/
export function useResendCooldown(seconds: number = DEFAULT_COOLDOWN_SECONDS) {
const [secondsLeft, setSecondsLeft] = useState(0);
useEffect(() => {
if (secondsLeft <= 0) return;
const t = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
return () => clearTimeout(t);
}, [secondsLeft]);
return {
secondsLeft,
start: () => setSecondsLeft(seconds),
reset: () => setSecondsLeft(0),
};
}

View File

@@ -36,7 +36,9 @@ export const BookingRow = memo(function BookingRow({
// Contract ready for signature → "View & sign" jumps straight to the
// full-page contract viewer where the signature flow lives.
const canSign = bookingIsSignable(booking);
const canApproveDelivery = booking.status === "COMPLETED";
// Visible from handover generation until the customer signs (flag is attached
// by the bookings list endpoint; false again the moment it's signed).
const canApproveDelivery = Boolean(booking.handoverAwaitingSignature);
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
const dest =
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";

View File

@@ -51,7 +51,6 @@ export default function CompanyProfileForm({
onBack,
initialStep,
resyncOpen,
hideFirstStepBack,
onStepChange,
onSaveStep,
rehydrate,
@@ -73,8 +72,6 @@ export default function CompanyProfileForm({
initialStep?: CompanyStep;
/** When this flips true (dialog reopened), jump back to initialStep (furthest reached). */
resyncOpen?: boolean;
/** Hide the Back button on the first step (onboarding can't go back to role pick). */
hideFirstStepBack?: boolean;
/** Reports the active step so the parent can persist resume progress. */
onStepChange?: (step: CompanyStep) => void;
/** Persist the current step's data before advancing; returns an error to show. */
@@ -514,10 +511,6 @@ export default function CompanyProfileForm({
else setStep(stepOrder[currentIdx - 1]);
};
// Back is hidden on the first step during onboarding (can't return to role
// selection); otherwise always available.
const showBack = !(hideFirstStepBack && step === "company");
return (
<>
<form onSubmit={(e) => e.preventDefault()}>
@@ -851,17 +844,13 @@ export default function CompanyProfileForm({
)}
<Group justify="space-between" pt="xs">
{showBack ? (
<Button
variant="default"
onClick={prevStep}
leftSection={<ArrowLeft size={16} />}
>
Back
</Button>
) : (
<span />
)}
<Button
variant="default"
onClick={prevStep}
leftSection={<ArrowLeft size={16} />}
>
Back
</Button>
<Button
color="edr-green"
onClick={nextStep}

View File

@@ -0,0 +1,312 @@
import { type FormEvent, useState } from "react";
import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import { AlertCircle, ArrowLeft, ArrowRight, KeyRound } from "lucide-react";
import { Link, useNavigate } from "react-router-dom";
import { useResendCooldown } from "@/hooks/useResendCooldown";
import AuthShell from "@/components/auth/AuthShell";
import OtpChannelStep, {
OTP_LENGTH,
OtpChannelSelect,
type OtpChannel,
} from "@/components/auth/OtpChannelStep";
import PasswordChecklist from "@/components/auth/PasswordChecklist";
import { api } from "@/services/api";
import type { ResetTicket } from "@/types/auth";
import { normaliseIdentifier } from "@/utils/identifier";
import { meetsAllRequirements } from "@/utils/passwordSchema";
import { extractApiError } from "@/utils/result";
type Stage = "identify" | "otp" | "password";
export default function ForgotPasswordPage() {
const navigate = useNavigate();
const [stage, setStage] = useState<Stage>("identify");
const [identifier, setIdentifier] = useState("");
const [channel, setChannel] = useState<OtpChannel>("phone");
const [otpCode, setOtpCode] = useState("");
// The reset ticket lives in memory only — persisting it would leave a
// password-change credential sitting in localStorage.
const [ticket, setTicket] = useState<ResetTicket | null>(null);
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [sending, setSending] = useState(false);
const [verifying, setVerifying] = useState(false);
const [error, setError] = useState<string | null>(null);
const resendCooldown = useResendCooldown();
/** The identifier as the API will see it — normalised once, reused everywhere. */
const normalised = normaliseIdentifier(identifier);
const sendCode = async () => {
await api.auth.requestPasswordReset.call({ identifier: normalised, channel });
setOtpCode("");
resendCooldown.start();
};
// Stage 1 — ask for a code. The API answers identically for unknown accounts,
// so we always advance; a non-existent identifier simply never receives a code.
const handleIdentify = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
setSending(true);
try {
await sendCode();
setStage("otp");
} catch (err) {
setError(extractApiError(err).message);
} finally {
setSending(false);
}
};
const handleResend = async () => {
setError(null);
setSending(true);
try {
await sendCode();
} catch (err) {
setError(extractApiError(err).message);
} finally {
setSending(false);
}
};
// Stage 2 — trade the code for a single-use ticket.
const handleVerify = async () => {
setError(null);
if (otpCode.trim().length !== OTP_LENGTH) {
setError(`Enter the ${OTP_LENGTH}-digit code we sent you.`);
return;
}
setVerifying(true);
try {
const result = await api.auth.verifyPasswordResetOtp.call({
identifier: normalised,
channel,
otp: otpCode.trim(),
});
setTicket(result);
setStage("password");
} catch (err) {
setError(extractApiError(err).message);
} finally {
setVerifying(false);
}
};
// Stage 3 — spend the ticket on IAM's set-password.
const handleReset = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
if (!ticket) {
setError("Your reset session expired. Start again.");
setStage("identify");
return;
}
if (password !== confirmPassword) {
setError("Passwords do not match.");
return;
}
setVerifying(true);
try {
await api.auth.resetPassword.call({
userId: ticket.userId,
// The API matches this against email / username / phone, so the typed
// identifier works regardless of which one it is.
email: normalised,
verificationCode: ticket.verificationCode,
newPassword: password,
confirmPassword,
});
navigate("/login", {
replace: true,
state: { passwordReset: true },
});
} catch (err) {
setError(extractApiError(err).message);
} finally {
setVerifying(false);
}
};
const identifierLabel =
channel === "email" ? "the email on your account" : "the phone on your account";
return (
<AuthShell
tagline="Recover your account"
taglineBody="Reset your EDR Freight password with a one-time code sent to your email or phone."
>
<div className="flex w-full flex-col">
{stage === "identify" ? (
<form onSubmit={handleIdentify} className="flex w-full flex-col">
<div className="mb-1 flex justify-center">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<KeyRound size={22} />
</span>
</div>
<div className="mb-4 mt-3 space-y-1.5 text-center sm:mb-5">
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
Forgot your password?
</h1>
<p className="text-sm leading-relaxed text-gray-500">
Enter your email or phone number and we&apos;ll send you a code to
reset it.
</p>
</div>
<Stack gap="md">
<TextInput
label="Email or Phone"
placeholder="name@company.com or 09XXXXXXXX"
autoComplete="username"
required
disabled={sending}
value={identifier}
onChange={(event) => setIdentifier(event.target.value)}
/>
<OtpChannelSelect
value={channel}
onChange={setChannel}
disabled={sending}
label="Send the code to"
/>
<p className="text-xs text-gray-500">
The code goes to {identifierLabel}, which may differ from what you
typed above.
</p>
{error ? (
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
{error}
</Alert>
) : null}
<Button
type="submit"
color="edr-green"
fullWidth
loading={sending}
disabled={!identifier.trim()}
rightSection={!sending ? <ArrowRight size={16} /> : undefined}
>
Send code
</Button>
<p className="text-center text-sm text-gray-500">
Remembered it?{" "}
<Link to="/login" className="font-semibold text-primary hover:underline">
Back to sign in
</Link>
</p>
</Stack>
</form>
) : null}
{stage === "otp" ? (
<OtpChannelStep
channel={channel}
target={normalised}
value={otpCode}
onChange={setOtpCode}
onVerify={handleVerify}
onBack={() => {
setStage("identify");
setError(null);
}}
onResend={handleResend}
resendIn={resendCooldown.secondsLeft}
sending={sending}
verifying={verifying}
error={error}
title="Enter your reset code"
description="Enter it to choose a new password."
submitLabel="Verify code"
/>
) : null}
{stage === "password" ? (
<form onSubmit={handleReset} className="flex w-full flex-col">
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
Choose a new password
</h1>
<p className="text-sm leading-relaxed text-gray-500">
Pick something strong you haven&apos;t used before.
</p>
</div>
<Stack gap="md">
<div>
<PasswordInput
label="New password"
placeholder="Create a strong password"
required
disabled={verifying}
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
<PasswordChecklist value={password} />
</div>
<PasswordInput
label="Confirm new password"
placeholder="Re-enter your password"
required
disabled={verifying}
error={
confirmPassword && confirmPassword !== password
? "Passwords do not match"
: undefined
}
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
/>
{error ? (
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
{error}
</Alert>
) : null}
<Button
type="submit"
color="edr-green"
fullWidth
loading={verifying}
disabled={
verifying ||
!meetsAllRequirements(password) ||
password !== confirmPassword
}
>
Reset password
</Button>
<Button
variant="subtle"
color="gray"
leftSection={<ArrowLeft size={14} />}
disabled={verifying}
onClick={() => {
setStage("otp");
setError(null);
}}
>
Back
</Button>
</Stack>
</form>
) : null}
</div>
</AuthShell>
);
}

View File

@@ -5,21 +5,11 @@ import { Link, useLocation, useNavigate } from "react-router-dom";
import useAuth from "@/hooks/useAuth";
import AuthShell from "@/components/auth/AuthShell";
import { normaliseIdentifier } from "@/utils/identifier";
import { extractApiError } from "@/utils/result";
const EDR_LOGO = "/assets/edr-logo.png";
/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */
function normaliseIdentifier(raw: string): string {
const v = raw.trim();
const digits = v.replace(/\D/g, "");
if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) {
const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, "");
return `+251${local}`;
}
return v.toLowerCase();
}
export default function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
@@ -80,7 +70,7 @@ export default function LoginPage() {
<div className="mb-1.5 flex items-center justify-between">
<span className="text-sm font-medium text-gray-800">Password</span>
<Link
to="#"
to="/forgot-password"
className="text-xs font-semibold text-primary hover:underline"
>
Forgot password?

View File

@@ -1,4 +1,13 @@
import { Alert, Box, Button, Group, PasswordInput, Stack, Text, ThemeIcon } from "@mantine/core";
import {
Alert,
Box,
Button,
Group,
PasswordInput,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowRight, Check, LockKeyhole, X } from "lucide-react";
import { useMemo, useState } from "react";
@@ -8,27 +17,19 @@ import { z } from "zod";
import useAuth from "@/hooks/useAuth";
import AuthLayout from "@/components/auth/AuthLayout";
const passwordRequirements = [
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
{ label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) },
{ label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) },
{ label: "One number", test: (v: string) => /\d/.test(v) },
{ label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) },
] as const;
import {
confirmPasswordField,
passwordField,
passwordRequirements,
samePassword,
} from "@/utils/passwordSchema";
const passwordSchema = z
.object({
password: z
.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must include an uppercase letter")
.regex(/[a-z]/, "Password must include a lowercase letter")
.regex(/\d/, "Password must include a number")
.regex(/[^A-Za-z0-9]/, "Password must include a special character"),
confirmPassword: z.string().min(1, "Please confirm your password"),
password: passwordField,
confirmPassword: confirmPasswordField,
})
.refine((data) => data.password === data.confirmPassword, {
.refine(samePassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
@@ -54,7 +55,8 @@ export default function SetPasswordPage() {
const password = watch("password");
const requirements = useMemo(
() => passwordRequirements.map((r) => ({ ...r, met: r.test(password || "") })),
() =>
passwordRequirements.map((r) => ({ ...r, met: r.test(password || "") })),
[password],
);
@@ -93,11 +95,21 @@ export default function SetPasswordPage() {
"Secure freight operations",
"Advanced authentication system",
],
stats: { label: "Security Protection", value: "256-bit", footer: "Encrypted", progress: "w-[98%]" },
stats: {
label: "Security Protection",
value: "256-bit",
footer: "Encrypted",
progress: "w-[98%]",
},
}}
>
<Stack gap="xs" mb="lg">
<Box w={48} h={48} bg="edr-soft" className="flex items-center justify-center rounded-2xl">
<Box
w={48}
h={48}
bg="edr-soft"
className="flex items-center justify-center rounded-2xl"
>
<LockKeyhole size={22} color="var(--mantine-color-edr-green-6)" />
</Box>
<Box>

View File

@@ -1,50 +1,38 @@
import { useEffect, useState } from "react";
import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Alert,
Button,
PasswordInput,
PinInput,
SegmentedControl,
SimpleGrid,
Stack,
Text,
TextInput,
} from "@mantine/core";
import {
AlertCircle,
ArrowLeft,
ArrowRight,
Check,
Mail,
RotateCw,
ShieldCheck,
Smartphone,
X,
} from "lucide-react";
import { AlertCircle, ArrowRight } from "lucide-react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { z } from "zod";
import { userType } from "@/enums/userType";
import useAuth from "@/hooks/useAuth";
import { useResendCooldown } from "@/hooks/useResendCooldown";
import type { SignupPayload } from "@/types/auth";
import AuthShell from "@/components/auth/AuthShell";
import OtpChannelStep, {
OTP_LENGTH,
OtpChannelSelect,
type OtpChannel,
} from "@/components/auth/OtpChannelStep";
import PasswordChecklist from "@/components/auth/PasswordChecklist";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import { api } from "@/services/api";
import {
confirmPasswordField,
passwordField,
samePassword,
} from "@/utils/passwordSchema";
import { extractApiError } from "@/utils/result";
const passwordRequirements = [
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
{ label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) },
{ label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) },
{ label: "One number", test: (v: string) => /\d/.test(v) },
{
label: "One special character",
test: (v: string) => /[^A-Za-z0-9]/.test(v),
},
] as const;
const userSchema = z
.object({
email: z.string().email("Invalid email address"),
@@ -61,43 +49,23 @@ const userSchema = z
en: z.string().min(2, "Name is required"),
am: z.string().nullable(),
}),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must include an uppercase letter")
.regex(/[a-z]/, "Password must include a lowercase letter")
.regex(/\d/, "Password must include a number")
.regex(/[^A-Za-z0-9]/, "Password must include a special character"),
confirmPassword: z.string().min(1, "Please confirm your password"),
password: passwordField,
confirmPassword: confirmPasswordField,
})
.refine((data) => data.password === data.confirmPassword, {
.refine(samePassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
type FormData = z.infer<typeof userSchema>;
/** Mask all but the first 7 chars of an E.164 phone for display. */
const maskPhone = (p: string) =>
p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p;
/** Mask the local part of an email for display (j***e@example.com). */
const maskEmail = (email: string) => {
const [local, domain] = email.split("@");
if (!local || !domain) return email;
if (local.length <= 2) return `${local[0] ?? ""}***@${domain}`;
return `${local[0]}***${local[local.length - 1]}@${domain}`;
};
type OtpChannel = "phone" | "email";
export default function SignupPage() {
const navigate = useNavigate();
const { signup } = useAuth();
const [error, setError] = useState<string | null>(null);
// Two-stage signup: fill the form, then a mandatory SMS OTP challenge on the
// phone number before the account is actually created. The account is only
// Two-stage signup: fill the form, then a mandatory OTP challenge on the
// chosen channel before the account is actually created. The account is only
// created after the code is verified — the OTP is a hard requirement.
const [stage, setStage] = useState<"form" | "otp">("form");
const [pendingData, setPendingData] = useState<FormData | null>(null);
@@ -109,14 +77,7 @@ export default function SignupPage() {
const [verifying, setVerifying] = useState(false);
const [otpCode, setOtpCode] = useState("");
const [otpError, setOtpError] = useState<string | null>(null);
const [resendIn, setResendIn] = useState(0);
// Resend cooldown countdown (pure setTimeout ticks — no Date.now needed).
useEffect(() => {
if (resendIn <= 0) return;
const t = setTimeout(() => setResendIn((s) => s - 1), 1000);
return () => clearTimeout(t);
}, [resendIn]);
const resendCooldown = useResendCooldown();
const {
register,
@@ -170,7 +131,7 @@ export default function SignupPage() {
setOtpChannel(channel);
setOtpCode("");
setOtpError(null);
setResendIn(60);
resendCooldown.start();
setStage("otp");
} catch (err) {
setError(extractApiError(err).message);
@@ -190,7 +151,7 @@ export default function SignupPage() {
: { phone: pendingData.phone },
);
setOtpCode("");
setResendIn(60);
resendCooldown.start();
} catch (err) {
setOtpError(extractApiError(err).message);
} finally {
@@ -202,8 +163,8 @@ export default function SignupPage() {
const confirmOtp = async () => {
if (!pendingData) return;
setOtpError(null);
if (otpCode.trim().length !== 6) {
setOtpError("Enter the 6-digit code we sent you.");
if (otpCode.trim().length !== OTP_LENGTH) {
setOtpError(`Enter the ${OTP_LENGTH}-digit code we sent you.`);
return;
}
setVerifying(true);
@@ -298,35 +259,11 @@ export default function SignupPage() {
disabled={sending}
/>
<div className="space-y-1.5">
<Text size="sm" fw={500} c="edr-text">
Send verification code via
</Text>
<SegmentedControl
fullWidth
disabled={sending}
value={channel}
onChange={(v) => setChannel(v as OtpChannel)}
data={[
{
value: "phone",
label: (
<span className="flex items-center justify-center gap-1.5">
<Smartphone size={14} /> Phone
</span>
),
},
{
value: "email",
label: (
<span className="flex items-center justify-center gap-1.5">
<Mail size={14} /> Email
</span>
),
},
]}
/>
</div>
<OtpChannelSelect
value={channel}
onChange={setChannel}
disabled={sending}
/>
<div>
<PasswordInput
@@ -337,37 +274,7 @@ export default function SignupPage() {
error={errors.password?.message}
{...register("password")}
/>
{passwordValue.length > 0 ? (
<div className="mt-2 space-y-1">
{passwordRequirements.map((req) => {
const met = req.test(passwordValue);
return (
<div
key={req.label}
className="flex items-center gap-2"
>
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${met
? "bg-primary text-primary-foreground"
: "bg-gray-200 text-gray-500"
}`}
>
{met ? (
<Check className="h-2.5 w-2.5" />
) : (
<X className="h-2.5 w-2.5" />
)}
</span>
<span
className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}
>
{req.label}
</span>
</div>
);
})}
</div>
) : null}
<PasswordChecklist value={passwordValue} />
</div>
<PasswordInput
@@ -412,87 +319,28 @@ export default function SignupPage() {
</Stack>
</form>
) : (
<Stack gap="md">
<div className="mb-1 flex justify-center">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<ShieldCheck size={22} />
</span>
</div>
<div className="space-y-1.5 text-center">
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
Verify your {otpChannel === "email" ? "email" : "phone"}
</h1>
<p className="text-sm leading-relaxed text-gray-500">
We sent a 6 - digit code to{" "}
<span className="font-medium text-gray-700">
{otpChannel === "email"
? maskEmail(pendingData?.email ?? "")
: maskPhone(pendingData?.phone ?? "")}
</span>
.Enter it to finish creating your account.
</p>
</div>
{otpError ? (
<Alert
color="red"
variant="light"
icon={<AlertCircle size={18} />}
>
{otpError}
</Alert>
) : null}
<Stack gap={6} align="center">
<Text size="sm" fw={500} c="edr-text">
Verification code
</Text>
<PinInput
length={6}
type="number"
oneTimeCode
value={otpCode}
placeholder="0"
disabled={verifying}
styles={{ input: { textAlign: "center" } }}
onChange={setOtpCode}
/>
</Stack>
<Button
color="edr-green"
fullWidth
loading={verifying}
disabled={verifying || otpCode.trim().length !== 6}
onClick={confirmOtp}
>
Verify & create account
</Button>
<div className="flex items-center justify-between">
<Button
variant="subtle"
color="gray"
leftSection={<ArrowLeft size={14} />}
disabled={sending || verifying}
onClick={() => {
setStage("form");
setOtpError(null);
}}
>
Back
</Button>
<Button
variant="subtle"
color="edr-green"
leftSection={<RotateCw size={14} />}
disabled={resendIn > 0 || sending || verifying}
onClick={resendOtp}
>
{resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
</Button>
</div>
</Stack>
<OtpChannelStep
channel={otpChannel}
target={
otpChannel === "email"
? (pendingData?.email ?? "")
: (pendingData?.phone ?? "")
}
value={otpCode}
onChange={setOtpCode}
onVerify={confirmOtp}
onBack={() => {
setStage("form");
setOtpError(null);
}}
onResend={resendOtp}
resendIn={resendCooldown.secondsLeft}
sending={sending}
verifying={verifying}
error={otpError}
description="Enter it to finish creating your account."
submitLabel="Verify & create account"
/>
)}
</div>
</AuthShell>

View File

@@ -107,10 +107,12 @@ export function ReadonlyBookingView({
(isGeneralContract
? status === "FULLY_EXECUTED"
: status === "SELECTED_FOR_BATCH");
const canApproveDelivery =
status === "COMPLETED" ||
Boolean(booking.handoverAwaitingSignature) ||
(status === "TRUCK_ASSIGNED" && Boolean(booking.customerTruckArrivedAt));
// Approve delivery tracks the handover lifecycle exactly: the button appears
// the moment a handover is generated (truck arrival, or an operator's
// signature request) and disappears the moment the customer signs it. The
// backend flag counts only unsigned SELF_HAUL handovers, so no status
// heuristics are needed here.
const canApproveDelivery = Boolean(booking.handoverAwaitingSignature);
const usesCustomerTruck =
booking.tradeDirection === "IMPORT"
? !booking.lastMileDeliveryAddress

View File

@@ -10,7 +10,7 @@ const LABEL_BY_CODE = new Map<string, string>([
...REQUIRED_DOC_FIELDS.map((d) => [d.key, d.label] as const),
// Company onboarding document codes (see file-upload-settings seeder).
["tin_certificate", "TIN Certificate"],
["commercial_license", "Commercial License"],
["commercial_license", "Commercial Registration"],
["business_license", "Business License / Trade License"],
["investment_license", "Investment License"],
["national_id", "National ID"],

View File

@@ -9,6 +9,7 @@ import {
Group,
Loader,
Paper,
Popover,
Select,
Stack,
Table,
@@ -17,6 +18,7 @@ import {
Title,
} from "@mantine/core";
import {
AlertTriangle,
CheckCircle2,
ChevronDown,
ChevronLeft,
@@ -31,6 +33,8 @@ import {
X,
} from "lucide-react";
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api";
import { ContractCustomerAction } from "@/components/customer-actions/ContractCustomerAction";
import type { ContractListFilter } from "@/services/contracts.service";
@@ -58,14 +62,26 @@ function primaryRoute(contract: Freight.IContract) {
export default function ContractsList() {
const navigate = useNavigate();
const { company } = useAuth();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [disclaimerOpen, setDisclaimerOpen] = useState(false);
const [freightFilter, setFreightFilter] = useState<string | null>(null);
const [kindFilter, setKindFilter] = useState<string | null>(null);
const [createdFrom, setCreatedFrom] = useState<string>("");
const [createdTo, setCreatedTo] = useState<string>("");
const [expanded, setExpanded] = useState<Set<string>>(new Set());
// A contract can only be created under an approved profile — NewContractPage
// blocks every operation whose profile isn't "active". With none approved the
// page is reachable but unusable, so warn before sending the user there.
const profiles = company?.company?.companyProfiles ?? [];
const noActiveProfile =
profiles.length > 0 && !profiles.some((p) => p.status === "active");
const openNewContract = () =>
navigate("/contracts/new", { state: { fresh: true } });
const toggleExpanded = (id: string) =>
setExpanded((prev) => {
const nextSet = new Set(prev);
@@ -137,9 +153,11 @@ export default function ContractsList() {
const stats = useMemo(() => {
const items = data?.items ?? [];
const active = items.filter((c) =>
["CONTRACT_ACTIVE", "FULLY_EXECUTED", "ACTIVE_SHIPMENT_IN_PROGRESS"].includes(
c.status,
),
[
"CONTRACT_ACTIVE",
"FULLY_EXECUTED",
"ACTIVE_SHIPMENT_IN_PROGRESS",
].includes(c.status),
).length;
const pending = items.filter((c) =>
[
@@ -158,7 +176,7 @@ export default function ContractsList() {
return { active, pending, total };
}, [data]);
const total = data?.meta?.total ?? (data?.items?.length ?? 0);
const total = data?.meta?.total ?? data?.items?.length ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const pageIndex = pagination.pageIndex;
const start = total === 0 ? 0 : pageIndex * pagination.pageSize + 1;
@@ -175,19 +193,63 @@ export default function ContractsList() {
<Stack gap="lg">
{/* Header */}
<Group justify="space-between" align="center" wrap="wrap" gap="md">
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
<Title
order={1}
fw={800}
fz={26}
style={{ letterSpacing: "-0.01em" }}
>
Contracts
</Title>
<Button
color="edr-green"
<Popover
opened={disclaimerOpen}
onChange={setDisclaimerOpen}
position="bottom-end"
width={340}
radius="md"
size="md"
leftSection={<Plus size={16} />}
onClick={() => navigate("/contracts/new", { state: { fresh: true } })}
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 } }}
shadow="md"
withArrow
trapFocus
>
New Contract
</Button>
<Popover.Target>
<Button
color="edr-green"
radius="md"
size="md"
leftSection={<Plus size={16} />}
onClick={() =>
noActiveProfile
? setDisclaimerOpen((o) => !o)
: openNewContract()
}
styles={{
root: { fontWeight: 600, height: 42, paddingInline: 18 },
}}
>
New Contract
</Button>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm">
<Group gap={8} wrap="nowrap" align="flex-start">
<AlertTriangle
size={18}
color="var(--mantine-color-edr-accent-6)"
style={{ flexShrink: 0, marginTop: 1 }}
/>
<Text fz={14} fw={700} style={{ color: INK }}>
None of your profiles are active yet
</Text>
</Group>
<Text fz={13} c="dimmed">
Contracts can only be created under a profile EDR has
approved. You can continue, but every operation stays locked
until at least one profile is approved.
</Text>
</Stack>
</Popover.Dropdown>
</Popover>
</Group>
{/* Summary strip */}
@@ -378,7 +440,11 @@ export default function ContractsList() {
<Table.Tr>
<Table.Td colSpan={11}>
<Stack align="center" gap={8} py={48}>
<Inbox size={26} color={MUTED} style={{ opacity: 0.5 }} />
<Inbox
size={26}
color={MUTED}
style={{ opacity: 0.5 }}
/>
<Text fz={13} c="dimmed">
No contracts yet. Create one from New Contract.
</Text>
@@ -400,154 +466,159 @@ export default function ContractsList() {
const isOpen = expanded.has(c.id);
return (
<Fragment key={c.id}>
<Table.Tr
style={{
cursor: "pointer",
background: isOpen ? "#F4FBF8" : undefined,
}}
onClick={() => navigate(`/contracts/${c.id}`)}
>
<Table.Td>
<Box
component="button"
aria-label={isOpen ? "Hide progress" : "Show progress"}
aria-expanded={isOpen}
onClick={(e) => {
e.stopPropagation();
toggleExpanded(c.id);
}}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 28,
height: 28,
borderRadius: 8,
border: `1px solid ${BORDER}`,
background: isOpen ? GREEN : "#FFFFFF",
color: isOpen ? "#FFFFFF" : MUTED,
cursor: "pointer",
transition: "all 140ms ease",
}}
>
<ChevronDown
size={16}
style={{
transform: isOpen ? "rotate(180deg)" : "none",
transition: "transform 160ms ease",
<Table.Tr
style={{
cursor: "pointer",
background: isOpen ? "#F4FBF8" : undefined,
}}
onClick={() => navigate(`/contracts/${c.id}`)}
>
<Table.Td>
<Box
component="button"
aria-label={
isOpen ? "Hide progress" : "Show progress"
}
aria-expanded={isOpen}
onClick={(e) => {
e.stopPropagation();
toggleExpanded(c.id);
}}
/>
</Box>
</Table.Td>
<Table.Td>
<Text fz={14} fw={700} style={{ color: INK }}>
{c.reference}
</Text>
<Text fz={12} c="dimmed">
{isContainer ? "Containerised" : "Bulk"}
</Text>
</Table.Td>
<Table.Td>
<Badge
variant="light"
color={isGeneral ? "edr-green" : "gray"}
radius="sm"
>
{isGeneral ? "General" : "One-Time"}
</Badge>
</Table.Td>
<Table.Td>
<Group gap={7} wrap="nowrap" align="center">
{isContainer ? (
<Package size={15} color={MUTED} />
) : (
<Weight size={15} color={MUTED} />
)}
<Text fz={13} style={{ color: INK }}>
{isContainer ? "Container" : "Bulk"}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 28,
height: 28,
borderRadius: 8,
border: `1px solid ${BORDER}`,
background: isOpen ? GREEN : "#FFFFFF",
color: isOpen ? "#FFFFFF" : MUTED,
cursor: "pointer",
transition: "all 140ms ease",
}}
>
<ChevronDown
size={16}
style={{
transform: isOpen ? "rotate(180deg)" : "none",
transition: "transform 160ms ease",
}}
/>
</Box>
</Table.Td>
<Table.Td>
<Text fz={14} fw={700} style={{ color: INK }}>
{c.reference}
</Text>
</Group>
</Table.Td>
<Table.Td>
<Text fz={13} style={{ color: INK }}>
{origin}{" "}
<Text span c="dimmed">
</Text>{" "}
{destination}
{count > 1 && (
<Text span c="dimmed" fz={12}>
{" "}
+{count - 1}
<Text fz={12} c="dimmed">
{isContainer ? "Containerised" : "Bulk"}
</Text>
</Table.Td>
<Table.Td>
<Badge
variant="light"
color={isGeneral ? "edr-green" : "gray"}
radius="sm"
>
{isGeneral ? "General" : "One-Time"}
</Badge>
</Table.Td>
<Table.Td>
<Group gap={7} wrap="nowrap" align="center">
{isContainer ? (
<Package size={15} color={MUTED} />
) : (
<Weight size={15} color={MUTED} />
)}
<Text fz={13} style={{ color: INK }}>
{isContainer ? "Container" : "Bulk"}
</Text>
)}
</Text>
</Table.Td>
<Table.Td>
<Text
fz={13}
c={dir ? undefined : "dimmed"}
style={{ color: dir ? INK : undefined }}
>
{tradeLabel}
</Text>
</Table.Td>
<Table.Td>
<Text fz={13} style={{ color: INK }}>
{c.paymentCurrency ?? "—"}
</Text>
</Table.Td>
<Table.Td>
<Text
fz={13}
c={c.createdAt ? undefined : "dimmed"}
style={{ color: c.createdAt ? INK : undefined }}
>
{c.createdAt
? new Date(c.createdAt).toLocaleDateString()
: "—"}
</Text>
</Table.Td>
<Table.Td>
<Text
fz={13}
c={c.contractValidUntil ? undefined : "dimmed"}
style={{
color: c.contractValidUntil ? INK : undefined,
}}
>
{c.contractValidUntil
? new Date(
</Group>
</Table.Td>
<Table.Td>
<Text fz={13} style={{ color: INK }}>
{origin}{" "}
<Text span c="dimmed">
</Text>{" "}
{destination}
{count > 1 && (
<Text span c="dimmed" fz={12}>
{" "}
+{count - 1}
</Text>
)}
</Text>
</Table.Td>
<Table.Td>
<Text
fz={13}
c={dir ? undefined : "dimmed"}
style={{ color: dir ? INK : undefined }}
>
{tradeLabel}
</Text>
</Table.Td>
<Table.Td>
<Text fz={13} style={{ color: INK }}>
{c.paymentCurrency ?? "—"}
</Text>
</Table.Td>
<Table.Td>
<Text
fz={13}
c={c.createdAt ? undefined : "dimmed"}
style={{ color: c.createdAt ? INK : undefined }}
>
{c.createdAt
? new Date(c.createdAt).toLocaleDateString()
: "—"}
</Text>
</Table.Td>
<Table.Td>
<Text
fz={13}
c={c.contractValidUntil ? undefined : "dimmed"}
style={{
color: c.contractValidUntil ? INK : undefined,
}}
>
{c.contractValidUntil
? new Date(
c.contractValidUntil,
).toLocaleDateString()
: "—"}
</Text>
</Table.Td>
<Table.Td>
<ContractStatusBadge status={c.status} />
</Table.Td>
<Table.Td>
<Group justify="flex-end" gap={8} wrap="nowrap">
<ContractDocButton
contract={c}
onClick={(e) => e.stopPropagation()}
/>
<ContractCustomerAction
contract={c}
bookings={bookings}
size="sm"
listStyle
/>
</Group>
</Table.Td>
</Table.Tr>
{isOpen && (
<Table.Tr style={{ background: "#F4FBF8" }}>
<Table.Td colSpan={11} style={{ padding: "6px 20px 18px" }}>
<ContractStepBanner contract={c} />
: "—"}
</Text>
</Table.Td>
<Table.Td>
<ContractStatusBadge status={c.status} />
</Table.Td>
<Table.Td>
<Group justify="flex-end" gap={8} wrap="nowrap">
<ContractDocButton
contract={c}
onClick={(e) => e.stopPropagation()}
/>
<ContractCustomerAction
contract={c}
bookings={bookings}
size="sm"
listStyle
/>
</Group>
</Table.Td>
</Table.Tr>
)}
{isOpen && (
<Table.Tr style={{ background: "#F4FBF8" }}>
<Table.Td
colSpan={11}
style={{ padding: "6px 20px 18px" }}
>
<ContractStepBanner contract={c} />
</Table.Td>
</Table.Tr>
)}
</Fragment>
);
})}
@@ -564,7 +635,10 @@ export default function ContractsList() {
gap="md"
px={20}
py={14}
style={{ borderTop: `1px solid ${BORDER}`, background: "#FCFDFE" }}
style={{
borderTop: `1px solid ${BORDER}`,
background: "#FCFDFE",
}}
>
<Group gap={10} align="center">
<Text fz={13} c="dimmed">
@@ -574,8 +648,7 @@ export default function ContractsList() {
data={["10", "25", "50"]}
value={String(pagination.pageSize)}
onChange={(v) =>
v &&
setPagination({ pageIndex: 0, pageSize: Number(v) })
v && setPagination({ pageIndex: 0, pageSize: Number(v) })
}
radius="md"
size="xs"

View File

@@ -26,7 +26,7 @@ export default function NationalitySelect({
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<RoleCard
label="Ethiopian Company"
description="Registered in Ethiopia. You'll provide a TIN certificate, commercial license and national ID."
description="Registered in Ethiopia. You'll provide a TIN certificate, commercial registration and national ID."
icon={<MapPin size={22} />}
selected={value === "ethiopian"}
onClick={() => onChange("ethiopian")}

View File

@@ -77,6 +77,9 @@ import type {
SetPasswordPayload,
SignupPayload,
SignupResponse,
ForgotPasswordRequestPayload,
ForgotPasswordVerifyPayload,
ResetTicket,
} from "@/types/auth";
// ---------------------------------------------------------------------------
@@ -110,6 +113,21 @@ export const api = {
"setPassword",
authService.setPassword,
),
requestPasswordReset: endpoint<ForgotPasswordRequestPayload, void>(
"auth",
"requestPasswordReset",
authService.requestPasswordReset,
),
verifyPasswordResetOtp: endpoint<ForgotPasswordVerifyPayload, ResetTicket>(
"auth",
"verifyPasswordResetOtp",
authService.verifyPasswordResetOtp,
),
resetPassword: endpoint<SetPasswordPayload, void>(
"auth",
"resetPassword",
authService.resetPassword,
),
checkAvailability: endpoint<CheckAvailabilityPayload, CheckAvailabilityResponse>(
"auth",
"checkAvailability",

View File

@@ -3,11 +3,14 @@ import type {
AuthUser,
CheckAvailabilityPayload,
CheckAvailabilityResponse,
ForgotPasswordRequestPayload,
ForgotPasswordVerifyPayload,
GenerateVerificationCodePayload,
LoginPayload,
LoginResponse,
OtpPayload,
OtpResponse,
ResetTicket,
SetPasswordPayload,
SignupPayload,
SignupResponse,
@@ -53,6 +56,31 @@ export const authService = {
return res.data.data;
},
// The three calls below drive the unauthenticated forgot-password flow.
// Responses under /api/auth are *flattened* by the API's response
// interceptor ({ success, ...payload }), so there is no `.data.data` here.
requestPasswordReset: async (body: ForgotPasswordRequestPayload) => {
await client.post(URL_CONSTANTS.AUTH.FORGOT_PASSWORD_REQUEST, body);
},
verifyPasswordResetOtp: async (body: ForgotPasswordVerifyPayload) => {
const res = await client.post<ResetTicket>(
URL_CONSTANTS.AUTH.FORGOT_PASSWORD_VERIFY,
body,
);
return { userId: res.data.userId, verificationCode: res.data.verificationCode };
},
/**
* Spend the reset ticket. Distinct from `setPassword` above, which the
* authenticated post-signup flow drives through `useAuth` — this one carries
* its own userId/verificationCode and never touches the session.
*/
resetPassword: async (body: SetPasswordPayload) => {
await client.patch(URL_CONSTANTS.USERS.SET_PASSWORD, body);
},
checkAvailability: async (params: CheckAvailabilityPayload) => {
const res = await client.get<CheckAvailabilityResponse>(
URL_CONSTANTS.USERS.CHECK_AVAILABILITY,

View File

@@ -63,6 +63,25 @@ export interface SetPasswordPayload {
verificationCode: string;
}
/** The channel a password-reset code is delivered over. */
export type ResetChannel = "email" | "phone";
export interface ForgotPasswordRequestPayload {
/** Email, username, or E.164 phone — whatever the user typed, normalised. */
identifier: string;
channel: ResetChannel;
}
export interface ForgotPasswordVerifyPayload extends ForgotPasswordRequestPayload {
otp: string;
}
/** Single-use ticket to spend on `PATCH /api/auth/set-password`. */
export interface ResetTicket {
userId: string;
verificationCode: string;
}
export interface GenerateVerificationCodePayload {
email: string;
phoneNumber: string;

View File

@@ -0,0 +1,22 @@
/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */
export function normaliseIdentifier(raw: string): string {
const v = raw.trim();
const digits = v.replace(/\D/g, "");
if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) {
const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, "");
return `+251${local}`;
}
return v.toLowerCase();
}
/** Mask all but the first 7 chars of an E.164 phone for display. */
export const maskPhone = (p: string) =>
p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p;
/** Mask the local part of an email for display (j***e@example.com). */
export const maskEmail = (email: string) => {
const [local, domain] = email.split("@");
if (!local || !domain) return email;
if (local.length <= 2) return `${local[0] ?? ""}***@${domain}`;
return `${local[0]}***${local[local.length - 1]}@${domain}`;
};

View File

@@ -0,0 +1,38 @@
import { z } from "zod";
/** Live checklist shown under the password field. Mirrors {@link passwordField}. */
export const passwordRequirements = [
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
{ label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) },
{ label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) },
{ label: "One number", test: (v: string) => /\d/.test(v) },
{
label: "One special character",
test: (v: string) => /[^A-Za-z0-9]/.test(v),
},
] as const;
/**
* Must stay in step with IAM's `@IsStrongPassword()` on `InitialResetPasswordDto`
* — a password this accepts but the API rejects surfaces as an opaque 400.
*/
export const passwordField = z
.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must include an uppercase letter")
.regex(/[a-z]/, "Password must include a lowercase letter")
.regex(/\d/, "Password must include a number")
.regex(/[^A-Za-z0-9]/, "Password must include a special character");
export const confirmPasswordField = z
.string()
.min(1, "Please confirm your password");
export const samePassword = (data: {
password: string;
confirmPassword: string;
}) => data.password === data.confirmPassword;
/** Every requirement in {@link passwordRequirements} is satisfied. */
export const meetsAllRequirements = (value: string) =>
passwordRequirements.every((r) => r.test(value));