From 0a2ecdc8899645f7cd8dc5d4a6b11080c6855fa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frane=20Poli=C4=87?= <16856471+fPolic@users.noreply.github.com> Date: Fri, 4 Oct 2024 11:10:26 +0200 Subject: [PATCH] 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> --- .../admin/dashboard/src/hooks/api/auth.tsx | 30 +- .../dashboard/src/i18n/translations/en.json | 12 +- .../providers/router-provider/route-map.tsx | 4 + .../dashboard/src/routes/login/login.tsx | 5 +- .../src/routes/reset-password/index.ts | 2 + .../routes/reset-password/reset-password.tsx | 330 ++++++++++++++++++ packages/core/js-sdk/src/auth/index.ts | 23 ++ 7 files changed, 401 insertions(+), 5 deletions(-) create mode 100644 packages/admin/dashboard/src/routes/reset-password/index.ts create mode 100644 packages/admin/dashboard/src/routes/reset-password/reset-password.tsx diff --git a/packages/admin/dashboard/src/hooks/api/auth.tsx b/packages/admin/dashboard/src/hooks/api/auth.tsx index 7bbcba2d1c..5803df26ac 100644 --- a/packages/admin/dashboard/src/hooks/api/auth.tsx +++ b/packages/admin/dashboard/src/hooks/api/auth.tsx @@ -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 +) => { + 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) => { return useMutation({ mutationFn: () => sdk.auth.logout(), ...options, }) } + +export const useUpdateProviderForEmailPass = ( + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: (payload) => + sdk.auth.updateProvider("user", "emailpass", payload), + onSuccess: async (data, variables, context) => { + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} diff --git a/packages/admin/dashboard/src/i18n/translations/en.json b/packages/admin/dashboard/src/i18n/translations/en.json index 4bc1f75cc5..cf790c05e6 100644 --- a/packages/admin/dashboard/src/i18n/translations/en.json +++ b/packages/admin/dashboard/src/i18n/translations/en.json @@ -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", + "backToLogin": "<0>Back to login", "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}} 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", diff --git a/packages/admin/dashboard/src/providers/router-provider/route-map.tsx b/packages/admin/dashboard/src/providers/router-provider/route-map.tsx index c4a85d2fbb..329dc57f08 100644 --- a/packages/admin/dashboard/src/providers/router-provider/route-map.tsx +++ b/packages/admin/dashboard/src/providers/router-provider/route-map.tsx @@ -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"), diff --git a/packages/admin/dashboard/src/routes/login/login.tsx b/packages/admin/dashboard/src/routes/login/login.tsx index 6ecab3fb5e..b1edd690ec 100644 --- a/packages/admin/dashboard/src/routes/login/login.tsx +++ b/packages/admin/dashboard/src/routes/login/login.tsx @@ -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 { diff --git a/packages/admin/dashboard/src/routes/reset-password/index.ts b/packages/admin/dashboard/src/routes/reset-password/index.ts new file mode 100644 index 0000000000..596188a311 --- /dev/null +++ b/packages/admin/dashboard/src/routes/reset-password/index.ts @@ -0,0 +1,2 @@ +export { ResetPassword as Component } from "./reset-password"; + diff --git a/packages/admin/dashboard/src/routes/reset-password/reset-password.tsx b/packages/admin/dashboard/src/routes/reset-password/reset-password.tsx new file mode 100644 index 0000000000..2f82fa1b3e --- /dev/null +++ b/packages/admin/dashboard/src/routes/reset-password/reset-password.tsx @@ -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 ( +
+
+ +
+ {t("resetPassword.invalidLinkTitle")} + + {t("resetPassword.invalidLinkHint")} + +
+
+ +
+ + , + ]} + /> + +
+
+ ) +} + +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>({ + 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 + } + + return ( +
+
+ +
+ {t("resetPassword.resetPassword")} + + {t("resetPassword.newPasswordHint")} + +
+
+
+ +
+ + { + return ( + + + + + + + ) + }} + /> + { + return ( + + + + + + + ) + }} + /> +
+ {showAlert && ( + +
+ + {t("resetPassword.successfulResetTitle")} + + {t("resetPassword.successfulReset")} +
+
+ )} + {!showAlert && ( + + )} +
+ +
+ + , + ]} + /> + +
+
+ ) +} + +export const ResetPassword = () => { + const { t } = useTranslation() + const [searchParams] = useSearchParams() + const [showAlert, setShowAlert] = useState(false) + + const token = searchParams.get("token") + + const form = useForm>({ + 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 + } + + return ( +
+
+ +
+ {t("resetPassword.resetPassword")} + + {t("resetPassword.hint")} + +
+
+
+ +
+ { + return ( + + + + + + + ) + }} + /> +
+ {showAlert && ( + +
+ + {t("resetPassword.successfulRequestTitle")} + + {t("resetPassword.successfulRequest")} +
+
+ )} + +
+ +
+ + , + ]} + /> + +
+
+ ) +} diff --git a/packages/core/js-sdk/src/auth/index.ts b/packages/core/js-sdk/src/auth/index.ts index f6d18435e8..a57f0290df 100644 --- a/packages/core/js-sdk/src/auth/index.ts +++ b/packages/core/js-sdk/src/auth/index.ts @@ -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 + ) => { + 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") {