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
Meng Zhang 2023-12-07 13:39:39 +08:00 committed by GitHub
parent bec96a176a
commit 27d2f18302
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 597 additions and 1 deletions

View File

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

View File

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

16
ee/tabby-ui/app/auth/page.tsx vendored Normal file
View File

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

177
ee/tabby-ui/components/ui/form.tsx vendored Normal file
View File

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

View File

@ -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.
*/

View File

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

View File

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

View File

@ -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": {

15
ee/tabby-ui/yarn.lock vendored
View File

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