feat(dashboard): Log in, reset password, and accept invite pages (#6310)

This commit is contained in:
Kasper Fabricius Kristensen
2024-02-05 11:16:10 +01:00
committed by GitHub
parent b1276cfcd5
commit 73fd92a1af
23 changed files with 1342 additions and 128 deletions

View File

@@ -177,7 +177,7 @@ const ErrorMessage = forwardRef<
const { error, formErrorMessageId } = useFormField()
const msg = error ? String(error?.message) : children
if (!msg) {
if (!msg || msg === "undefined") {
return null
}

View File

@@ -0,0 +1 @@
export * from "./logo-box"

View File

@@ -0,0 +1,74 @@
import { clx } from "@medusajs/ui"
import { Transition, motion } from "framer-motion"
type LogoBoxProps = {
className?: string
checked?: boolean
containerTransition?: Transition
pathTransition?: Transition
}
export const LogoBox = ({
className,
checked,
containerTransition = {
duration: 0.8,
delay: 0.5,
ease: [0, 0.71, 0.2, 1.01],
},
pathTransition = {
duration: 0.8,
delay: 0.6,
ease: [0.1, 0.8, 0.2, 1.01],
},
}: LogoBoxProps) => {
return (
<div
className={clx(
"size-14 bg-ui-button-neutral shadow-buttons-neutral relative flex items-center justify-center rounded-xl",
"after:button-neutral-gradient after:inset-0 after:content-['']",
className
)}
>
{checked && (
<motion.div
className="size-5 absolute -right-[5px] -top-1 flex items-center justify-center rounded-full border-[0.5px] border-[rgba(3,7,18,0.2)] bg-[#3B82F6] bg-gradient-to-b from-white/0 to-white/20 shadow-[0px_1px_2px_0px_rgba(3,7,18,0.12),0px_1px_2px_0px_rgba(255,255,255,0.10)_inset,0px_-1px_5px_0px_rgba(255,255,255,0.10)_inset,0px_0px_0px_0px_rgba(3,7,18,0.06)_inset]"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
transition={containerTransition}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
>
<motion.path
d="M5.8335 10.4167L9.16683 13.75L14.1668 6.25"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={pathTransition}
/>
</svg>
</motion.div>
)}
<svg
width="36"
height="38"
viewBox="0 0 36 38"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M30.85 6.16832L22.2453 1.21782C19.4299 -0.405941 15.9801 -0.405941 13.1648 1.21782L4.52043 6.16832C1.74473 7.79208 0 10.802 0 14.0099V23.9505C0 27.198 1.74473 30.1683 4.52043 31.7921L13.1251 36.7822C15.9405 38.4059 19.3903 38.4059 22.2056 36.7822L30.8103 31.7921C33.6257 30.1683 35.3307 27.198 35.3307 23.9505V14.0099C35.41 10.802 33.6653 7.79208 30.85 6.16832ZM17.6852 27.8317C12.8079 27.8317 8.8426 23.8713 8.8426 19C8.8426 14.1287 12.8079 10.1683 17.6852 10.1683C22.5625 10.1683 26.5674 14.1287 26.5674 19C26.5674 23.8713 22.6022 27.8317 17.6852 27.8317Z"
className="fill-ui-button-inverted relative drop-shadow-sm"
/>
</svg>
</div>
)
}

View File

@@ -24,6 +24,7 @@ import {
import { Skeleton } from "../../common/skeleton"
import { queryClient } from "../../../lib/medusa"
import { useSearch } from "../../../providers/search-provider"
import { useSidebar } from "../../../providers/sidebar-provider"
import { useTheme } from "../../../providers/theme-provider"
@@ -35,7 +36,7 @@ export const Shell = ({ children }: PropsWithChildren) => {
<MobileSidebarContainer>{children}</MobileSidebarContainer>
<DesktopSidebarContainer>{children}</DesktopSidebarContainer>
</div>
<div className="flex flex-col h-screen w-full overflow-auto">
<div className="flex h-screen w-full flex-col overflow-auto">
<Topbar />
<main className="flex h-full w-full flex-col items-center overflow-y-auto">
<Gutter>
@@ -73,7 +74,7 @@ const Breadcrumbs = () => {
})
return (
<ol className={clx("text-ui-fg-muted flex items-center select-none")}>
<ol className={clx("text-ui-fg-muted flex select-none items-center")}>
{crumbs.map((crumb, index) => {
const isLast = index === crumbs.length - 1
const isSingle = crumbs.length === 1
@@ -106,7 +107,7 @@ const Breadcrumbs = () => {
</div>
)}
{/* {!isLast && <TriangleRightMini className="-mt-0.5 mx-2" />} */}
{!isLast && <span className="-mt-0.5 mx-2"></span>}
{!isLast && <span className="mx-2 -mt-0.5"></span>}
</li>
)
})}
@@ -115,18 +116,22 @@ const Breadcrumbs = () => {
}
const UserBadge = () => {
const { user, isError, error } = useAdminGetSession()
const { user, isLoading, isError, error } = useAdminGetSession()
const displayName = user
? user.first_name && user.last_name
? `${user.first_name} ${user.last_name}`
: user.first_name
? user.first_name
: user.email
: null
const name = [user?.first_name, user?.last_name].filter(Boolean).join(" ")
const displayName = name || user?.email
const fallback = displayName ? displayName[0].toUpperCase() : null
if (isLoading) {
return (
<button className="shadow-borders-base flex max-w-[192px] select-none items-center gap-x-2 overflow-hidden text-ellipsis whitespace-nowrap rounded-full py-1 pl-1 pr-2.5">
<Skeleton className="h-5 w-5 rounded-full" />
<Skeleton className="h-[9px] w-[70px]" />
</button>
)
}
if (isError) {
throw error
}
@@ -136,13 +141,13 @@ const UserBadge = () => {
<button
disabled={!user}
className={clx(
"shadow-borders-base flex max-w-[192px] items-center gap-x-2 overflow-hidden text-ellipsis whitespace-nowrap rounded-full py-1 pl-1 pr-2.5 select-none"
"shadow-borders-base flex max-w-[192px] select-none items-center gap-x-2 overflow-hidden text-ellipsis whitespace-nowrap rounded-full py-1 pl-1 pr-2.5"
)}
>
{fallback ? (
<Avatar size="xsmall" fallback={fallback} />
) : (
<Skeleton className="w-5 h-5 rounded-full" />
<Skeleton className="h-5 w-5 rounded-full" />
)}
{displayName ? (
<Text
@@ -154,7 +159,7 @@ const UserBadge = () => {
{displayName}
</Text>
) : (
<Skeleton className="w-[70px] h-[9px]" />
<Skeleton className="h-[9px] w-[70px]" />
)}
</button>
</DropdownMenu.Trigger>
@@ -203,6 +208,11 @@ const Logout = () => {
const handleLayout = async () => {
await logoutMutation(undefined, {
onSuccess: () => {
/**
* When the user logs out, we want to clear the query cache
*/
queryClient.clear()
navigate("/login")
},
})
@@ -297,7 +307,7 @@ const Searchbar = () => {
return (
<button
onClick={toggleSearch}
className="shadow-borders-base bg-ui-bg-subtle hover:bg-ui-bg-subtle-hover transition-fg focus-visible:shadow-borders-focus text-ui-fg-muted flex w-full max-w-[280px] items-center gap-x-2 rounded-full py-1.5 pl-2 pr-1.5 outline-none select-none"
className="shadow-borders-base bg-ui-bg-subtle hover:bg-ui-bg-subtle-hover transition-fg focus-visible:shadow-borders-focus text-ui-fg-muted flex w-full max-w-[280px] select-none items-center gap-x-2 rounded-full py-1.5 pl-2 pr-1.5 outline-none"
>
<MagnifyingGlass />
<div className="flex-1 text-left">
@@ -335,7 +345,7 @@ const ToggleSidebar = () => {
const Topbar = () => {
return (
<div className="w-full grid-cols-3 border-b p-3 grid">
<div className="grid w-full grid-cols-3 border-b p-3">
<div className="flex items-center gap-x-1.5">
<ToggleSidebar />
<Breadcrumbs />
@@ -374,8 +384,8 @@ const MobileSidebarContainer = ({ children }: PropsWithChildren) => {
return (
<Dialog.Root open={mobile} onOpenChange={() => toggle("mobile")}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-ui-bg-overlay" />
<Dialog.Content className="h-screen fixed left-0 inset-y-0 w-[220px] border-r bg-ui-bg-subtle">
<Dialog.Overlay className="bg-ui-bg-overlay fixed inset-0" />
<Dialog.Content className="bg-ui-bg-subtle fixed inset-y-0 left-0 h-screen w-[220px] border-r">
{children}
</Dialog.Content>
</Dialog.Portal>

View File

@@ -18,7 +18,6 @@ import {
import { ProtectedRoute } from "../../components/authentication/require-auth"
import { ErrorBoundary } from "../../components/error/error-boundary"
import { MainLayout } from "../../components/layout/main-layout"
import { PublicLayout } from "../../components/layout/public-layout"
import { SettingsLayout } from "../../components/layout/settings-layout"
import routes from "medusa-admin:routes/pages"
@@ -46,14 +45,28 @@ const settingsExtensions: RouteObject[] = settings.pages.map((ext) => {
const router = createBrowserRouter([
{
element: <PublicLayout />,
path: "/login",
lazy: () => import("../../routes/login"),
},
{
path: "/reset-password",
element: <Outlet />,
children: [
{
path: "/login",
lazy: () => import("../../routes/login"),
index: true,
lazy: () =>
import("../../routes/reset-password/reset-password-request"),
},
{
path: ":token",
lazy: () => import("../../routes/reset-password/reset-password-token"),
},
],
},
{
path: "/invite",
lazy: () => import("../../routes/invite"),
},
{
element: <ProtectedRoute />,
errorElement: <ErrorBoundary />,

View File

@@ -0,0 +1 @@
export { Invite as Component } from "./invite"

View File

@@ -0,0 +1,386 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { UserRoles } from "@medusajs/medusa"
import { Alert, Button, Heading, Input, Text, Tooltip } from "@medusajs/ui"
import { AnimatePresence, motion } from "framer-motion"
import { useAdminAcceptInvite } from "medusa-react"
import { Trans, useTranslation } from "react-i18next"
import { Link, useSearchParams } from "react-router-dom"
import * as z from "zod"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { decodeToken } from "react-jwt"
import { Form } from "../../components/common/form"
import { LogoBox } from "../../components/common/logo-box"
import { isAxiosError } from "../../lib/is-axios-error"
const CreateAccountSchema = z
.object({
email: z.string().email(),
first_name: z.string().min(1),
last_name: z.string().min(1),
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: "Passwords do not match",
path: ["repeat_password"],
})
}
})
type DecodedInvite = {
invite_id: string
role: UserRoles
user_email: string
iat: number
}
export const Invite = () => {
const [searchParams] = useSearchParams()
const [success, setSuccess] = useState(false)
const token = searchParams.get("token")
const invite: DecodedInvite | null = token ? decodeToken(token) : null
const isValidInvite = invite && validateDecodedInvite(invite)
return (
<div className="min-h-dvh w-dvw bg-ui-bg-base relative flex items-center justify-center p-4">
<div className="flex w-full max-w-[300px] flex-col items-center">
<LogoBox
className="mb-4"
checked={success}
containerTransition={{
duration: 1.2,
delay: 0.8,
ease: [0, 0.71, 0.2, 1.01],
}}
pathTransition={{
duration: 1.3,
delay: 1.1,
bounce: 0.6,
ease: [0.1, 0.8, 0.2, 1.01],
}}
/>
<div className="max-h-[557px] w-full will-change-contents">
{isValidInvite ? (
<AnimatePresence>
{!success ? (
<motion.div
key="create-account"
initial={false}
animate={{
height: "557px",
y: 0,
}}
exit={{
height: 0,
y: 40,
}}
transition={{
duration: 0.8,
delay: 0.6,
ease: [0, 0.71, 0.2, 1.01],
}}
className="w-full will-change-transform"
>
<motion.div
initial={false}
animate={{
opacity: 1,
scale: 1,
}}
exit={{
opacity: 0,
scale: 0.7,
}}
transition={{
duration: 0.6,
delay: 0,
ease: [0, 0.71, 0.2, 1.01],
}}
key="inner-create-account"
>
<CreateView
onSuccess={() => setSuccess(true)}
token={token!}
invite={invite}
/>
</motion.div>
</motion.div>
) : (
<motion.div
key="success-view"
initial={{
opacity: 0,
scale: 0.4,
}}
animate={{
opacity: 1,
scale: 1,
}}
transition={{
duration: 1,
delay: 0.6,
ease: [0, 0.71, 0.2, 1.01],
}}
className="w-full"
>
<SuccessView />
</motion.div>
)}
</AnimatePresence>
) : (
<InvalidView />
)}
</div>
</div>
</div>
)
}
const LoginLink = () => {
const { t } = useTranslation()
return (
<div className="flex w-full flex-col items-center">
<div className="my-6 h-px w-full border-b border-dotted" />
<span className="text-ui-fg-subtle txt-small">
<Trans
t={t}
i18nKey="invite.alreadyHaveAccount"
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>
)
}
const InvalidView = () => {
const { t } = useTranslation()
return (
<div className="flex flex-col items-center">
<div className="flex flex-col items-center gap-y-1">
<Heading>{t("invite.invalidTokenTitle")}</Heading>
<Text size="small" className="text-ui-fg-subtle text-center">
{t("invite.invalidTokenHint")}
</Text>
</div>
<LoginLink />
</div>
)
}
const CreateView = ({
onSuccess,
token,
invite,
}: {
onSuccess: () => void
token: string
invite: DecodedInvite
}) => {
const { t } = useTranslation()
const [invalid, setInvalid] = useState(false)
const form = useForm<z.infer<typeof CreateAccountSchema>>({
resolver: zodResolver(CreateAccountSchema),
defaultValues: {
email: invite.user_email,
first_name: "",
last_name: "",
password: "",
repeat_password: "",
},
})
const { mutateAsync, isLoading } = useAdminAcceptInvite()
const handleSubmit = form.handleSubmit(async (data) => {
await mutateAsync(
{
user: {
first_name: data.first_name,
last_name: data.last_name,
password: data.password,
},
token: token!,
},
{
onSuccess,
onError: (error) => {
if (isAxiosError(error) && error.response?.status === 400) {
form.setError("root", {
type: "manual",
message: t("invite.invalidInvite"),
})
setInvalid(true)
return
}
form.setError("root", {
type: "manual",
message: t("errors.serverError"),
})
},
}
)
})
return (
<div className="flex w-full flex-col items-center">
<div className="mb-4 flex flex-col items-center">
<Heading>{t("invite.title")}</Heading>
<Text size="small" className="text-ui-fg-subtle text-center">
{t("invite.hint")}
</Text>
</div>
<Form {...form}>
<form onSubmit={handleSubmit} className="flex w-full flex-col gap-y-6">
<div className="flex flex-col gap-y-4">
<Form.Field
control={form.control}
name="email"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.email")}</Form.Label>
<Form.Control>
<Tooltip content={t("invite.emailTooltip")}>
<Input autoComplete="off" {...field} disabled />
</Tooltip>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="first_name"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.firstName")}</Form.Label>
<Form.Control>
<Input autoComplete="given-name" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="last_name"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.lastName")}</Form.Label>
<Form.Control>
<Input autoComplete="family-name" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="password"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.password")}</Form.Label>
<Form.Control>
<Input
autoComplete="new-password"
type="password"
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="repeat_password"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.repeatPassword")}</Form.Label>
<Form.Control>
<Input autoComplete="off" type="password" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
{form.formState.errors.root && (
<Alert
variant="error"
dismissible={false}
className="text-balance"
>
{form.formState.errors.root.message}
</Alert>
)}
</div>
<Button
className="w-full"
type="submit"
isLoading={isLoading}
disabled={invalid}
>
{t("invite.createAccount")}
</Button>
</form>
</Form>
<LoginLink />
</div>
)
}
const SuccessView = () => {
const { t } = useTranslation()
return (
<div className="flex w-full flex-col items-center gap-y-6">
<div className="flex flex-col items-center gap-y-1">
<Heading>{t("invite.successTitle")}</Heading>
<Text size="small" className="text-ui-fg-subtle text-center">
{t("invite.successHint")}
</Text>
</div>
<Button variant="secondary" asChild className="w-full">
<Link to="/orders" replace>
{t("invite.successAction")}
</Link>
</Button>
</div>
)
}
const InviteSchema = z.object({
invite_id: z.string(),
role: z.string(),
user_email: z.string().email(),
iat: z.number(),
})
const validateDecodedInvite = (decoded: any): decoded is DecodedInvite => {
return InviteSchema.safeParse(decoded).success
}

View File

@@ -1,11 +1,13 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Button, Heading, Input, Text } from "@medusajs/ui"
import { useAdminGetSession, useAdminLogin } from "medusa-react"
import { useAdminLogin } from "medusa-react"
import { useForm } from "react-hook-form"
import { Link, Navigate, useLocation, useNavigate } from "react-router-dom"
import { Trans, useTranslation } from "react-i18next"
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 { isAxiosError } from "../../lib/is-axios-error"
const LoginSchema = z.object({
@@ -14,10 +16,11 @@ const LoginSchema = z.object({
})
export const Login = () => {
const navigate = useNavigate()
const { t } = useTranslation()
const location = useLocation()
const navigate = useNavigate()
const from = location.state?.from?.pathname || "/"
const from = location.state?.from?.pathname || "/orders"
const form = useForm<z.infer<typeof LoginSchema>>({
resolver: zodResolver(LoginSchema),
@@ -27,94 +30,112 @@ export const Login = () => {
},
})
const { user, isLoading } = useAdminGetSession()
const { mutateAsync } = useAdminLogin({
const { mutateAsync, isLoading } = useAdminLogin({
retry: false,
})
const onSubmit = form.handleSubmit(async ({ email, password }) => {
const handleSubmit = form.handleSubmit(async ({ email, password }) => {
await mutateAsync(
{
email,
password,
},
{ email, password },
{
onSuccess: () => {
navigate(from, { replace: true })
},
onError: (e) => {
if (isAxiosError(e)) {
if (e.response?.status === 401) {
onError: (error) => {
if (isAxiosError(error)) {
if (error.response?.status === 401) {
form.setError("email", {
type: "manual",
})
form.setError("password", {
type: "manual",
message: "Invalid email or password",
message: t("errors.invalidCredentials"),
})
return
}
}
form.setError("password", {
form.setError("root.serverError", {
type: "manual",
message: "Something went wrong",
message: t("errors.serverError"),
})
},
}
)
})
if (user && !isLoading) {
return <Navigate to={from} replace />
}
return (
<div className="flex flex-col gap-y-12">
<div className="flex flex-col items-center">
<Heading>Login</Heading>
<Text>Welcome back. Login to get started!</Text>
<div className="min-h-dvh w-dvw bg-ui-bg-base flex 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("login.title")}</Heading>
<Text size="small" className="text-ui-fg-subtle text-center">
{t("login.hint")}
</Text>
</div>
<Form {...form}>
<form
onSubmit={handleSubmit}
className="flex w-full flex-col gap-y-6"
>
<div className="flex flex-col gap-y-4">
<Form.Field
control={form.control}
name="email"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.email")}</Form.Label>
<Form.Control>
<Input autoComplete="email" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="password"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.password")}</Form.Label>
<Form.Control>
<Input
type="password"
autoComplete="current-password"
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<Button className="w-full" type="submit" isLoading={isLoading}>
{t("general.continue")}
</Button>
</form>
</Form>
<div className="my-6 h-px w-full border-b border-dotted" />
<span className="text-ui-fg-subtle txt-small">
<Trans
i18nKey="login.forgotPassword"
components={[
<Link
key="reset-password-link"
to="/reset-password"
className="text-ui-fg-interactive transition-fg hover:text-ui-fg-interactive-hover focus-visible:text-ui-fg-interactive-hover outline-none"
/>,
]}
/>
</span>
</div>
<Form {...form}>
<form onSubmit={onSubmit} className="flex flex-col gap-y-12">
<Form.Field
control={form.control}
name="email"
render={({ field }) => (
<Form.Item>
<Form.Label>Email</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
<Form.Field
control={form.control}
name="password"
render={({ field }) => (
<Form.Item>
<div className="flex items-center justify-between">
<Form.Label>Password</Form.Label>
<Link
to={"/forgot-password"}
className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover focus-visible:text-ui-fg-interactive-hover transition-fg outline-none"
>
<Text leading="compact">Forgot password?</Text>
</Link>
</div>
<Form.Control>
<Input type="password" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)}
/>
<Button size="large" variant="secondary" className="w-full">
Submit
</Button>
</form>
</Form>
</div>
)
}

View File

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

View File

@@ -0,0 +1,143 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Alert, Button, Heading, Input, Text } from "@medusajs/ui"
import { motion } from "framer-motion"
import { useAdminSendResetPasswordToken } from "medusa-react"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { Trans, useTranslation } from "react-i18next"
import { Link, useSearchParams } from "react-router-dom"
import * as z from "zod"
import { Form } from "../../../components/common/form"
import { LogoBox } from "../../../components/common/logo-box"
const ResetPasswordSchema = z.object({
email: z.string().email(),
})
export const ResetPassword = () => {
const { t } = useTranslation()
const [success, setSuccess] = useState(false)
const [searchParams] = useSearchParams()
const defaultValue = searchParams.get("email") || ""
const form = useForm<z.infer<typeof ResetPasswordSchema>>({
resolver: zodResolver(ResetPasswordSchema),
defaultValues: {
email: defaultValue,
},
})
const { mutateAsync } = useAdminSendResetPasswordToken({
retry: false,
})
const handleSubmit = form.handleSubmit(async ({ email }) => {
await mutateAsync(
{ email },
{
onSuccess: () => {
setSuccess(true)
},
onError: (_error) => {
/**
* This endpoint is not supposed to return any errors.
* If it does, we can assume it's a server error.
*/
setSuccess(false)
form.setError("root", {
type: "manual",
message: t("errors.serverError"),
})
},
}
)
})
return (
<div className="min-h-dvh w-dvw bg-ui-bg-base flex items-center justify-center">
<div className="m-4 flex w-full max-w-[300px] flex-col items-center">
<LogoBox className="mb-4" checked={success} />
<div className="mb-4 flex flex-col items-center">
<Heading>{t("resetPassword.title")}</Heading>
<Text
size="small"
className="text-ui-fg-subtle text-pretty text-center"
>
{t("resetPassword.hint")}
</Text>
</div>
<Form {...form}>
<form
onSubmit={handleSubmit}
className="flex w-full flex-col gap-y-6"
>
<div className="flex flex-col">
<Form.Field
control={form.control}
name="email"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.email")}</Form.Label>
<Form.Control>
<Input autoComplete="email" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
{success && (
<motion.div
initial={{
opacity: 0,
height: 0,
y: 20,
}}
animate={{
opacity: 1,
height: "auto",
y: 0,
}}
transition={{
duration: 0.5,
delay: 0.2,
ease: [0, 0.71, 0.2, 1.01],
}}
layout
>
<Alert variant="success" dismissible className="mt-4">
{t("resetPassword.successfulRequest")}
</Alert>
</motion.div>
)}
{form.formState.errors.root && (
<Alert variant="error" dismissible className="mt-4">
{form.formState.errors.root.message}
</Alert>
)}
</div>
<Button className="w-full" type="submit">
{t("resetPassword.sendResetInstructions")}
</Button>
</form>
</Form>
<div className="my-6 h-px w-full border-b border-dotted" />
<span className="text-ui-fg-subtle txt-small">
<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

@@ -0,0 +1 @@
export { ResetPasswordToken as Component } from "./reset-password-token"

View File

@@ -0,0 +1,258 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Button, Heading, Input, Text } from "@medusajs/ui"
import { useAdminResetPassword } from "medusa-react"
import { useForm } from "react-hook-form"
import { Trans, useTranslation } from "react-i18next"
import { decodeToken } from "react-jwt"
import { Link, Navigate, useNavigate, useParams } from "react-router-dom"
import * as z from "zod"
import { Clock } from "@medusajs/icons"
import { useEffect, useState } from "react"
import { Form } from "../../../components/common/form"
import { LogoBox } from "../../../components/common/logo-box"
const ResetPasswordTokenSchema = z
.object({
email: z.string().email(),
new_password: z.string(),
repeat_new_password: z.string(),
})
.superRefine(({ new_password, repeat_new_password }, ctx) => {
if (new_password !== repeat_new_password) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Passwords do not match",
path: ["repeat_new_password"],
})
}
})
export const ResetPasswordToken = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const { token } = useParams()
let tokenStatus: "valid" | "invalid" | "expired" = "valid"
const decodedToken: { email: string; exp: number } | null = token
? decodeToken(token)
: null
if (!decodedToken) {
tokenStatus = "invalid"
}
const expiresAt = decodedToken ? new Date(decodedToken.exp * 1000) : null
if (expiresAt && expiresAt < new Date()) {
tokenStatus = "expired"
}
const redirectLink = `/reset-password${
decodedToken ? `?email=${decodedToken.email}` : ""
}`
const form = useForm<z.infer<typeof ResetPasswordTokenSchema>>({
resolver: zodResolver(ResetPasswordTokenSchema),
defaultValues: {
email: decodedToken?.email || "",
new_password: "",
repeat_new_password: "",
},
})
const { mutateAsync, isLoading } = useAdminResetPassword({
retry: false,
})
const handleSubmit = form.handleSubmit(async ({ new_password }) => {
await mutateAsync(
{ password: new_password, token: token! },
{
onSuccess: () => {
navigate("/orders")
},
onError: (_error) => {
form.setError("root", {
type: "manual",
message: t("errors.serverError"),
})
},
}
)
})
const [title, hint] = {
valid: [t("resetPassword.title"), t("resetPassword.newPasswordHint")],
invalid: [
t("resetPassword.invalidTokenTitle"),
t("resetPassword.invalidTokenHint"),
],
expired: [
t("resetPassword.expiredTokenTitle"),
t("resetPassword.invalidTokenHint"),
],
}[tokenStatus]
return (
<div className="min-h-dvh w-dvw bg-ui-bg-base flex 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>{title}</Heading>
<Text
size="small"
className="text-ui-fg-subtle text-pretty text-center"
>
{hint}
</Text>
</div>
{tokenStatus === "valid" ? (
<Form {...form}>
<form
onSubmit={handleSubmit}
className="flex w-full flex-col gap-y-6"
>
<div className="flex flex-col gap-y-4">
<Form.Field
control={form.control}
name="email"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.email")}</Form.Label>
<Form.Control>
<Input autoComplete="off" {...field} disabled />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="new_password"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.newPassword")}</Form.Label>
<Form.Control>
<Input
autoComplete="new-password"
type="password"
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="repeat_new_password"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.repeatNewPassword")}</Form.Label>
<Form.Control>
<Input
autoComplete="off"
type="password"
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
{form.formState.errors.root && (
<span>{form.formState.errors.root.message}</span>
)}
</div>
<Button className="w-full" type="submit" isLoading={isLoading}>
{t("resetPassword.resetPassword")}
</Button>
{expiresAt && <Countdown expiresAt={expiresAt} />}
</form>
</Form>
) : (
<Button variant="secondary" asChild className="w-full">
<Link to={redirectLink} replace>
{t("resetPassword.goToResetPassword")}
</Link>
</Button>
)}
<div className="my-6 h-px w-full border-b border-dotted" />
<span className="text-ui-fg-subtle txt-small">
<Trans
t={t}
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 Countdown = ({ expiresAt }: { expiresAt: Date }) => {
const { t } = useTranslation()
const [timeLeft, setTimeLeft] = useState<number>(
Math.floor((expiresAt.getTime() - Date.now()) / 1000)
)
// Total time is 15 minutes
const totalTime = 15 * 60
useEffect(() => {
const interval = setInterval(() => {
setTimeLeft(Math.floor((expiresAt.getTime() - Date.now()) / 1000))
}, 1000)
return () => clearInterval(interval)
}, [expiresAt])
const minutes = Math.floor(timeLeft / 60)
const seconds = timeLeft % 60
const timespan = `${minutes}:${seconds < 10 ? `0${seconds}` : seconds}`
const percentageLeft = ((timeLeft / totalTime) * 100).toFixed(2)
const isExpired = timeLeft <= 0
if (isExpired) {
return <Navigate to="." replace />
}
return (
<div className="bg-ui-bg-subtle border-ui-border-base txt-compact-small relative w-full rounded-lg border p-2">
<div className="absolute inset-0 flex overflow-hidden rounded-lg">
<div
className="bg-ui-bg-subtle-hover h-full transition-[width]"
style={{
width: `${percentageLeft}%`,
}}
/>
</div>
<div className="text-ui-fg-subtle relative z-10 flex items-center gap-x-2">
<Clock className="text-ui-tag-neutral-icon" />
<span>
<Trans
t={t}
i18nKey={"resetPassword.tokenExpiresIn"}
values={{ time: timespan }}
components={[<span key="countdown" className="tabular-nums" />]}
/>
</span>
</div>
</div>
)
}