feat: add authentication page example (#955)
* feat: add authentication page autogen * feat: pass invitation code to UserAuthForm in SignUp * [autofix.ci] apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>support-auth-token
parent
bec96a176a
commit
27d2f18302
|
|
@ -0,0 +1,34 @@
|
|||
'use client'
|
||||
|
||||
import { graphql } from '@/lib/gql/generates'
|
||||
import { UserAuthForm } from './user-auth-form'
|
||||
import { useGraphQL } from '@/lib/hooks/use-graphql'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
|
||||
export const getIsAdminInitialized = graphql(/* GraphQL */ `
|
||||
query GetIsAdminInitialized {
|
||||
isAdminInitialized
|
||||
}
|
||||
`)
|
||||
|
||||
export default function Signup() {
|
||||
const { data } = useGraphQL(getIsAdminInitialized)
|
||||
const title = data?.isAdminInitialized
|
||||
? 'Create an account'
|
||||
: 'Create an admin account'
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
const invitationCode = searchParams.get('invitationCode') || undefined
|
||||
|
||||
return (
|
||||
<div className="space-y-6 w-[350px]">
|
||||
<div className="flex flex-col space-y-2 text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Fill form below to create your account
|
||||
</p>
|
||||
</div>
|
||||
<UserAuthForm invitationCode={invitationCode} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { IconSpinner } from '@/components/ui/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form'
|
||||
import { graphql } from '@/lib/gql/generates'
|
||||
import { useGraphQLForm } from '@/lib/tabby-gql-client'
|
||||
|
||||
export const registerUser = graphql(/* GraphQL */ `
|
||||
mutation register(
|
||||
$email: String!
|
||||
$password1: String!
|
||||
$password2: String!
|
||||
$invitationCode: String
|
||||
) {
|
||||
register(
|
||||
email: $email
|
||||
password1: $password1
|
||||
password2: $password2
|
||||
invitationCode: $invitationCode
|
||||
) {
|
||||
accessToken
|
||||
refreshToken
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
password1: z.string(),
|
||||
password2: z.string(),
|
||||
invitationCode: z.string().optional()
|
||||
})
|
||||
|
||||
interface UserAuthFormProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
invitationCode?: string
|
||||
}
|
||||
|
||||
export function UserAuthForm({
|
||||
className,
|
||||
invitationCode,
|
||||
...props
|
||||
}: UserAuthFormProps) {
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password1: '',
|
||||
password2: '',
|
||||
invitationCode
|
||||
}
|
||||
})
|
||||
|
||||
const { isSubmitting } = form.formState
|
||||
const { onSubmit } = useGraphQLForm(registerUser, {
|
||||
onError: (path, message) => form.setError(path as any, { message })
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={cn('grid gap-6', className)} {...props}>
|
||||
<Form {...form}>
|
||||
<form className="grid gap-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder=""
|
||||
type="email"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
autoCorrect="off"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password1"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password2"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirm Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="invitationCode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input type="hidden" {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && (
|
||||
<IconSpinner className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Register
|
||||
</Button>
|
||||
</form>
|
||||
<FormMessage className="text-center" />
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { Metadata } from 'next'
|
||||
|
||||
import Signup from './components/signup'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Authentication',
|
||||
description: 'Authentication forms built using the components.'
|
||||
}
|
||||
|
||||
export default function AuthenticationPage() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-1">
|
||||
<Signup />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
import * as React from 'react'
|
||||
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext
|
||||
} from 'react-hook-form'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
const name = fieldContext.name || 'root'
|
||||
|
||||
const fieldState = getFieldState(name, formState)
|
||||
|
||||
if (!formState) {
|
||||
throw new Error('useFormField should be used within <Form>')
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn('space-y-2', className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
})
|
||||
FormItem.displayName = 'FormItem'
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && 'text-destructive', className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormLabel.displayName = 'FormLabel'
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormControl.displayName = 'FormControl'
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormDescription.displayName = 'FormDescription'
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message) : children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn('text-sm font-medium text-destructive', className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
FormMessage.displayName = 'FormMessage'
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField
|
||||
}
|
||||
|
|
@ -15,6 +15,10 @@ import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/
|
|||
const documents = {
|
||||
'\n query GetRegistrationToken {\n registrationToken\n }\n':
|
||||
types.GetRegistrationTokenDocument,
|
||||
'\n query GetIsAdminInitialized {\n isAdminInitialized\n }\n':
|
||||
types.GetIsAdminInitializedDocument,
|
||||
'\n mutation register($email: String!, $password1: String!, $password2: String!, $invitationCode: String) {\n register(\n email: $email\n password1: $password1\n password2: $password2\n invitationCode: $invitationCode\n ) {\n accessToken\n refreshToken\n }\n }\n':
|
||||
types.RegisterDocument,
|
||||
'\n query GetWorkers {\n workers {\n kind\n name\n addr\n device\n arch\n cpuInfo\n cpuCount\n cudaDevices\n }\n }\n':
|
||||
types.GetWorkersDocument,
|
||||
'\n mutation refreshToken($refreshToken: String!) {\n refreshToken(refreshToken: $refreshToken) {\n accessToken\n refreshToken\n }\n }\n':
|
||||
|
|
@ -41,6 +45,18 @@ export function graphql(source: string): unknown
|
|||
export function graphql(
|
||||
source: '\n query GetRegistrationToken {\n registrationToken\n }\n'
|
||||
): (typeof documents)['\n query GetRegistrationToken {\n registrationToken\n }\n']
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(
|
||||
source: '\n query GetIsAdminInitialized {\n isAdminInitialized\n }\n'
|
||||
): (typeof documents)['\n query GetIsAdminInitialized {\n isAdminInitialized\n }\n']
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(
|
||||
source: '\n mutation register($email: String!, $password1: String!, $password2: String!, $invitationCode: String) {\n register(\n email: $email\n password1: $password1\n password2: $password2\n invitationCode: $invitationCode\n ) {\n accessToken\n refreshToken\n }\n }\n'
|
||||
): (typeof documents)['\n mutation register($email: String!, $password1: String!, $password2: String!, $invitationCode: String) {\n register(\n email: $email\n password1: $password1\n password2: $password2\n invitationCode: $invitationCode\n ) {\n accessToken\n refreshToken\n }\n }\n']
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -146,6 +146,31 @@ export type GetRegistrationTokenQuery = {
|
|||
registrationToken: string
|
||||
}
|
||||
|
||||
export type GetIsAdminInitializedQueryVariables = Exact<{
|
||||
[key: string]: never
|
||||
}>
|
||||
|
||||
export type GetIsAdminInitializedQuery = {
|
||||
__typename?: 'Query'
|
||||
isAdminInitialized: boolean
|
||||
}
|
||||
|
||||
export type RegisterMutationVariables = Exact<{
|
||||
email: Scalars['String']['input']
|
||||
password1: Scalars['String']['input']
|
||||
password2: Scalars['String']['input']
|
||||
invitationCode?: InputMaybe<Scalars['String']['input']>
|
||||
}>
|
||||
|
||||
export type RegisterMutation = {
|
||||
__typename?: 'Mutation'
|
||||
register: {
|
||||
__typename?: 'RegisterResponse'
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
}
|
||||
}
|
||||
|
||||
export type GetWorkersQueryVariables = Exact<{ [key: string]: never }>
|
||||
|
||||
export type GetWorkersQuery = {
|
||||
|
|
@ -195,6 +220,128 @@ export const GetRegistrationTokenDocument = {
|
|||
GetRegistrationTokenQuery,
|
||||
GetRegistrationTokenQueryVariables
|
||||
>
|
||||
export const GetIsAdminInitializedDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'query',
|
||||
name: { kind: 'Name', value: 'GetIsAdminInitialized' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'isAdminInitialized' } }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
} as unknown as DocumentNode<
|
||||
GetIsAdminInitializedQuery,
|
||||
GetIsAdminInitializedQueryVariables
|
||||
>
|
||||
export const RegisterDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'register' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'email' }
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }
|
||||
}
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'password1' }
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }
|
||||
}
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'password2' }
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }
|
||||
}
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'invitationCode' }
|
||||
},
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }
|
||||
}
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'register' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'email' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'email' }
|
||||
}
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'password1' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'password1' }
|
||||
}
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'password2' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'password2' }
|
||||
}
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'invitationCode' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'invitationCode' }
|
||||
}
|
||||
}
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'accessToken' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'refreshToken' } }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
} as unknown as DocumentNode<RegisterMutation, RegisterMutationVariables>
|
||||
export const GetWorkersDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
|
|
|
|||
|
|
@ -1,5 +1,47 @@
|
|||
import { GraphQLClient } from 'graphql-request'
|
||||
import { GraphQLClient, Variables } from 'graphql-request'
|
||||
import { TypedDocumentNode } from '@graphql-typed-document-node/core'
|
||||
import { GraphQLResponse } from 'graphql-request/build/esm/types'
|
||||
|
||||
export const gqlClient = new GraphQLClient(
|
||||
`${process.env.NEXT_PUBLIC_TABBY_SERVER_URL ?? ''}/graphql`
|
||||
)
|
||||
|
||||
export interface ValidationError {
|
||||
path: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ValidationErrors {
|
||||
errors: Array<ValidationError>
|
||||
}
|
||||
|
||||
export function useGraphQLForm<
|
||||
TResult,
|
||||
TVariables extends Variables | undefined
|
||||
>(
|
||||
document: TypedDocumentNode<TResult, TVariables>,
|
||||
options?: {
|
||||
onError?: (path: string, message: string) => void
|
||||
}
|
||||
) {
|
||||
const onSubmit = async (values: TVariables) => {
|
||||
try {
|
||||
await gqlClient.request(document, values)
|
||||
} catch (err) {
|
||||
const { errors = [] } = (err as any).response as GraphQLResponse
|
||||
for (const error of errors) {
|
||||
if (error.extensions && error.extensions['validation-errors']) {
|
||||
const validationErrors = error.extensions[
|
||||
'validation-errors'
|
||||
] as ValidationErrors
|
||||
for (const error of validationErrors.errors) {
|
||||
options?.onError && options?.onError(error.path, error.message)
|
||||
}
|
||||
} else {
|
||||
options?.onError && options?.onError('root', error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { onSubmit }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
"codegen:watch": "graphql-codegen --config codegen.ts --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.3.2",
|
||||
"@radix-ui/react-alert-dialog": "1.0.4",
|
||||
"@radix-ui/react-dialog": "1.0.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.5",
|
||||
|
|
@ -48,6 +49,7 @@
|
|||
"openai-edge": "^0.5.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.48.2",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-intersection-observer": "^9.4.4",
|
||||
"react-markdown": "^8.0.7",
|
||||
|
|
@ -56,6 +58,7 @@
|
|||
"remark-gfm": "^3.0.1",
|
||||
"remark-math": "^5.1.1",
|
||||
"swr": "^2.2.4",
|
||||
"zod": "^3.22.4",
|
||||
"zustand": "^4.4.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -1010,6 +1010,11 @@
|
|||
resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.2.0.tgz#5f3d96ec6b2354ad6d8a28bf216a1d97b5426861"
|
||||
integrity sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==
|
||||
|
||||
"@hookform/resolvers@^3.3.2":
|
||||
version "3.3.2"
|
||||
resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-3.3.2.tgz#5c40f06fe8137390b071d961c66d27ee8f76f3bc"
|
||||
integrity sha512-Tw+GGPnBp+5DOsSg4ek3LCPgkBOuOgS5DsDV7qsWNH9LZc433kgsWICjlsh2J9p04H2K66hsXPPb9qn9ILdUtA==
|
||||
|
||||
"@humanwhocodes/config-array@^0.11.11":
|
||||
version "0.11.11"
|
||||
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.11.tgz#88a04c570dbbc7dd943e4712429c3df09bc32844"
|
||||
|
|
@ -5784,6 +5789,11 @@ react-dom@^18.2.0:
|
|||
loose-envify "^1.1.0"
|
||||
scheduler "^0.23.0"
|
||||
|
||||
react-hook-form@^7.48.2:
|
||||
version "7.48.2"
|
||||
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.48.2.tgz#01150354d2be61412ff56a030b62a119283b9935"
|
||||
integrity sha512-H0T2InFQb1hX7qKtDIZmvpU1Xfn/bdahWBN1fH19gSe4bBEqTfmlr7H3XWTaVtiK4/tpPaI1F3355GPMZYge+A==
|
||||
|
||||
react-hot-toast@^2.4.1:
|
||||
version "2.4.1"
|
||||
resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.4.1.tgz#df04295eda8a7b12c4f968e54a61c8d36f4c0994"
|
||||
|
|
@ -7233,6 +7243,11 @@ zod@3.21.4:
|
|||
resolved "https://registry.yarnpkg.com/zod/-/zod-3.21.4.tgz#10882231d992519f0a10b5dd58a38c9dabbb64db"
|
||||
integrity sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==
|
||||
|
||||
zod@^3.22.4:
|
||||
version "3.22.4"
|
||||
resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.4.tgz#f31c3a9386f61b1f228af56faa9255e845cf3fff"
|
||||
integrity sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==
|
||||
|
||||
zustand@^4.4.6:
|
||||
version "4.4.6"
|
||||
resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.4.6.tgz#03c78e3e2686c47095c93714c0c600b72a6512bd"
|
||||
|
|
|
|||
Loading…
Reference in New Issue