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:
Frane Polić
2024-10-04 11:10:26 +02:00
committed by GitHub
parent b2a8c897f7
commit 0a2ecdc889
7 changed files with 401 additions and 5 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
export { ResetPassword as Component } from "./reset-password";

View File

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

View File

@@ -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") {