feat(dashboard, js-sdk): reset password UI (#9451)
**What** - add password reset flow on Admin --- https://github.com/user-attachments/assets/3438ace2-c661-4121-a580-794a69ad4518 --- CLOSES CC-568 Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
@@ -3,7 +3,7 @@ import { FetchError } from "@medusajs/js-sdk"
|
||||
import { sdk } from "../../lib/client"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
export const useSignInWithEmailPassword = (
|
||||
export const useSignInWithEmailPass = (
|
||||
options?: UseMutationOptions<
|
||||
string,
|
||||
FetchError,
|
||||
@@ -35,9 +35,37 @@ export const useSignUpWithEmailPass = (
|
||||
})
|
||||
}
|
||||
|
||||
export const useResetPasswordForEmailPass = (
|
||||
options?: UseMutationOptions<void, FetchError, { email: string }>
|
||||
) => {
|
||||
return useMutation({
|
||||
mutationFn: (payload) =>
|
||||
sdk.auth.resetPassword("user", "emailpass", {
|
||||
identifier: payload.email,
|
||||
}),
|
||||
onSuccess: async (data, variables, context) => {
|
||||
options?.onSuccess?.(data, variables, context)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export const useLogout = (options?: UseMutationOptions<void, FetchError>) => {
|
||||
return useMutation({
|
||||
mutationFn: () => sdk.auth.logout(),
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export const useUpdateProviderForEmailPass = (
|
||||
options?: UseMutationOptions<void, FetchError, { password: string }>
|
||||
) => {
|
||||
return useMutation({
|
||||
mutationFn: (payload) =>
|
||||
sdk.auth.updateProvider("user", "emailpass", payload),
|
||||
onSuccess: async (data, variables, context) => {
|
||||
options?.onSuccess?.(data, variables, context)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2373,15 +2373,23 @@
|
||||
"hint": "Enter your email below, and we will send you instructions on how to reset your password.",
|
||||
"email": "Email",
|
||||
"sendResetInstructions": "Send reset instructions",
|
||||
"backToLogin": "You can always go back - <0>Log in</0>",
|
||||
"backToLogin": "<0>Back to login</0>",
|
||||
"newPasswordHint": "Choose a new password below.",
|
||||
"invalidTokenTitle": "Your reset token is invalid",
|
||||
"invalidTokenHint": "Try requesting a new reset link.",
|
||||
"expiredTokenTitle": "Your reset token has expired",
|
||||
"goToResetPassword": "Go to Reset Password",
|
||||
"resetPassword": "Reset password",
|
||||
"newPassword": "New password",
|
||||
"repeatNewPassword": "Repeat new password",
|
||||
"tokenExpiresIn": "Token expires in <0>{{time}}</0> minutes",
|
||||
"successfulRequest": "We have sent you an email with instructions on how to reset your password. If you don't receive an email, please check your spam folder or try again."
|
||||
"successfulRequestTitle": "Successfully sent you an email",
|
||||
"successfulRequest": "We've sent you an email which you can use to reset your password. Check your spam folder if you haven't received it after a few minutes.",
|
||||
"successfulResetTitle": "Password reset successful",
|
||||
"successfulReset": "Please login in on the login page.",
|
||||
"passwordMismatch": "Passwords do no match",
|
||||
"invalidLinkTitle": "Your reset link is invalid",
|
||||
"invalidLinkHint": "Try resetting your password again."
|
||||
},
|
||||
"workflowExecutions": {
|
||||
"domain": "Workflows",
|
||||
|
||||
@@ -26,6 +26,10 @@ export const RouteMap: RouteObject[] = [
|
||||
path: "/login",
|
||||
lazy: () => import("../../routes/login"),
|
||||
},
|
||||
{
|
||||
path: "/reset-password",
|
||||
lazy: () => import("../../routes/reset-password"),
|
||||
},
|
||||
{
|
||||
path: "*",
|
||||
lazy: () => import("../../routes/no-match"),
|
||||
|
||||
@@ -6,7 +6,8 @@ import { Link, useLocation, useNavigate } from "react-router-dom"
|
||||
import * as z from "zod"
|
||||
|
||||
import { Form } from "../../components/common/form"
|
||||
|
||||
import { LogoBox } from "../../components/common/logo-box"
|
||||
import { useSignInWithEmailPass } from "../../hooks/api/auth"
|
||||
import { useSignInWithEmailPassword } from "../../hooks/api/auth"
|
||||
|
||||
import after from "virtual:medusa/widgets/login/after"
|
||||
@@ -34,7 +35,7 @@ export const Login = () => {
|
||||
},
|
||||
})
|
||||
|
||||
const { mutateAsync, isPending } = useSignInWithEmailPassword()
|
||||
const { mutateAsync, isPending } = useSignInWithEmailPass()
|
||||
|
||||
const handleSubmit = form.handleSubmit(async ({ email, password }) => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ResetPassword as Component } from "./reset-password";
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Alert, Button, Heading, Input, Text, toast } from "@medusajs/ui"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { Trans, useTranslation } from "react-i18next"
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom"
|
||||
import * as z from "zod"
|
||||
|
||||
import { Form } from "../../components/common/form"
|
||||
import { LogoBox } from "../../components/common/logo-box"
|
||||
import {
|
||||
useResetPasswordForEmailPass,
|
||||
useUpdateProviderForEmailPass,
|
||||
} from "../../hooks/api/auth"
|
||||
import { useState } from "react"
|
||||
import { decodeToken } from "react-jwt"
|
||||
import { i18n } from "../../components/utilities/i18n"
|
||||
|
||||
const ResetPasswordInstructionsSchema = z.object({
|
||||
email: z.string().email(),
|
||||
})
|
||||
|
||||
const ResetPasswordSchema = z
|
||||
.object({
|
||||
password: z.string().min(1),
|
||||
repeat_password: z.string().min(1),
|
||||
})
|
||||
.superRefine(({ password, repeat_password }, ctx) => {
|
||||
if (password !== repeat_password) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: i18n.t("resetPassword.passwordMismatch"),
|
||||
path: ["repeat_password"],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const ResetPasswordTokenSchema = z.object({
|
||||
entity_id: z.string(),
|
||||
provider: z.string(),
|
||||
exp: z.number(),
|
||||
iat: z.number(),
|
||||
})
|
||||
|
||||
type DecodedResetPasswordToken = {
|
||||
entity_id: string // -> email in here
|
||||
provider: string
|
||||
exp: string
|
||||
iat: string
|
||||
}
|
||||
|
||||
const validateDecodedResetPasswordToken = (
|
||||
decoded: any
|
||||
): decoded is DecodedResetPasswordToken => {
|
||||
return ResetPasswordTokenSchema.safeParse(decoded).success
|
||||
}
|
||||
|
||||
const InvalidResetToken = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="bg-ui-bg-base flex min-h-dvh w-dvw items-center justify-center">
|
||||
<div className="m-4 flex w-full max-w-[300px] flex-col items-center">
|
||||
<LogoBox className="mb-4" />
|
||||
<div className="mb-6 flex flex-col items-center">
|
||||
<Heading>{t("resetPassword.invalidLinkTitle")}</Heading>
|
||||
<Text size="small" className="text-ui-fg-subtle text-center">
|
||||
{t("resetPassword.invalidLinkHint")}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-y-3">
|
||||
<Button
|
||||
onClick={() => navigate("/reset-password", { replace: true })}
|
||||
className="w-full"
|
||||
type="submit"
|
||||
>
|
||||
{t("resetPassword.goToResetPassword")}
|
||||
</Button>
|
||||
</div>
|
||||
<span className="txt-small my-6">
|
||||
<Trans
|
||||
i18nKey="resetPassword.backToLogin"
|
||||
components={[
|
||||
<Link
|
||||
key="login-link"
|
||||
to="/login"
|
||||
className="text-ui-fg-interactive transition-fg hover:text-ui-fg-interactive-hover focus-visible:text-ui-fg-interactive-hover outline-none"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChooseNewPassword = ({ token }: { token: string }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [showAlert, setShowAlert] = useState(false)
|
||||
|
||||
const invite: DecodedResetPasswordToken | null = token
|
||||
? decodeToken(token)
|
||||
: null
|
||||
|
||||
const isValidResetPasswordToken =
|
||||
invite && validateDecodedResetPasswordToken(invite)
|
||||
|
||||
const form = useForm<z.infer<typeof ResetPasswordSchema>>({
|
||||
resolver: zodResolver(ResetPasswordSchema),
|
||||
defaultValues: {
|
||||
password: "",
|
||||
repeat_password: "",
|
||||
},
|
||||
})
|
||||
|
||||
const { mutateAsync, isPending } = useUpdateProviderForEmailPass()
|
||||
|
||||
const handleSubmit = form.handleSubmit(async ({ password }) => {
|
||||
try {
|
||||
await mutateAsync({
|
||||
email: invite.entity_id,
|
||||
password,
|
||||
})
|
||||
|
||||
form.setValue("password", "")
|
||||
form.setValue("repeat_password", "")
|
||||
|
||||
setShowAlert(true)
|
||||
} catch (error) {
|
||||
toast.error(error.message)
|
||||
}
|
||||
})
|
||||
|
||||
if (!isValidResetPasswordToken) {
|
||||
return <InvalidResetToken />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-ui-bg-base flex min-h-dvh w-dvw items-center justify-center">
|
||||
<div className="m-4 flex w-full max-w-[300px] flex-col items-center">
|
||||
<LogoBox className="mb-4" />
|
||||
<div className="mb-6 flex flex-col items-center">
|
||||
<Heading>{t("resetPassword.resetPassword")}</Heading>
|
||||
<Text size="small" className="text-ui-fg-subtle text-center">
|
||||
{t("resetPassword.newPasswordHint")}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-y-3">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex w-full flex-col gap-y-6"
|
||||
>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<Input type="email" disabled value={invite?.entity_id} />
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Control>
|
||||
<Input
|
||||
autoComplete="new-password"
|
||||
type="password"
|
||||
{...field}
|
||||
placeholder={t("resetPassword.newPassword")}
|
||||
/>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="repeat_password"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Control>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
type="password"
|
||||
{...field}
|
||||
placeholder={t("resetPassword.repeatNewPassword")}
|
||||
/>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{showAlert && (
|
||||
<Alert dismissible variant="success">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-ui-fg-base mb-1">
|
||||
{t("resetPassword.successfulResetTitle")}
|
||||
</span>
|
||||
<span>{t("resetPassword.successfulReset")}</span>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
{!showAlert && (
|
||||
<Button className="w-full" type="submit" isLoading={isPending}>
|
||||
{t("resetPassword.resetPassword")}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
<span className="txt-small my-6">
|
||||
<Trans
|
||||
i18nKey="resetPassword.backToLogin"
|
||||
components={[
|
||||
<Link
|
||||
key="login-link"
|
||||
to="/login"
|
||||
className="text-ui-fg-interactive transition-fg hover:text-ui-fg-interactive-hover focus-visible:text-ui-fg-interactive-hover outline-none"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ResetPassword = () => {
|
||||
const { t } = useTranslation()
|
||||
const [searchParams] = useSearchParams()
|
||||
const [showAlert, setShowAlert] = useState(false)
|
||||
|
||||
const token = searchParams.get("token")
|
||||
|
||||
const form = useForm<z.infer<typeof ResetPasswordInstructionsSchema>>({
|
||||
resolver: zodResolver(ResetPasswordInstructionsSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
},
|
||||
})
|
||||
|
||||
const { mutateAsync, isPending } = useResetPasswordForEmailPass()
|
||||
|
||||
const handleSubmit = form.handleSubmit(async ({ email }) => {
|
||||
try {
|
||||
await mutateAsync({
|
||||
email,
|
||||
})
|
||||
form.setValue("email", "")
|
||||
setShowAlert(true)
|
||||
} catch (error) {
|
||||
toast.error(error.message)
|
||||
}
|
||||
})
|
||||
|
||||
if (token) {
|
||||
return <ChooseNewPassword token={token} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-ui-bg-base flex min-h-dvh w-dvw items-center justify-center">
|
||||
<div className="m-4 flex w-full max-w-[300px] flex-col items-center">
|
||||
<LogoBox className="mb-4" />
|
||||
<div className="mb-4 flex flex-col items-center">
|
||||
<Heading>{t("resetPassword.resetPassword")}</Heading>
|
||||
<Text size="small" className="text-ui-fg-subtle text-center">
|
||||
{t("resetPassword.hint")}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-y-3">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex w-full flex-col gap-y-6"
|
||||
>
|
||||
<div className="mt-4 flex flex-col gap-y-3">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Control>
|
||||
<Input
|
||||
autoComplete="email"
|
||||
{...field}
|
||||
placeholder={t("fields.email")}
|
||||
/>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{showAlert && (
|
||||
<Alert dismissible variant="success">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-ui-fg-base mb-1">
|
||||
{t("resetPassword.successfulRequestTitle")}
|
||||
</span>
|
||||
<span>{t("resetPassword.successfulRequest")}</span>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
<Button className="w-full" type="submit" isLoading={isPending}>
|
||||
{t("resetPassword.sendResetInstructions")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
<span className="txt-small my-6">
|
||||
<Trans
|
||||
i18nKey="resetPassword.backToLogin"
|
||||
components={[
|
||||
<Link
|
||||
key="login-link"
|
||||
to="/login"
|
||||
className="text-ui-fg-interactive transition-fg hover:text-ui-fg-interactive-hover focus-visible:text-ui-fg-interactive-hover outline-none"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -95,6 +95,29 @@ export class Auth {
|
||||
this.client.clearToken()
|
||||
}
|
||||
|
||||
resetPassword = async (
|
||||
actor: string,
|
||||
provider: string,
|
||||
body: { identifier: string }
|
||||
) => {
|
||||
await this.client.fetch(`/auth/${actor}/${provider}/reset-password`, {
|
||||
method: "POST",
|
||||
body,
|
||||
headers: { accept: "text/plain" }, // 201 Created response
|
||||
})
|
||||
}
|
||||
|
||||
updateProvider = async (
|
||||
actor: string,
|
||||
provider: string,
|
||||
body: Record<string, unknown>
|
||||
) => {
|
||||
await this.client.fetch(`/auth/${actor}/${provider}/update`, {
|
||||
method: "POST",
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
private setToken_ = async (token: string) => {
|
||||
// By default we just set the token in the configured storage, if configured to use sessions we convert it into session storage instead.
|
||||
if (this.config?.auth?.type === "session") {
|
||||
|
||||
Reference in New Issue
Block a user