mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 23:00:57 +00:00
feat(auth): implement staff-triggered password-reset links
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Button, Modal, Radio, Stack, Text } from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
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";
|
||||
|
||||
@@ -10,32 +10,48 @@ import { api } from "@/services/api";
|
||||
import type { Company, ResetChannel } from "@/types/customer";
|
||||
|
||||
export interface ResetPasswordActionProps {
|
||||
company: Pick<Company, "id" | "email" | "phone">;
|
||||
company: Pick<Company, "id">;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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) {
|
||||
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 code sent",
|
||||
description: `The customer can now reset their password using the code sent to ${result.maskedTarget}.`,
|
||||
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 code",
|
||||
title: "Could not send reset link",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
@@ -43,7 +59,10 @@ export default function ResetPasswordAction({ company }: ResetPasswordActionProp
|
||||
}),
|
||||
);
|
||||
|
||||
if (!hasPermission(user, FREIGHT_PERMS.customers.resetPassword)) return null;
|
||||
if (!allowed) return null;
|
||||
|
||||
const channelMissing =
|
||||
!!target && (channel === "email" ? !target.email : !target.phone);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -58,46 +77,66 @@ export default function ResetPasswordAction({ company }: ResetPasswordActionProp
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
title="Send a password-reset code"
|
||||
title="Send a password-reset link"
|
||||
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.
|
||||
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>
|
||||
|
||||
<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"}
|
||||
/>
|
||||
{targetQuery.isLoading ? (
|
||||
<Stack align="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
) : 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={!target.phone}
|
||||
description={
|
||||
target.phone ?? "No phone number on this account"
|
||||
}
|
||||
/>
|
||||
<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">
|
||||
The code goes to the primary contact's own email or phone, which
|
||||
may differ from the company contact details shown above.
|
||||
</Text>
|
||||
<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}
|
||||
onClick={() => mutate({ companyId: company.id, channel })}
|
||||
>
|
||||
Send reset code
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={isPending}
|
||||
disabled={channelMissing}
|
||||
onClick={() => mutate({ companyId: company.id, channel })}
|
||||
>
|
||||
Send reset link
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user