mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +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>
|
||||
</>
|
||||
|
||||
@@ -38,6 +38,8 @@ export const QUERY_KEYS = {
|
||||
documents: (id: string) =>
|
||||
["customers", "detail", id, "documents"] as const,
|
||||
payments: (id: string) => ["customers", "detail", id, "payments"] as const,
|
||||
resetTarget: (id: string) =>
|
||||
["customers", "detail", id, "reset-target"] as const,
|
||||
changeRequests: (id: string) =>
|
||||
["customers", "detail", id, "change-requests"] as const,
|
||||
},
|
||||
|
||||
@@ -88,6 +88,8 @@ export const URL_CONSTANTS = {
|
||||
`/payments/by-company/${id}/customer-view`,
|
||||
RESET_PASSWORD: (companyId: string) =>
|
||||
`/backoffice/customers/${companyId}/reset-password`,
|
||||
RESET_TARGET: (companyId: string) =>
|
||||
`/backoffice/customers/${companyId}/reset-target`,
|
||||
},
|
||||
|
||||
BILLING: {
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
CustomerBooking,
|
||||
CustomerDocument,
|
||||
CustomerPayment,
|
||||
CustomerResetTarget,
|
||||
PaginatedCompanies,
|
||||
ProfileStatus,
|
||||
ResetChannel,
|
||||
@@ -2581,6 +2582,13 @@ export const api = {
|
||||
({ id }) => QUERY_KEYS.CUSTOMERS.payments(id),
|
||||
),
|
||||
|
||||
resetTarget: endpoint<{ companyId: string }, CustomerResetTarget>(
|
||||
"customers",
|
||||
"resetTarget",
|
||||
({ companyId }) => customersService.resetTarget(companyId),
|
||||
({ companyId }) => QUERY_KEYS.CUSTOMERS.resetTarget(companyId),
|
||||
),
|
||||
|
||||
resetPassword: endpoint<
|
||||
{ companyId: string; channel: ResetChannel },
|
||||
ResetPasswordResult
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
CustomerBooking,
|
||||
CustomerDocument,
|
||||
CustomerPayment,
|
||||
CustomerResetTarget,
|
||||
PaginatedCompanies,
|
||||
ProfileStatus,
|
||||
ResetChannel,
|
||||
@@ -89,8 +90,20 @@ export const customersService = {
|
||||
},
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The IAM account a reset link would go to. Read before offering the action
|
||||
* so staff see the credentials the link actually reaches, not the company's
|
||||
* business contact details.
|
||||
*/
|
||||
resetTarget(companyId: string): Promise<CustomerResetTarget> {
|
||||
return apiClient
|
||||
.get<CustomerResetTarget>(URL_CONSTANTS.COMPANIES.RESET_TARGET(companyId))
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a password-reset link to the company's primary contact. Staff never
|
||||
* receive a credential — the customer opens the link and sets their own
|
||||
* password.
|
||||
*/
|
||||
resetPassword(
|
||||
companyId: string,
|
||||
|
||||
@@ -110,13 +110,27 @@ export interface CompanyChangeRequest {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** The channel a customer's password-reset code is delivered over. */
|
||||
/** The channel a customer's password-reset link 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. */
|
||||
/** Where the link went, e.g. `+251•••4821` — safe to show to staff. */
|
||||
maskedTarget: string;
|
||||
/** ISO timestamp after which the link stops working. */
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The IAM account a reset link would reach — the company's primary contact.
|
||||
* Distinct from `Company.email` / `Company.phone`, which are business contact
|
||||
* details and routinely differ from the credentials the customer logs in with.
|
||||
*/
|
||||
export interface CustomerResetTarget {
|
||||
userId: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
}
|
||||
|
||||
/** Mirrors backend `Company` (+ its `companyProfiles`). */
|
||||
|
||||
@@ -35,6 +35,7 @@ import MyPortalPage from "./pages/MyPortalPage";
|
||||
import MySignaturePage from "./pages/MySignaturePage";
|
||||
import SettingsPage from "./pages/SettingsPage";
|
||||
import ForgotPasswordPage from "./pages/accounts/ForgotPasswordPage";
|
||||
import ResetPasswordLinkPage from "./pages/accounts/ResetPasswordLinkPage";
|
||||
import LoginPage from "./pages/accounts/LoginPage";
|
||||
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
|
||||
import SignupPage from "./pages/accounts/SignupPage";
|
||||
@@ -265,6 +266,11 @@ const App = () => {
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Staff-issued reset links land here. Deliberately outside
|
||||
RedirectIfAuthed: a customer with a stale session still needs the link
|
||||
to work, and the token — not the session — is what authorises it. */}
|
||||
<Route path="/reset-password" element={<ResetPasswordLinkPage />} />
|
||||
|
||||
{/* Signup-flow pages; reached while a session already exists */}
|
||||
<Route path="/otp" element={<VerificationOtpPage />} />
|
||||
<Route path="/set-password" element={<SetPasswordPage />} />
|
||||
|
||||
@@ -31,6 +31,7 @@ const EXCLUDED_PATH_PATTERNS = [
|
||||
/^\/forgot-password/,
|
||||
/^\/otp/,
|
||||
/^\/set-password/,
|
||||
/^\/reset-password/,
|
||||
/warehouse/i,
|
||||
/first-mile/i,
|
||||
/last-mile/i,
|
||||
|
||||
@@ -8,6 +8,7 @@ export const URL_CONSTANTS = {
|
||||
CHANGE_PASSWORD: "/api/auth/change-password",
|
||||
FORGOT_PASSWORD_REQUEST: "/api/auth/forgot-password/request",
|
||||
FORGOT_PASSWORD_VERIFY: "/api/auth/forgot-password/verify",
|
||||
FORGOT_PASSWORD_RESOLVE_LINK: "/api/auth/forgot-password/resolve-link",
|
||||
},
|
||||
|
||||
USERS: {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { Alert, Button, Loader, PasswordInput, Stack } from "@mantine/core";
|
||||
import { AlertCircle, KeyRound } from "lucide-react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
|
||||
import AuthShell from "@/components/auth/AuthShell";
|
||||
import PasswordChecklist from "@/components/auth/PasswordChecklist";
|
||||
import { api } from "@/services/api";
|
||||
import type { ResetLinkAccount } from "@/types/auth";
|
||||
import { meetsAllRequirements } from "@/utils/passwordSchema";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
/**
|
||||
* Lands the password-reset link a staff member sends from the backoffice
|
||||
* (`/reset-password?uid=…&token=…`).
|
||||
*
|
||||
* The link itself is the proof of possession — it was delivered to the address
|
||||
* on the account — so there is no code to type. The token is validated before
|
||||
* the form appears, which is what lets an expired link say so up front instead
|
||||
* of after a password has been chosen.
|
||||
*/
|
||||
export default function ResetPasswordLinkPage() {
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const userId = params.get("uid") ?? "";
|
||||
const token = params.get("token") ?? "";
|
||||
|
||||
const [account, setAccount] = useState<ResetLinkAccount | null>(null);
|
||||
const [checking, setChecking] = useState(true);
|
||||
const [linkError, setLinkError] = useState<string | null>(null);
|
||||
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId || !token) {
|
||||
setLinkError(
|
||||
"This password-reset link is incomplete. Open the full link from your email or SMS.",
|
||||
);
|
||||
setChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
api.auth.resolveResetLink
|
||||
.call({ userId, token })
|
||||
.then((resolved) => {
|
||||
if (!cancelled) setAccount(resolved);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setLinkError(extractApiError(err).message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setChecking(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [userId, token]);
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!account) return;
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await api.auth.resetPassword.call({
|
||||
userId: account.userId,
|
||||
// Resolved server-side from the token — the account holder never types
|
||||
// an identifier, so there is nothing here to get wrong.
|
||||
email: account.identifier,
|
||||
verificationCode: account.verificationCode,
|
||||
newPassword: password,
|
||||
confirmPassword,
|
||||
});
|
||||
navigate("/login", { replace: true, state: { passwordReset: true } });
|
||||
} catch (err) {
|
||||
setError(extractApiError(err).message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
tagline="Set a new password"
|
||||
taglineBody="Choose a new password for your EDR Freight account."
|
||||
>
|
||||
<div 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">
|
||||
Choose a new password
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
{checking
|
||||
? "Checking your reset link…"
|
||||
: account
|
||||
? `Resetting the password for ${account.maskedIdentifier}.`
|
||||
: "This link can no longer be used."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{checking ? (
|
||||
<div className="flex justify-center py-6">
|
||||
<Loader size="sm" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!checking && linkError ? (
|
||||
<Stack gap="md">
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||
{linkError}
|
||||
</Alert>
|
||||
<Button
|
||||
color="edr-green"
|
||||
fullWidth
|
||||
onClick={() => navigate("/forgot-password")}
|
||||
>
|
||||
Request a new link
|
||||
</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>
|
||||
) : null}
|
||||
|
||||
{!checking && account ? (
|
||||
<form onSubmit={handleSubmit} className="flex w-full flex-col">
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<PasswordInput
|
||||
label="New password"
|
||||
placeholder="Create a strong password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
disabled={submitting}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
<PasswordChecklist value={password} />
|
||||
</div>
|
||||
|
||||
<PasswordInput
|
||||
label="Confirm new password"
|
||||
placeholder="Re-enter your password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
disabled={submitting}
|
||||
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={submitting}
|
||||
disabled={
|
||||
submitting ||
|
||||
!meetsAllRequirements(password) ||
|
||||
password !== confirmPassword
|
||||
}
|
||||
>
|
||||
Reset password
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
@@ -81,7 +81,9 @@ import type {
|
||||
SignupResponse,
|
||||
ForgotPasswordRequestPayload,
|
||||
ForgotPasswordVerifyPayload,
|
||||
ResetLinkAccount,
|
||||
ResetTicket,
|
||||
ResolveResetLinkPayload,
|
||||
SendContactOtpPayload,
|
||||
SendContactOtpResponse,
|
||||
UpdateAccountNamePayload,
|
||||
@@ -130,6 +132,11 @@ export const api = {
|
||||
"verifyPasswordResetOtp",
|
||||
authService.verifyPasswordResetOtp,
|
||||
),
|
||||
resolveResetLink: endpoint<ResolveResetLinkPayload, ResetLinkAccount>(
|
||||
"auth",
|
||||
"resolveResetLink",
|
||||
authService.resolveResetLink,
|
||||
),
|
||||
resetPassword: endpoint<SetPasswordPayload, void>(
|
||||
"auth",
|
||||
"resetPassword",
|
||||
|
||||
@@ -11,7 +11,9 @@ import type {
|
||||
LoginResponse,
|
||||
OtpPayload,
|
||||
OtpResponse,
|
||||
ResetLinkAccount,
|
||||
ResetTicket,
|
||||
ResolveResetLinkPayload,
|
||||
SendContactOtpPayload,
|
||||
SendContactOtpResponse,
|
||||
SetPasswordPayload,
|
||||
@@ -79,6 +81,19 @@ export const authService = {
|
||||
return { userId: res.data.userId, verificationCode: res.data.verificationCode };
|
||||
},
|
||||
|
||||
/**
|
||||
* Validate a staff-issued reset link before showing the password form, and
|
||||
* pick up the ticket it carries. Rejected links (expired, already spent) fail
|
||||
* here rather than after the customer has typed a new password.
|
||||
*/
|
||||
resolveResetLink: async (body: ResolveResetLinkPayload) => {
|
||||
const res = await client.post<ResetLinkAccount>(
|
||||
URL_CONSTANTS.AUTH.FORGOT_PASSWORD_RESOLVE_LINK,
|
||||
body,
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Spend the reset ticket. Distinct from `setPassword` above, which the
|
||||
* authenticated post-signup flow drives through `useAuth` — this one carries
|
||||
|
||||
@@ -121,6 +121,21 @@ export interface ResetTicket {
|
||||
verificationCode: string;
|
||||
}
|
||||
|
||||
/** The `uid` / `token` pair carried by a staff-issued password-reset link. */
|
||||
export interface ResolveResetLinkPayload {
|
||||
userId: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A validated reset link. Carries the identifier IAM matches the account on, so
|
||||
* the customer never has to type one — plus a masked copy safe to display.
|
||||
*/
|
||||
export interface ResetLinkAccount extends ResetTicket {
|
||||
identifier: string;
|
||||
maskedIdentifier: string;
|
||||
}
|
||||
|
||||
export interface GenerateVerificationCodePayload {
|
||||
email: string;
|
||||
phoneNumber: string;
|
||||
|
||||
Reference in New Issue
Block a user