feat(admin-next): Email password invite flow in admin 2.0 (#6821)
This commit is contained in:
@@ -738,9 +738,10 @@
|
||||
"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",
|
||||
"successAction": "Sign in to start using Medusa",
|
||||
"invalidTokenTitle": "Your invite token is invalid",
|
||||
"invalidTokenHint": "Try requesting a new invite link."
|
||||
"invalidTokenHint": "Try requesting a new invite link.",
|
||||
"passwordMismatch": "Passwords do not match"
|
||||
},
|
||||
"resetPassword": {
|
||||
"title": "Reset password",
|
||||
@@ -952,4 +953,4 @@
|
||||
"seconds_one": "Second",
|
||||
"seconds_other": "Seconds"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { adminAuthKeys, useAdminCustomQuery } from "medusa-react"
|
||||
import { medusa } from "../medusa"
|
||||
import { AcceptInviteInput, CreateAuthUserInput } from "./types/auth"
|
||||
|
||||
export const useV2Session = (options: any = {}) => {
|
||||
const { data, isLoading, isError, error } = useAdminCustomQuery(
|
||||
@@ -15,7 +16,7 @@ export const useV2Session = (options: any = {}) => {
|
||||
return { user, isLoading, isError, error }
|
||||
}
|
||||
|
||||
export const useV2LoginWithSession = () => {
|
||||
export const useV2LoginAndSetSession = () => {
|
||||
return useMutation(
|
||||
(payload: { email: string; password: string }) =>
|
||||
medusa.client.request("POST", "/auth/admin/emailpass", {
|
||||
@@ -41,3 +42,24 @@ export const useV2LoginWithSession = () => {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export const useV2CreateAuthUser = (provider = "emailpass") => {
|
||||
// TODO: Migrate type to work for other providers, e.g. Google
|
||||
return useMutation((args: CreateAuthUserInput) =>
|
||||
medusa.client.request("POST", `/auth/admin/${provider}`, args)
|
||||
)
|
||||
}
|
||||
|
||||
export const useV2AcceptInvite = (inviteToken: string) => {
|
||||
return useMutation((input: AcceptInviteInput) =>
|
||||
medusa.client.request(
|
||||
"POST",
|
||||
`/admin/invites/accept?token=${inviteToken}`,
|
||||
input.payload,
|
||||
{},
|
||||
{
|
||||
Authorization: `Bearer ${input.token}`,
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
13
packages/admin-next/dashboard/src/lib/api-v2/types/auth.ts
Normal file
13
packages/admin-next/dashboard/src/lib/api-v2/types/auth.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export type AcceptInviteInput = {
|
||||
payload: {
|
||||
first_name: string
|
||||
last_name: string
|
||||
}
|
||||
// Token for the created auth user
|
||||
token: string
|
||||
}
|
||||
|
||||
export type CreateAuthUserInput = {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
@@ -53,6 +53,10 @@ export const v2Routes: RouteObject[] = [
|
||||
path: "*",
|
||||
lazy: () => import("../../routes/no-match"),
|
||||
},
|
||||
{
|
||||
path: "/invite",
|
||||
lazy: () => import("../../v2-routes/invite"),
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute />,
|
||||
errorElement: <ErrorBoundary />,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAdminAcceptInvite } from "medusa-react"
|
||||
import { Trans, useTranslation } from "react-i18next"
|
||||
import { Link, useSearchParams } from "react-router-dom"
|
||||
import * as z from "zod"
|
||||
import i18n from "i18next"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
@@ -26,7 +27,7 @@ const CreateAccountSchema = z
|
||||
if (password !== repeat_password) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Passwords do not match",
|
||||
message: i18n.t("invite.passwordMismatch"),
|
||||
path: ["repeat_password"],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { Invite as Component } from "./invite"
|
||||
392
packages/admin-next/dashboard/src/v2-routes/invite/invite.tsx
Normal file
392
packages/admin-next/dashboard/src/v2-routes/invite/invite.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { UserRoles } from "@medusajs/medusa"
|
||||
import { Alert, Button, Heading, Input, Text } from "@medusajs/ui"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
import { Trans, useTranslation } from "react-i18next"
|
||||
import { Link, useSearchParams } from "react-router-dom"
|
||||
import * as z from "zod"
|
||||
|
||||
import i18n from "i18next"
|
||||
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"
|
||||
import { useV2AcceptInvite, useV2CreateAuthUser } from "../../lib/api-v2"
|
||||
|
||||
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: i18n.t("invite.passwordMismatch"),
|
||||
path: ["repeat_password"],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
type DecodedInvite = {
|
||||
id: string
|
||||
jti: UserRoles
|
||||
exp: 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,
|
||||
}: {
|
||||
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: "",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
password: "",
|
||||
repeat_password: "",
|
||||
},
|
||||
})
|
||||
|
||||
const { mutateAsync: createAuthUser, isLoading: isCreatingAuthUser } =
|
||||
useV2CreateAuthUser()
|
||||
|
||||
const { mutateAsync: acceptInvite, isLoading: isAcceptingInvite } =
|
||||
useV2AcceptInvite(token)
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (data) => {
|
||||
try {
|
||||
const { token: authToken } = await createAuthUser({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
})
|
||||
|
||||
const invitePayload = {
|
||||
email: data.email,
|
||||
first_name: data.first_name,
|
||||
last_name: data.last_name,
|
||||
}
|
||||
|
||||
await acceptInvite({
|
||||
payload: invitePayload,
|
||||
token: authToken,
|
||||
})
|
||||
onSuccess()
|
||||
} catch (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>
|
||||
<Input autoComplete="off" {...field} />
|
||||
</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={isCreatingAuthUser || isAcceptingInvite}
|
||||
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="/login" replace>
|
||||
{t("invite.successAction")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const InviteSchema = z.object({
|
||||
id: z.string(),
|
||||
jti: z.string(),
|
||||
exp: z.number(),
|
||||
iat: z.number(),
|
||||
})
|
||||
|
||||
const validateDecodedInvite = (decoded: any): decoded is DecodedInvite => {
|
||||
return InviteSchema.safeParse(decoded).success
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import * as z from "zod"
|
||||
|
||||
import { Form } from "../../components/common/form"
|
||||
import { LogoBox } from "../../components/common/logo-box"
|
||||
import { useV2LoginWithSession } from "../../lib/api-v2"
|
||||
import { useV2LoginAndSetSession } from "../../lib/api-v2"
|
||||
import { isAxiosError } from "../../lib/is-axios-error"
|
||||
|
||||
const LoginSchema = z.object({
|
||||
@@ -31,7 +31,7 @@ export const Login = () => {
|
||||
})
|
||||
|
||||
// TODO: Update when more than emailpass is supported
|
||||
const { mutateAsync, isLoading } = useV2LoginWithSession()
|
||||
const { mutateAsync, isLoading } = useV2LoginAndSetSession()
|
||||
|
||||
const handleSubmit = form.handleSubmit(async ({ email, password }) => {
|
||||
await mutateAsync(
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import * as defaultProviders from "@providers"
|
||||
|
||||
import {
|
||||
asClass,
|
||||
AwilixContainer,
|
||||
ClassOrFunctionReturning,
|
||||
Constructor,
|
||||
Resolver,
|
||||
} from "awilix"
|
||||
import {
|
||||
AuthModuleProviderConfig,
|
||||
AuthProviderScope,
|
||||
LoaderOptions,
|
||||
ModulesSdkTypes,
|
||||
} from "@medusajs/types"
|
||||
import {
|
||||
AwilixContainer,
|
||||
ClassOrFunctionReturning,
|
||||
Constructor,
|
||||
Resolver,
|
||||
asClass,
|
||||
} from "awilix"
|
||||
|
||||
type AuthModuleProviders = {
|
||||
providers: AuthModuleProviderConfig[]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
|
||||
|
||||
import { IAuthModuleService } from "@medusajs/types"
|
||||
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
|
||||
import { IAuthModuleService } from "@medusajs/types"
|
||||
import { isDefined } from "@medusajs/utils"
|
||||
|
||||
type StepInput = {
|
||||
|
||||
@@ -38,7 +38,6 @@ export function getLinkRepository(model: EntitySchema) {
|
||||
this.joinerConfig_.databaseConfig?.idPrefix ?? "link"
|
||||
)
|
||||
link.deleted_at = null
|
||||
|
||||
return manager.create(model, link)
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
AuthenticatedMedusaRequest,
|
||||
MedusaResponse,
|
||||
} from "../../../../types/routing"
|
||||
|
||||
import { AdminPostInvitesInviteAcceptReq } from "../validators"
|
||||
|
||||
export const POST = async (
|
||||
@@ -15,7 +16,7 @@ export const POST = async (
|
||||
const moduleService: IUserModuleService = req.scope.resolve(
|
||||
ModuleRegistrationName.USER
|
||||
)
|
||||
const user = moduleService.retrieve(req.auth.actor_id)
|
||||
const user = await moduleService.retrieve(req.auth.actor_id)
|
||||
res.status(200).json({ user })
|
||||
return
|
||||
}
|
||||
@@ -27,6 +28,7 @@ export const POST = async (
|
||||
} as InviteWorkflow.AcceptInviteWorkflowInputDTO
|
||||
|
||||
let users
|
||||
|
||||
try {
|
||||
const { result } = await acceptInviteWorkflow(req.scope).run({ input })
|
||||
users = result
|
||||
|
||||
@@ -109,7 +109,6 @@ export class AdminCreateInviteRequest {
|
||||
*/
|
||||
export class AdminPostInvitesInviteAcceptReq {
|
||||
/**
|
||||
* The invite's first name.
|
||||
* If email is not passed, we default to using the email of the invite.
|
||||
*/
|
||||
@IsString()
|
||||
|
||||
@@ -31,7 +31,7 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
|
||||
|
||||
if (successRedirectUrl) {
|
||||
const url = new URL(successRedirectUrl!)
|
||||
url.searchParams.append("auth_token", token)
|
||||
url.searchParams.append("access_token", token)
|
||||
|
||||
return res.redirect(url.toString())
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
MedusaError,
|
||||
ModulesSdkUtils,
|
||||
arrayDifference,
|
||||
isString,
|
||||
} from "@medusajs/utils"
|
||||
import jwt, { JwtPayload } from "jsonwebtoken"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user