From b85585ad7249dc4c3794dda4240845b0c908d212 Mon Sep 17 00:00:00 2001 From: Meng Zhang Date: Tue, 5 Dec 2023 22:46:20 +0800 Subject: [PATCH] feat: connect user register with backend api --- .../app/auth/components/user-auth-form.tsx | 40 ++-- ee/tabby-ui/lib/gql/generates/gql.ts | 26 ++- ee/tabby-ui/lib/gql/generates/graphql.ts | 175 +++++++++++++++--- ee/tabby-ui/lib/tabby-gql-client.ts | 39 +++- ee/tabby-webserver/src/schema/mod.rs | 8 +- ee/tabby-webserver/src/service/auth.rs | 10 +- 6 files changed, 239 insertions(+), 59 deletions(-) diff --git a/ee/tabby-ui/app/auth/components/user-auth-form.tsx b/ee/tabby-ui/app/auth/components/user-auth-form.tsx index a5fb309..912dc65 100644 --- a/ee/tabby-ui/app/auth/components/user-auth-form.tsx +++ b/ee/tabby-ui/app/auth/components/user-auth-form.tsx @@ -3,7 +3,7 @@ import * as React from "react" import { zodResolver } from "@hookform/resolvers/zod" -import { useForm } from "react-hook-form" +import { FieldError, FieldErrors, useForm, useFormContext, useFormState } from "react-hook-form" import * as z from "zod" import { cn } from "@/lib/utils" @@ -11,15 +11,28 @@ 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(), -}).refine((data) => data.password1 === data.password2, { - message: "Passwords don't match", - path: ["password2"], }); interface UserAuthFormProps extends React.HTMLAttributes { @@ -30,18 +43,17 @@ export function UserAuthForm({ className, invitationCode, ...props }: UserAuthFo const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { - email: "", - password1: "", - password2: "", + email: "a@a.com", + password1: "a@a.comacom", + password2: "a@a.comacom", invitationCode, } }) - const { isSubmitSuccessful, isSubmitting } = form.formState; - - async function onSubmit(values: z.infer) { - await new Promise(resolve => setTimeout(resolve, 500000)) - } + const { isSubmitting } = form.formState; + const { onSubmit } = useGraphQLForm(registerUser, { + onError: (path, message) => form.setError(path as any, { message }) + }); return (
@@ -78,6 +90,7 @@ export function UserAuthForm({ className, invitationCode, ...props }: UserAuthFo + )} /> ( @@ -91,9 +104,10 @@ export function UserAuthForm({ className, invitationCode, ...props }: UserAuthFo {isSubmitting && ( )} - Sign In + Register +
) diff --git a/ee/tabby-ui/lib/gql/generates/gql.ts b/ee/tabby-ui/lib/gql/generates/gql.ts index ce70771..a028a2a 100644 --- a/ee/tabby-ui/lib/gql/generates/gql.ts +++ b/ee/tabby-ui/lib/gql/generates/gql.ts @@ -13,12 +13,14 @@ import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/ * Therefore it is highly recommended to use the babel or swc plugin for production. */ const documents = { + '\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 query GetRegistrationToken {\n registrationToken\n }\n': - types.GetRegistrationTokenDocument, - '\n query GetIsAdminInitialized {\n isAdminInitialized\n }\n': - types.GetIsAdminInitializedDocument + types.GetRegistrationTokenDocument } /** @@ -35,6 +37,18 @@ const documents = { */ export function graphql(source: string): unknown +/** + * 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. */ @@ -47,12 +61,6 @@ export function graphql( 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'] export function graphql(source: string) { return (documents as any)[source] ?? {} diff --git a/ee/tabby-ui/lib/gql/generates/graphql.ts b/ee/tabby-ui/lib/gql/generates/graphql.ts index 10e68ad..ae50657 100644 --- a/ee/tabby-ui/lib/gql/generates/graphql.ts +++ b/ee/tabby-ui/lib/gql/generates/graphql.ts @@ -127,6 +127,31 @@ export enum WorkerKind { Completion = 'COMPLETION' } +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 +}> + +export type RegisterMutation = { + __typename?: 'Mutation' + register: { + __typename?: 'RegisterResponse' + accessToken: string + refreshToken: string + } +} + export type GetWorkersQueryVariables = Exact<{ [key: string]: never }> export type GetWorkersQuery = { @@ -151,15 +176,128 @@ export type GetRegistrationTokenQuery = { registrationToken: string } -export type GetIsAdminInitializedQueryVariables = Exact<{ - [key: string]: never -}> - -export type GetIsAdminInitializedQuery = { - __typename?: 'Query' - isAdminInitialized: boolean -} - +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 export const GetWorkersDocument = { kind: 'Document', definitions: [ @@ -211,22 +349,3 @@ 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 -> diff --git a/ee/tabby-ui/lib/tabby-gql-client.ts b/ee/tabby-ui/lib/tabby-gql-client.ts index 25d4c33..769ffb9 100644 --- a/ee/tabby-ui/lib/tabby-gql-client.ts +++ b/ee/tabby-ui/lib/tabby-gql-client.ts @@ -1,5 +1,42 @@ -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 +} + +export function useGraphQLForm( + document: TypedDocumentNode, + 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 }; +} \ No newline at end of file diff --git a/ee/tabby-webserver/src/schema/mod.rs b/ee/tabby-webserver/src/schema/mod.rs index 04b486e..798a56f 100644 --- a/ee/tabby-webserver/src/schema/mod.rs +++ b/ee/tabby-webserver/src/schema/mod.rs @@ -191,9 +191,11 @@ fn from_validation_errors(error: ValidationErrors) -> FieldError obj.into() }) .collect::>(); - let mut ext = Object::with_capacity(2); - ext.add_field("code", Value::scalar("validation-error".to_string())); - ext.add_field("errors", Value::list(errors)); + let mut error = Object::with_capacity(1); + error.add_field("errors", Value::list(errors)); + + let mut ext = Object::with_capacity(1); + ext.add_field("validation-errors", error.into()); FieldError::new("Invalid input parameters", ext.into()) } diff --git a/ee/tabby-webserver/src/service/auth.rs b/ee/tabby-webserver/src/service/auth.rs index 5a7cb31..6cccb87 100644 --- a/ee/tabby-webserver/src/service/auth.rs +++ b/ee/tabby-webserver/src/service/auth.rs @@ -38,17 +38,17 @@ struct RegisterInput { code = "password1", message = "Password must be at most 20 characters" ))] - #[validate(must_match( - code = "password1", - message = "Passwords do not match", - other = "password2" - ))] password1: String, #[validate(length( min = 8, code = "password2", message = "Password must be at least 8 characters" ))] + #[validate(must_match( + code = "password2", + message = "Passwords do not match", + other = "password1" + ))] #[validate(length( max = 20, code = "password2",