feat(admin-next): Email password invite flow in admin 2.0 (#6821)

This commit is contained in:
Oli Juhl
2024-03-29 08:05:11 +01:00
committed by GitHub
parent 45c49e89f2
commit d97af91a8d
15 changed files with 453 additions and 20 deletions

View File

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

View File

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

View 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
}

View File

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

View File

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

View File

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

View 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
}

View File

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

View File

@@ -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[]

View File

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

View File

@@ -38,7 +38,6 @@ export function getLinkRepository(model: EntitySchema) {
this.joinerConfig_.databaseConfig?.idPrefix ?? "link"
)
link.deleted_at = null
return manager.create(model, link)
})

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ import {
MedusaError,
ModulesSdkUtils,
arrayDifference,
isString,
} from "@medusajs/utils"
import jwt, { JwtPayload } from "jsonwebtoken"