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

@@ -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 />} />

View File

@@ -31,6 +31,7 @@ const EXCLUDED_PATH_PATTERNS = [
/^\/forgot-password/,
/^\/otp/,
/^\/set-password/,
/^\/reset-password/,
/warehouse/i,
/first-mile/i,
/last-mile/i,

View File

@@ -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: {

View File

@@ -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>
);
}

View File

@@ -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",

View File

@@ -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

View File

@@ -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;