fix: add password form to account

This commit is contained in:
Nathnael
2026-07-16 12:37:52 +00:00
parent f71bbbf782
commit e2f42136a8
6 changed files with 381 additions and 163 deletions

View File

@@ -5,6 +5,7 @@ export const URL_CONSTANTS = {
REFRESH_TOKEN: "/api/auth/refresh-token",
LOGOUT: "/api/auth/logout",
PROFILE: "/auth/profile",
CHANGE_PASSWORD: "/api/auth/change-password",
FORGOT_PASSWORD_REQUEST: "/api/auth/forgot-password/request",
FORGOT_PASSWORD_VERIFY: "/api/auth/forgot-password/verify",
},

View File

@@ -0,0 +1,165 @@
import { useMutation } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { CheckCircle2, KeyRound, Save, XCircle } from "lucide-react";
import {
Button,
Card,
Group,
PasswordInput,
Stack,
Text,
Title,
} from "@mantine/core";
import { api } from "@/services/api";
/**
* Mirrors IAM's own `IsStrongPassword` rule on ChangePasswordDto — minLength 8,
* ≥1 lowercase, ≥1 number, ≥1 symbol, uppercase NOT required. Kept in step with
* the server so the user gets a precise message inline instead of a generic 400.
*/
const strongPassword = z
.string()
.min(8, "At least 8 characters")
.regex(/[a-z]/, "Include a lowercase letter")
.regex(/\d/, "Include a number")
.regex(/[^A-Za-z0-9]/, "Include a symbol");
const schema = z
.object({
oldPassword: z.string().min(1, "Current password is required"),
newPassword: strongPassword,
confirmPassword: z.string().min(1, "Confirm your new password"),
})
.refine((d) => d.newPassword === d.confirmPassword, {
path: ["confirmPassword"],
message: "Passwords do not match",
})
.refine((d) => d.newPassword !== d.oldPassword, {
path: ["newPassword"],
message: "New password must be different from your current one",
});
type FormData = z.infer<typeof schema>;
/** IAM returns bare error codes; turn them into something a customer can act on. */
const MESSAGES: Record<string, string> = {
unable_to_change_password: "Your current password is incorrect.",
new_password_same_as_old:
"New password must be different from your current one.",
new_passwords_do_not_match: "The new passwords do not match.",
user_credentials_not_found: "This account has no password set.",
};
/**
* Password changes go straight to IAM's `PATCH /api/auth/change-password`, which
* verifies the old password and owns the credential write (argon hashing,
* retiring the previous credential). The freight app deliberately implements no
* part of that — it only collects the fields.
*/
export default function ChangePasswordCard() {
const {
register,
handleSubmit,
reset,
formState: { errors, isDirty },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { oldPassword: "", newPassword: "", confirmPassword: "" },
});
const mutation = useMutation({
mutationFn: (data: FormData) => api.auth.changePassword.call(data),
// Never leave the old password sitting in component state after a change.
onSuccess: () => reset(),
});
const errorMessage = (err: unknown): string => {
const raw = (
err as { response?: { data?: { message?: string | string[] } } }
)?.response?.data?.message;
const code = Array.isArray(raw) ? raw[0] : raw;
if (!code) return "Could not change your password. Please try again.";
return MESSAGES[code] ?? code;
};
return (
<Card padding="lg">
<Group gap="sm" mb="xs">
<KeyRound size={20} />
<Title order={3}>Password</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
Change the password you use to sign in. You'll need your current one.
</Text>
<form onSubmit={handleSubmit((d) => mutation.mutate(d))}>
<Stack gap="md">
<PasswordInput
label="Current Password"
autoComplete="current-password"
error={errors.oldPassword?.message}
{...register("oldPassword")}
/>
<PasswordInput
label="New Password"
description="At least 8 characters, with a lowercase letter, a number and a symbol."
autoComplete="new-password"
error={errors.newPassword?.message}
{...register("newPassword")}
/>
<PasswordInput
label="Confirm New Password"
autoComplete="new-password"
error={errors.confirmPassword?.message}
{...register("confirmPassword")}
/>
</Stack>
<Group
justify="space-between"
mt="xl"
pt="md"
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Group gap="xs">
{mutation.isSuccess && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm" fw={500}>
Password changed
</Text>
</Group>
)}
{mutation.isError && (
<Group gap={6} c="red">
<XCircle size={16} />
<Text size="sm" fw={500}>
{errorMessage(mutation.error)}
</Text>
</Group>
)}
</Group>
<Group gap="md">
<Button
type="button"
variant="outline"
disabled={mutation.isPending || !isDirty}
onClick={() => reset()}
>
Reset
</Button>
<Button
type="submit"
leftSection={<Save size={16} />}
loading={mutation.isPending}
>
Change Password
</Button>
</Group>
</Group>
</form>
</Card>
);
}

