feat(dashboard): Log in, reset password, and accept invite pages (#6310)
This commit is contained in:
committed by
GitHub
parent
b1276cfcd5
commit
73fd92a1af
@@ -28,6 +28,7 @@
|
||||
"@uiw/react-json-view": "2.0.0-alpha.10",
|
||||
"cmdk": "^0.2.0",
|
||||
"date-fns": "^3.2.0",
|
||||
"framer-motion": "^11.0.3",
|
||||
"i18next": "23.7.11",
|
||||
"i18next-browser-languagedetector": "7.2.0",
|
||||
"i18next-http-backend": "2.4.2",
|
||||
@@ -36,6 +37,7 @@
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "7.49.1",
|
||||
"react-i18next": "13.5.0",
|
||||
"react-jwt": "^1.2.0",
|
||||
"react-router-dom": "6.20.1",
|
||||
"zod": "3.22.4"
|
||||
},
|
||||
@@ -48,8 +50,8 @@
|
||||
"@types/react": "18.2.43",
|
||||
"@types/react-dom": "18.2.17",
|
||||
"@vitejs/plugin-react": "4.2.1",
|
||||
"autoprefixer": "10.4.16",
|
||||
"postcss": "8.4.32",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"postcss": "^8.4.33",
|
||||
"prettier": "^3.1.1",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "5.2.2",
|
||||
|
||||
@@ -239,6 +239,43 @@
|
||||
"createdBy": "Created by",
|
||||
"revokedBy": "Revoked by"
|
||||
},
|
||||
"login": {
|
||||
"forgotPassword": "Forgot password? - <0>Reset</0>",
|
||||
"title": "Log in",
|
||||
"hint": "to continue to Medusa"
|
||||
},
|
||||
"invite": {
|
||||
"title": "Create your account",
|
||||
"hint": "to continue to Medusa",
|
||||
"createAccount": "Create account",
|
||||
"alreadyHaveAccount": "Already have an account? - <0>Log in</0>",
|
||||
"emailTooltip": "Your email cannot be changed. If you would like to use another email, a new invite must be sent.",
|
||||
"invalidInvite": "The invite is invalid or has expired.",
|
||||
"successTitle": "Your account has been created",
|
||||
"successHint": "Get started with Medusa Admin right away.",
|
||||
"successAction": "Start using Medusa",
|
||||
"invalidTokenTitle": "Your invite token is invalid",
|
||||
"invalidTokenHint": "Try requesting a new invite link."
|
||||
},
|
||||
"resetPassword": {
|
||||
"title": "Reset password",
|
||||
"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>",
|
||||
"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",
|
||||
"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."
|
||||
},
|
||||
"errors": {
|
||||
"serverError": "Server error - Try again later.",
|
||||
"invalidCredentials": "Wrong email or password"
|
||||
},
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"lastName": "Last Name",
|
||||
@@ -247,7 +284,10 @@
|
||||
"description": "Description",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"repeatPassword": "Repeat Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"newPassword": "New Password",
|
||||
"repeatNewPassword": "Repeat New Password",
|
||||
"categories": "Categories",
|
||||
"category": "Category",
|
||||
"collection": "Collection",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./logo-box"
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />,
|
||||
|
||||
1
packages/admin-next/dashboard/src/routes/invite/index.ts
Normal file
1
packages/admin-next/dashboard/src/routes/invite/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Invite as Component } from "./invite"
|
||||
386
packages/admin-next/dashboard/src/routes/invite/invite.tsx
Normal file
386
packages/admin-next/dashboard/src/routes/invite/invite.tsx
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { ResetPassword as Component } from "../reset-password-request/reset-password"
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ResetPasswordToken as Component } from "./reset-password-token"
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -60,12 +60,12 @@
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.0.1",
|
||||
"@vitest/coverage-v8": "^0.32.2",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"chromatic": "^6.20.0",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-plugin-storybook": "^0.6.12",
|
||||
"jsdom": "^22.1.0",
|
||||
"postcss": "^8.4.24",
|
||||
"postcss": "^8.4.33",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
@@ -107,7 +107,7 @@
|
||||
"prism-react-renderer": "^2.0.6",
|
||||
"react-currency-input-field": "^3.6.11",
|
||||
"react-day-picker": "^8.8.0",
|
||||
"tailwind-merge": "^1.13.2"
|
||||
"tailwind-merge": "^2.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react"
|
||||
import * as React from "react"
|
||||
|
||||
import { Alert } from "./alert"
|
||||
|
||||
const meta: Meta<typeof Alert> = {
|
||||
title: "Components/Alert",
|
||||
component: Alert,
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: {
|
||||
type: "select",
|
||||
options: ["info", "error", "success", "warning"],
|
||||
},
|
||||
},
|
||||
dismissible: {
|
||||
control: {
|
||||
type: "boolean",
|
||||
},
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof Alert>
|
||||
|
||||
const Text =
|
||||
"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."
|
||||
|
||||
export const Info: Story = {
|
||||
args: {
|
||||
variant: "info",
|
||||
children: Text,
|
||||
},
|
||||
render: (args) => (
|
||||
<div className="max-w-[300px]">
|
||||
<Alert {...args} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Error: Story = {
|
||||
args: {
|
||||
variant: "error",
|
||||
children: Text,
|
||||
},
|
||||
render: (args) => (
|
||||
<div className="max-w-[300px]">
|
||||
<Alert {...args} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Success: Story = {
|
||||
args: {
|
||||
variant: "success",
|
||||
children: Text,
|
||||
},
|
||||
render: (args) => (
|
||||
<div className="max-w-[300px]">
|
||||
<Alert {...args} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
variant: "warning",
|
||||
children: Text,
|
||||
},
|
||||
render: (args) => (
|
||||
<div className="max-w-[300px]">
|
||||
<Alert {...args} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Dismissible: Story = {
|
||||
args: {
|
||||
dismissible: true,
|
||||
children: Text,
|
||||
},
|
||||
render: (args) => (
|
||||
<div className="max-w-[300px]">
|
||||
<Alert {...args} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
92
packages/design-system/ui/src/components/alert/alert.tsx
Normal file
92
packages/design-system/ui/src/components/alert/alert.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { clx } from "@/utils/clx"
|
||||
import {
|
||||
CheckCircleSolid,
|
||||
ExclamationCircleSolid,
|
||||
InformationCircleSolid,
|
||||
XCircleSolid,
|
||||
XMarkMini,
|
||||
} from "@medusajs/icons"
|
||||
import * as React from "react"
|
||||
|
||||
import { IconButton } from "@/components/icon-button"
|
||||
|
||||
interface AlertProps extends React.ComponentPropsWithoutRef<"div"> {
|
||||
variant?: "error" | "success" | "warning" | "info"
|
||||
dismissible?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* This component is based on the div element and supports all of its props
|
||||
*
|
||||
* @excludeExternal
|
||||
*/
|
||||
export const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
|
||||
(
|
||||
{
|
||||
/**
|
||||
* The variant of the alert
|
||||
*/
|
||||
variant = "info",
|
||||
/**
|
||||
* Whether the alert is dismissible
|
||||
*/
|
||||
dismissible = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [dismissed, setDismissed] = React.useState(false)
|
||||
|
||||
const Icon = {
|
||||
info: InformationCircleSolid,
|
||||
error: XCircleSolid,
|
||||
success: CheckCircleSolid,
|
||||
warning: ExclamationCircleSolid,
|
||||
}[variant]
|
||||
|
||||
const handleDismiss = () => {
|
||||
setDismissed(true)
|
||||
}
|
||||
|
||||
if (dismissed) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clx(
|
||||
"bg-ui-bg-subtle text-pretty txt-compact-small grid items-start gap-x-3 rounded-lg border p-3",
|
||||
{
|
||||
"grid-cols-[20px_1fr]": !dismissible,
|
||||
"grid-cols-[20px_1fr_20px]": dismissible,
|
||||
},
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Icon
|
||||
className={clx({
|
||||
"text-ui-tag-red-icon": variant === "error",
|
||||
"text-ui-tag-green-icon": variant === "success",
|
||||
"text-ui-tag-orange-icon": variant === "warning",
|
||||
"text-ui-tag-neutral-icon": variant === "info",
|
||||
})}
|
||||
/>
|
||||
<div>{children}</div>
|
||||
{dismissible && (
|
||||
<IconButton
|
||||
size="2xsmall"
|
||||
variant="transparent"
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
<XMarkMini className="text-ui-fg-muted" />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
1
packages/design-system/ui/src/components/alert/index.ts
Normal file
1
packages/design-system/ui/src/components/alert/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./alert"
|
||||
@@ -5,7 +5,7 @@ import * as React from "react"
|
||||
import { clx } from "../../utils/clx"
|
||||
|
||||
const hintVariants = cva({
|
||||
base: "txt-small inline-flex items-center gap-x-2",
|
||||
base: "txt-small inline-flex items-start gap-x-2",
|
||||
variants: {
|
||||
variant: {
|
||||
info: "text-ui-fg-subtle",
|
||||
|
||||
@@ -28,6 +28,8 @@ const iconButtonVariants = cva({
|
||||
),
|
||||
},
|
||||
size: {
|
||||
"2xsmall": "h-5 w-5",
|
||||
xsmall: "h-6 w-6 p-1",
|
||||
small: "h-7 w-7 p-1",
|
||||
base: "h-8 w-8 p-1.5",
|
||||
large: "h-10 w-10 p-2.5",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { Alert } from "./components/alert"
|
||||
export { Avatar } from "./components/avatar"
|
||||
export { Badge } from "./components/badge"
|
||||
export { Button } from "./components/button"
|
||||
@@ -21,6 +22,7 @@ export { IconButton } from "./components/icon-button"
|
||||
export { Input } from "./components/input"
|
||||
export { Kbd } from "./components/kbd"
|
||||
export { Label } from "./components/label"
|
||||
export { Popover } from "./components/popover"
|
||||
export { ProgressAccordion } from "./components/progress-accordion"
|
||||
export { ProgressTabs } from "./components/progress-tabs"
|
||||
export { Prompt } from "./components/prompt"
|
||||
@@ -35,7 +37,6 @@ export { Textarea } from "./components/textarea"
|
||||
export { Toast } from "./components/toast"
|
||||
export { Toaster } from "./components/toaster"
|
||||
export { Tooltip } from "./components/tooltip"
|
||||
export { Popover } from "./components/popover"
|
||||
|
||||
// Hooks
|
||||
export { usePrompt } from "./hooks/use-prompt"
|
||||
|
||||
Reference in New Issue
Block a user