feat(auth): implement staff-triggered password-reset links

This commit is contained in:
Nathnael
2026-07-20 11:20:50 +00:00
parent bed1208dee
commit 6420c72e89
21 changed files with 688 additions and 73 deletions

View File

@@ -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&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.
We&apos;ll send a single-use link to this customer&apos;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&apos;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&apos;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>
</>