mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-02 19:43:39 +00:00
feat: setup forget password to the backoffice
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
||||
import { AlertCircle, ArrowLeft, ArrowRight, KeyRound } from "lucide-react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
requestPasswordResetRequest,
|
||||
resetPasswordRequest,
|
||||
verifyPasswordResetOtpRequest,
|
||||
} from "@/auth/api";
|
||||
import type { ResetTicket } from "@/auth/types";
|
||||
import AuthShell from "@/components/auth/AuthShell";
|
||||
import OtpChannelStep, {
|
||||
OTP_LENGTH,
|
||||
OtpChannelSelect,
|
||||
type OtpChannel,
|
||||
} from "@/components/auth/OtpChannelStep";
|
||||
import PasswordChecklist from "@/components/auth/PasswordChecklist";
|
||||
import { useResendCooldown } from "@/hooks/useResendCooldown";
|
||||
import { normaliseIdentifier } from "@/utils/identifier";
|
||||
import { meetsAllRequirements } from "@/utils/passwordSchema";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
type Stage = "identify" | "otp" | "password";
|
||||
|
||||
const ForgotPasswordPage = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [stage, setStage] = useState<Stage>("identify");
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [channel, setChannel] = useState<OtpChannel>("phone");
|
||||
const [otpCode, setOtpCode] = useState("");
|
||||
// The reset ticket lives in memory only — persisting it would leave a
|
||||
// password-change credential sitting in localStorage.
|
||||
const [ticket, setTicket] = useState<ResetTicket | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
|
||||
const [sending, setSending] = useState(false);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const resendCooldown = useResendCooldown();
|
||||
|
||||
/** The identifier as the API will see it — normalised once, reused everywhere. */
|
||||
const normalised = normaliseIdentifier(identifier);
|
||||
|
||||
const sendCode = async () => {
|
||||
await requestPasswordResetRequest({ identifier: normalised, channel });
|
||||
setOtpCode("");
|
||||
resendCooldown.start();
|
||||
};
|
||||
|
||||
// Stage 1 — ask for a code. The API answers identically for unknown accounts,
|
||||
// so we always advance; a non-existent identifier simply never receives a code.
|
||||
const handleIdentify = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSending(true);
|
||||
try {
|
||||
await sendCode();
|
||||
setStage("otp");
|
||||
} catch (err) {
|
||||
setError(extractApiError(err).message);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResend = async () => {
|
||||
setError(null);
|
||||
setSending(true);
|
||||
try {
|
||||
await sendCode();
|
||||
} catch (err) {
|
||||
setError(extractApiError(err).message);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Stage 2 — trade the code for a single-use ticket.
|
||||
const handleVerify = async () => {
|
||||
setError(null);
|
||||
if (otpCode.trim().length !== OTP_LENGTH) {
|
||||
setError(`Enter the ${OTP_LENGTH}-digit code we sent you.`);
|
||||
return;
|
||||
}
|
||||
setVerifying(true);
|
||||
try {
|
||||
const result = await verifyPasswordResetOtpRequest({
|
||||
identifier: normalised,
|
||||
channel,
|
||||
otp: otpCode.trim(),
|
||||
});
|
||||
setTicket(result);
|
||||
setStage("password");
|
||||
} catch (err) {
|
||||
setError(extractApiError(err).message);
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Stage 3 — spend the ticket on IAM's set-password.
|
||||
const handleReset = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!ticket) {
|
||||
setError("Your reset session expired. Start again.");
|
||||
setStage("identify");
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
|
||||
setVerifying(true);
|
||||
try {
|
||||
await resetPasswordRequest({
|
||||
userId: ticket.userId,
|
||||
// The API matches this against email / username / phone, so the typed
|
||||
// identifier works regardless of which one it is.
|
||||
email: normalised,
|
||||
verificationCode: ticket.verificationCode,
|
||||
newPassword: password,
|
||||
confirmPassword,
|
||||
});
|
||||
navigate("/auth", {
|
||||
replace: true,
|
||||
state: { passwordReset: true },
|
||||
});
|
||||
} catch (err) {
|
||||
setError(extractApiError(err).message);
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const identifierLabel =
|
||||
channel === "email" ? "the email on your account" : "the phone on your account";
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
tagline="Recover your account"
|
||||
taglineBody="Reset your EDR Freight backoffice password with a one-time code sent to your email or phone."
|
||||
>
|
||||
<div className="flex w-full flex-col">
|
||||
{stage === "identify" ? (
|
||||
<form onSubmit={handleIdentify} 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">
|
||||
Forgot your password?
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
Enter your email or phone number and we'll send you a code to
|
||||
reset it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Email or Phone"
|
||||
placeholder="name@company.com or 09XXXXXXXX"
|
||||
autoComplete="username"
|
||||
required
|
||||
disabled={sending}
|
||||
value={identifier}
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
/>
|
||||
|
||||
<OtpChannelSelect
|
||||
value={channel}
|
||||
onChange={setChannel}
|
||||
disabled={sending}
|
||||
label="Send the code to"
|
||||
/>
|
||||
|
||||
<p className="text-xs text-gray-500">
|
||||
The code goes to {identifierLabel}, which may differ from what you
|
||||
typed above.
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
color="edr-green"
|
||||
fullWidth
|
||||
loading={sending}
|
||||
disabled={!identifier.trim()}
|
||||
rightSection={!sending ? <ArrowRight size={16} /> : undefined}
|
||||
>
|
||||
Send code
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
Remembered it?{" "}
|
||||
<Link to="/auth" className="font-semibold text-primary hover:underline">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</p>
|
||||
</Stack>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{stage === "otp" ? (
|
||||
<OtpChannelStep
|
||||
channel={channel}
|
||||
target={normalised}
|
||||
value={otpCode}
|
||||
onChange={setOtpCode}
|
||||
onVerify={handleVerify}
|
||||
onBack={() => {
|
||||
setStage("identify");
|
||||
setError(null);
|
||||
}}
|
||||
onResend={handleResend}
|
||||
resendIn={resendCooldown.secondsLeft}
|
||||
sending={sending}
|
||||
verifying={verifying}
|
||||
error={error}
|
||||
title="Enter your reset code"
|
||||
description="Enter it to choose a new password."
|
||||
submitLabel="Verify code"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{stage === "password" ? (
|
||||
<form onSubmit={handleReset} className="flex w-full flex-col">
|
||||
<div className="mb-4 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">
|
||||
Pick something strong you haven't used before.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<PasswordInput
|
||||
label="New password"
|
||||
placeholder="Create a strong password"
|
||||
required
|
||||
disabled={verifying}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
<PasswordChecklist value={password} />
|
||||
</div>
|
||||
|
||||
<PasswordInput
|
||||
label="Confirm new password"
|
||||
placeholder="Re-enter your password"
|
||||
required
|
||||
disabled={verifying}
|
||||
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={verifying}
|
||||
disabled={
|
||||
verifying ||
|
||||
!meetsAllRequirements(password) ||
|
||||
password !== confirmPassword
|
||||
}
|
||||
>
|
||||
Reset password
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<ArrowLeft size={14} />}
|
||||
disabled={verifying}
|
||||
onClick={() => {
|
||||
setStage("otp");
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
</AuthShell>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForgotPasswordPage;
|
||||
@@ -14,25 +14,13 @@ import {
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { AlertCircle, ArrowLeft } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import AuthShell from "@/components/auth/AuthShell";
|
||||
import { normaliseIdentifier } from "@/utils/identifier";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */
|
||||
const normaliseIdentifier = (raw: string): string => {
|
||||
const v = raw.trim();
|
||||
const digits = v.replace(/\D/g, "");
|
||||
if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) {
|
||||
const local = digits.startsWith("251")
|
||||
? digits.slice(3)
|
||||
: digits.replace(/^0/, "");
|
||||
return `+251${local}`;
|
||||
}
|
||||
return v.toLowerCase();
|
||||
};
|
||||
|
||||
const EDR_LOGO = "/assets/logo.svg";
|
||||
|
||||
const LoginPage = () => {
|
||||
@@ -111,14 +99,24 @@ const LoginPage = () => {
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
disabled={submitting}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Password</span>
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-xs font-semibold text-primary hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<PasswordInput
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
disabled={submitting}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||
|
||||
Reference in New Issue
Block a user