mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
The SMS gateway is domestic-only, but OTP sends fanned out to any phone on the account - a foreign number meant a code queued into the void while the response claimed success. isDomesticPhone (+2519/+2517 E.164) now gates SMS: dual-channel sends with a foreign phone go email-only (the phone stays on the row so verify still matches it), and a phone-only foreign target still tries SMS as the only route. The staff-triggered reset exposes phoneIsDomestic so the backoffice disables the SMS channel with an explanation, and the API refuses the channel directly for foreign numbers. EDRFREIGHT-186
152 lines
5.0 KiB
TypeScript
152 lines
5.0 KiB
TypeScript
import { Alert, Button, Loader, Modal, Radio, Stack, Text } from "@mantine/core";
|
|
import { useMutation, useQuery } 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">;
|
|
}
|
|
|
|
/**
|
|
* Staff-triggered password reset. Sends a single-use link to the customer's
|
|
* primary contact; the customer opens it and 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 allowed = hasPermission(user, FREIGHT_PERMS.customers.resetPassword);
|
|
|
|
// The destination is the primary contact's IAM account, not the company
|
|
// record — those are different fields and routinely hold different values, so
|
|
// showing `company.phone` here would tell staff the wrong number. Only fetched
|
|
// once the modal is open.
|
|
const targetQuery = useQuery(
|
|
api.customers.resetTarget.queryOptions({
|
|
input: { companyId: company.id },
|
|
enabled: allowed && opened,
|
|
}),
|
|
);
|
|
const target = targetQuery.data;
|
|
|
|
const { mutate, isPending } = useMutation(
|
|
api.customers.resetPassword.mutationOptions({
|
|
onSuccess: (result) => {
|
|
setOpened(false);
|
|
toast({
|
|
title: "Reset link sent",
|
|
description: `The customer can set a new password using the link sent to ${result.maskedTarget}. It expires in 24 hours.`,
|
|
});
|
|
},
|
|
onError: (error) => {
|
|
toast({
|
|
title: "Could not send reset link",
|
|
description: error.message,
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
}),
|
|
);
|
|
|
|
if (!allowed) return null;
|
|
|
|
// SMS is domestic-only: a foreign number counts as unavailable, same as a
|
|
// missing one, so staff can't send a link that will never arrive.
|
|
const phoneUsable = !!target?.phone && target.phoneIsDomestic !== false;
|
|
const channelMissing =
|
|
!!target && (channel === "email" ? !target.email : !phoneUsable);
|
|
|
|
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 link"
|
|
centered
|
|
>
|
|
<Stack gap="md">
|
|
<Text size="sm" c="dimmed">
|
|
We'll send a single-use link to this customer's primary
|
|
contact. They choose their own new password — you will not see it.
|
|
The link expires in 24 hours.
|
|
</Text>
|
|
|
|
{targetQuery.isLoading ? (
|
|
<Stack align="center" py="md">
|
|
<Loader size="sm" />
|
|
</Stack>
|
|
) : targetQuery.isError ? (
|
|
<Alert color="red" variant="light">
|
|
{targetQuery.error.message}
|
|
</Alert>
|
|
) : target ? (
|
|
<>
|
|
<Radio.Group
|
|
value={channel}
|
|
onChange={(v) => setChannel(v as ResetChannel)}
|
|
label={`Send the link to ${target.name || "the primary contact"} via`}
|
|
>
|
|
<Stack gap="xs" mt="xs">
|
|
<Radio
|
|
value="phone"
|
|
label="SMS"
|
|
disabled={!phoneUsable}
|
|
description={
|
|
!target.phone
|
|
? "No phone number on this account"
|
|
: target.phoneIsDomestic === false
|
|
? `${target.phone} — foreign number, SMS unavailable; use email`
|
|
: target.phone
|
|
}
|
|
/>
|
|
<Radio
|
|
value="email"
|
|
label="Email"
|
|
disabled={!target.email}
|
|
description={
|
|
target.email ?? "No email address on this account"
|
|
}
|
|
/>
|
|
</Stack>
|
|
</Radio.Group>
|
|
|
|
<Text size="xs" c="dimmed">
|
|
These are the primary contact's own login details, which may
|
|
differ from the company contact details on the profile.
|
|
</Text>
|
|
|
|
<Button
|
|
color="edr-green"
|
|
loading={isPending}
|
|
disabled={channelMissing}
|
|
onClick={() => mutate({ companyId: company.id, channel })}
|
|
>
|
|
Send reset link
|
|
</Button>
|
|
</>
|
|
) : null}
|
|
</Stack>
|
|
</Modal>
|
|
</>
|
|
);
|
|
}
|