mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 22:18:12 +00:00
feat: password reset flow
This commit is contained in:
@@ -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'll send a one-time code to this customer'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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -12,6 +12,8 @@ import type {
|
||||
CustomerPayment,
|
||||
PaginatedCompanies,
|
||||
ProfileStatus,
|
||||
ResetChannel,
|
||||
ResetPasswordResult,
|
||||
} from "@/types/customer";
|
||||
import {
|
||||
CreateDropdownOptionDto,
|
||||
@@ -2261,6 +2263,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user