View File

@@ -3,7 +3,13 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { CheckCircle2, Save, ShieldCheck, UserCog, XCircle } from "lucide-react";
import {
CheckCircle2,
Save,
ShieldCheck,
UserCog,
XCircle,
} from "lucide-react";
import {
Alert,
Button,
@@ -23,6 +29,7 @@ import {
toEthiopianE164,
} from "@/components/PhoneField";
import type { AuthUser, ContactChannel } from "@/types/auth";
import ChangePasswordCard from "./ChangePasswordCard";
const schema = z.object({
phoneNumber: z
@@ -110,13 +117,16 @@ export default function TabAccount({ user }: TabAccountProps) {
/** Name needs no proof of possession, so it saves straight through. */
const nameMutation = useMutation({
mutationFn: (data: FormData) =>
api.account.updateName.call({ name: { en: data.nameEn, am: data.nameAm } }),
api.account.updateName.call({
name: { en: data.nameEn, am: data.nameAm },
}),
onSuccess: refreshUser,
});
/** Step 1 of a contact change: ask the server to code the new value. */
const otpMutation = useMutation({
mutationFn: (change: PendingChange) => api.account.sendContactOtp.call(change),
mutationFn: (change: PendingChange) =>
api.account.sendContactOtp.call(change),
onSuccess: (res) => {
setOtp("");
setSentTo(res.sentTo);
@@ -183,14 +193,17 @@ export default function TabAccount({ user }: TabAccountProps) {
};
const errorMessage = (err: unknown): string => {
const res = (err as { response?: { data?: { message?: string | string[] } } })
?.response?.data?.message;
const res = (
err as { response?: { data?: { message?: string | string[] } } }
)?.response?.data?.message;
if (Array.isArray(res)) return res[0];
return res ?? "Something went wrong. Please try again.";
};
const busy =
otpMutation.isPending || contactMutation.isPending || nameMutation.isPending;
otpMutation.isPending ||
contactMutation.isPending ||
nameMutation.isPending;
const savedSummary =
completed.length && !queue.length
@@ -200,175 +213,187 @@ export default function TabAccount({ user }: TabAccountProps) {
: null;
return (
<Card padding="lg">
<Group gap="sm" mb="xs">
<UserCog size={20} />
<Title order={3}>Account</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
Your login details. Verification codes and SMS notifications are sent to
the phone number below.
</Text>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<ControlledPhoneField
control={control}
name="phoneNumber"
label="Phone Number"
required
/>
<TextInput
label="Email"
placeholder="you@company.com"
error={errors.email?.message}
{...register("email")}
/>
<TextInput
label="Full Name"
placeholder="Abebe Bekele"
error={errors.nameEn?.message}
{...register("nameEn")}
/>
<TextInput
label="Full Name (Amharic)"
placeholder="አበበ በቀለ"
error={errors.nameAm?.message}
{...register("nameAm")}
/>
</Stack>
<Text c="edr-muted" size="xs" mt="sm">
Changing your phone number or email requires a verification code sent
to the new one.
<Stack gap="lg">
<Card padding="lg">
<Group gap="sm" mb="xs">
<UserCog size={20} />
<Title order={3}>Account</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
Your login details. Verification codes and SMS notifications are sent
to the phone number below.
</Text>
<Group
justify="space-between"
mt="xl"
pt="md"
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Group gap="xs">
{savedSummary && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm" fw={500}>{savedSummary}</Text>
</Group>
)}
{nameMutation.isSuccess && !savedSummary && !queue.length && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm" fw={500}>Saved successfully</Text>
</Group>
)}
{(otpMutation.isError || nameMutation.isError) && (
<Group gap={6} c="red">
<XCircle size={16} />
<Text size="sm" fw={500}>
{errorMessage(otpMutation.error ?? nameMutation.error)}
</Text>
</Group>
)}
</Group>
<Group gap="md">
<Button
type="button"
variant="outline"
disabled={busy || !isDirty}
onClick={() => reset()}
>
Reset
</Button>
<Button type="submit" leftSection={<Save size={16} />} loading={busy}>
Save Changes
</Button>
</Group>
</Group>
</form>
<Modal
opened={current !== null}
onClose={cancelQueue}
title="Verify your new contact details"
centered
>
{current && (
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
{totalSteps > 1 && (
<Text size="sm" c="edr-muted">
Step {step} of {totalSteps}
</Text>
)}
<Alert icon={<ShieldCheck size={16} />} color="blue">
{sentTo ? (
<>
We sent a 6-digit code to <b>{sentTo}</b>. Enter it to confirm
your new {CHANNEL_LABEL[current.channel]}.
</>
) : (
<>Sending a code to your new {CHANNEL_LABEL[current.channel]}</>
)}
</Alert>
{completed.length > 0 && queue.length > 0 && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm">
{CHANNEL_LABEL[completed[completed.length - 1]]} updated one
more to confirm.
</Text>
</Group>
)}
<PinInput
length={6}
type="number"
oneTimeCode
value={otp}
onChange={setOtp}
disabled={!sentTo || otpMutation.isPending}
aria-label="Verification code"
<ControlledPhoneField
control={control}
name="phoneNumber"
label="Phone Number"
required
/>
{contactMutation.isError && (
<Group gap={6} c="red">
<XCircle size={16} />
<Text size="sm">{errorMessage(contactMutation.error)}</Text>
</Group>
)}
<TextInput
label="Email"
placeholder="you@company.com"
error={errors.email?.message}
{...register("email")}
/>
<Group justify="space-between">
<TextInput
label="Full Name"
placeholder="Abebe Bekele"
error={errors.nameEn?.message}
{...register("nameEn")}
/>
<TextInput
label="Full Name (Amharic)"
placeholder="አበበ በቀለ"
error={errors.nameAm?.message}
{...register("nameAm")}
/>
</Stack>
<Text c="edr-muted" size="xs" mt="sm">
Changing your phone number or email requires a verification code
sent to the new one.
</Text>
<Group
justify="space-between"
mt="xl"
pt="md"
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Group gap="xs">
{savedSummary && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm" fw={500}>
{savedSummary}
</Text>
</Group>
)}
{nameMutation.isSuccess && !savedSummary && !queue.length && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm" fw={500}>
Saved successfully
</Text>
</Group>
)}
{(otpMutation.isError || nameMutation.isError) && (
<Group gap={6} c="red">
<XCircle size={16} />
<Text size="sm" fw={500}>
{errorMessage(otpMutation.error ?? nameMutation.error)}
</Text>
</Group>
)}
</Group>
<Group gap="md">
<Button
variant="subtle"
disabled={otpMutation.isPending || !sentTo}
onClick={() => {
// Clear any "invalid code" error first: the user is asking for
// a fresh code, not retrying the old one, so leaving the
// failure on screen would describe a request they didn't make.
contactMutation.reset();
otpMutation.mutate(current);
}}
type="button"
variant="outline"
disabled={busy || !isDirty}
onClick={() => reset()}
>
Resend code
Reset
</Button>
<Button
loading={contactMutation.isPending}
disabled={otp.length !== 6 || !sentTo}
onClick={() =>
contactMutation.mutate({ ...current, otp })
}
type="submit"
leftSection={<Save size={16} />}
loading={busy}
>
Verify &amp; Save
Save Changes
</Button>
</Group>
</Stack>
)}
</Modal>
</Card>
</Group>
</form>
<Modal
opened={current !== null}
onClose={cancelQueue}
title="Verify your new contact details"
centered
>
{current && (
<Stack gap="md">
{totalSteps > 1 && (
<Text size="sm" c="edr-muted">
Step {step} of {totalSteps}
</Text>
)}
<Alert icon={<ShieldCheck size={16} />} color="blue">
{sentTo ? (
<>
We sent a 6-digit code to <b>{sentTo}</b>. Enter it to
confirm your new {CHANNEL_LABEL[current.channel]}.
</>
) : (
<>
Sending a code to your new {CHANNEL_LABEL[current.channel]}
</>
)}
</Alert>
{completed.length > 0 && queue.length > 0 && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm">
{CHANNEL_LABEL[completed[completed.length - 1]]} updated
one more to confirm.
</Text>
</Group>
)}
<PinInput
length={6}
type="number"
oneTimeCode
value={otp}
onChange={setOtp}
disabled={!sentTo || otpMutation.isPending}
aria-label="Verification code"
/>
{contactMutation.isError && (
<Group gap={6} c="red">
<XCircle size={16} />
<Text size="sm">{errorMessage(contactMutation.error)}</Text>
</Group>
)}
<Group justify="space-between">
<Button
variant="subtle"
disabled={otpMutation.isPending || !sentTo}
onClick={() => {
// Clear any "invalid code" error first: the user is asking for
// a fresh code, not retrying the old one, so leaving the
// failure on screen would describe a request they didn't make.
contactMutation.reset();
otpMutation.mutate(current);
}}
>
Resend code
</Button>
<Button
loading={contactMutation.isPending}
disabled={otp.length !== 6 || !sentTo}
onClick={() => contactMutation.mutate({ ...current, otp })}
>
Verify &amp; Save
</Button>
</Group>
</Stack>
)}
</Modal>
</Card>
<ChangePasswordCard />
</Stack>
);
}

View File

@@ -68,6 +68,7 @@ import type {
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import type {
AuthUser,
ChangePasswordPayload,
CheckAvailabilityPayload,
CheckAvailabilityResponse,
GenerateVerificationCodePayload,
@@ -149,6 +150,11 @@ export const api = {
"verifyOTP",
authService.verifyOTP,
),
changePassword: endpoint<ChangePasswordPayload, void>(
"auth",
"changePassword",
authService.changePassword,
),
logout: endpoint<void, void>("auth", "logout", authService.logout),
},

View File

@@ -1,6 +1,7 @@
import { URL_CONSTANTS } from "@/constants/URLS";
import type {
AuthUser,
ChangePasswordPayload,
CheckAvailabilityPayload,
CheckAvailabilityResponse,
ForgotPasswordRequestPayload,
@@ -111,6 +112,15 @@ export const authService = {
return res.data.data;
},
/**
* Change the signed-in user's password via IAM's own route: it verifies the
* old password with argon and owns the credential write (deactivating the
* previous one), so this app never touches password material.
*/
changePassword: async (body: ChangePasswordPayload) => {
await client.patch(URL_CONSTANTS.AUTH.CHANGE_PASSWORD, body);
},
// The three calls below manage the signed-in user's own account record
// (`/api/me`), which is what OTPs and SMS notifications are delivered to.
// Changing phone/email is OTP-gated server-side: the code goes to the NEW

View File

@@ -45,6 +45,17 @@ export interface OtpResponse {
message: string;
}
/**
* Change the signed-in user's password. The old password is the proof of
* possession — IAM verifies it server-side and owns the credential write, so the
* freight app never hashes or stores a password itself.
*/
export interface ChangePasswordPayload {
oldPassword: string;
newPassword: string;
confirmPassword: string;
}
/** The contact channel being changed on the signed-in user's own account. */
export type ContactChannel = "email" | "phone";