feat(tabby-ui): add cluster information page and team management page (#997)

* feat(tabby-ui): move swagger API from sidebar to header

* feat(tabby-ui): extract /workers page

* feat(tabby-ui): add reset button for registeration token

* refactor: extract <SlackDialog />

* feat(tabby-ui): display authToken in dashboard

* add team management page

* extract invitation-table

* fix lint

* rename /workers -> /cluster

* cleanup team management page

* cleanup

* fix

* fix link title

* fix title

* fix nextjs build

* fix lint

* fix missing window error

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
r0.7
Meng Zhang 2023-12-10 00:51:05 +08:00 committed by GitHub
parent fbceaafbc9
commit fc93cf80b4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 611 additions and 159 deletions

View File

@ -0,0 +1,90 @@
'use client'
import { graphql } from '@/lib/gql/generates'
import { WorkerKind } from '@/lib/gql/generates/graphql'
import { useHealth } from '@/lib/hooks/use-health'
import { useWorkers } from '@/lib/hooks/use-workers'
import { useAuthenticatedGraphQLQuery, useGraphQLForm } from '@/lib/tabby/gql'
import { Button } from '@/components/ui/button'
import { IconRefresh } from '@/components/ui/icons'
import { CopyButton } from '@/components/copy-button'
import WorkerCard from './worker-card'
const getRegistrationTokenDocument = graphql(/* GraphQL */ `
query GetRegistrationToken {
registrationToken
}
`)
const resetRegistrationTokenDocument = graphql(/* GraphQL */ `
mutation ResetRegistrationToken {
resetRegistrationToken
}
`)
export default function Workers() {
const { data: healthInfo } = useHealth()
const workers = useWorkers()
const { data: registrationTokenRes, mutate } = useAuthenticatedGraphQLQuery(
getRegistrationTokenDocument
)
const { onSubmit: resetRegistrationToken } = useGraphQLForm(
resetRegistrationTokenDocument,
{
onSuccess: () => mutate()
}
)
if (!healthInfo) return
return (
<div className="flex w-full flex-col gap-3 p-4 lg:p-16">
{!!registrationTokenRes?.registrationToken && (
<div className="flex items-center gap-1">
Registeration token:
<code className="rounded-lg text-sm text-red-600">
{registrationTokenRes.registrationToken}
</code>
<Button
title="Reset"
size="icon"
variant="hover-destructive"
onClick={() => resetRegistrationToken()}
>
<IconRefresh />
</Button>
<CopyButton value={registrationTokenRes.registrationToken} />
</div>
)}
<div className="mt-4 flex flex-col gap-4 lg:flex-row lg:flex-wrap">
{!!workers?.[WorkerKind.Completion] && (
<>
{workers[WorkerKind.Completion].map((worker, i) => {
return <WorkerCard key={i} {...worker} />
})}
</>
)}
{!!workers?.[WorkerKind.Chat] && (
<>
{workers[WorkerKind.Chat].map((worker, i) => {
return <WorkerCard key={i} {...worker} />
})}
</>
)}
<WorkerCard
addr="localhost"
name="Code Search Index"
kind="INDEX"
arch=""
device={healthInfo.device}
cudaDevices={healthInfo.cuda_devices}
cpuCount={healthInfo.cpu_count}
cpuInfo={healthInfo.cpu_info}
/>
</div>
</div>
)
}

View File

@ -0,0 +1,11 @@
import { Metadata } from 'next'
import ClusterInfo from './components/cluster'
export const metadata: Metadata = {
title: 'Cluster Information'
}
export default function IndexPage() {
return <ClusterInfo />
}

View File

@ -4,7 +4,9 @@ import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { cva } from 'class-variance-authority'
import { useSession } from '@/lib/tabby/auth'
import { cn } from '@/lib/utils'
import { IconHome, IconNetwork, IconUsers } from '@/components/ui/icons'
import UserPanel from '@/components/user-panel'
export interface SidebarProps {
@ -13,6 +15,8 @@ export interface SidebarProps {
}
export default function Sidebar({ children, className }: SidebarProps) {
const { data: session } = useSession()
const isAdmin = session?.isAdmin || false
return (
<div
className={cn('grid overflow-hidden lg:grid-cols-[280px_1fr]', className)}
@ -23,45 +27,18 @@ export default function Sidebar({ children, className }: SidebarProps) {
<div className="flex-1">
<nav className="grid items-start gap-4 px-4 text-sm font-medium">
<SidebarButton href="/">
<svg
xmlns="http://www.w3.org/2000/svg"
className=" h-4 w-4"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
Home
</SidebarButton>
<SidebarButton href="/swagger">
<svg
xmlns="http://www.w3.org/2000/svg"
className=" h-4 w-4"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
<path d="M6 8h2" />
<path d="M6 12h2" />
<path d="M16 8h2" />
<path d="M16 12h2" />
</svg>
Swagger
<IconHome /> Home
</SidebarButton>
{isAdmin && (
<>
<SidebarButton href="/cluster">
<IconNetwork /> Cluster Information
</SidebarButton>
<SidebarButton href="/team">
<IconUsers /> Team Management
</SidebarButton>
</>
)}
</nav>
</div>

View File

@ -5,7 +5,10 @@ import { Header } from '@/components/header'
import Sidebar from './components/sidebar'
export const metadata: Metadata = {
title: 'Dashboard'
title: {
default: 'Home',
template: `Tabby - %s`
}
}
interface DashboardLayoutProps {

View File

@ -1,100 +1,38 @@
'use client'
import { PropsWithChildren, useEffect, useState } from 'react'
import { graphql } from '@/lib/gql/generates'
import { WorkerKind } from '@/lib/gql/generates/graphql'
import { useHealth } from '@/lib/hooks/use-health'
import { useWorkers } from '@/lib/hooks/use-workers'
import { useSession } from '@/lib/tabby/auth'
import { useAuthenticatedGraphQLQuery } from '@/lib/tabby/gql'
import { buttonVariants } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { IconSlack } from '@/components/ui/icons'
import { Separator } from '@/components/ui/separator'
import { CopyButton } from '@/components/copy-button'
import WorkerCard from './components/worker-card'
const COMMUNITY_DIALOG_SHOWN_KEY = 'community-dialog-shown'
import SlackDialog from '@/components/slack-dialog'
export default function Home() {
const { status } = useSession()
const [open, setOpen] = useState(false)
useEffect(() => {
if (status !== 'authenticated') return
if (!localStorage.getItem(COMMUNITY_DIALOG_SHOWN_KEY)) {
setOpen(true)
localStorage.setItem(COMMUNITY_DIALOG_SHOWN_KEY, 'true')
}
}, [status])
return (
<div className="p-4 lg:p-16">
<MainPanel />
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader className="gap-3">
<DialogTitle>Join the Tabby community</DialogTitle>
<DialogDescription>
Connect with other contributors building Tabby. Share knowledge,
get help, and contribute to the open-source project.
</DialogDescription>
</DialogHeader>
<DialogFooter className="sm:justify-start">
<a
target="_blank"
href="https://join.slack.com/t/tabbycommunity/shared_invite/zt-1xeiddizp-bciR2RtFTaJ37RBxr8VxpA"
className={buttonVariants()}
>
<IconSlack className="-ml-2 h-8 w-8" />
Join us on Slack
</a>
</DialogFooter>
</DialogContent>
</Dialog>
<SlackDialog />
</div>
)
}
interface LinkProps {
href: string
}
function Link({ href, children }: PropsWithChildren<LinkProps>) {
return (
<a target="_blank" href={href} className="underline">
{children}
</a>
)
}
function toBadgeString(str: string) {
return encodeURIComponent(str.replaceAll('-', '--'))
}
const getRegistrationTokenDocument = graphql(/* GraphQL */ `
query GetRegistrationToken {
registrationToken
const meQuery = graphql(/* GraphQL */ `
query MeQuery {
me {
authToken
}
}
`)
function MainPanel() {
const { data: healthInfo } = useHealth()
const workers = useWorkers(healthInfo)
const { data: registrationTokenRes } = useAuthenticatedGraphQLQuery(
getRegistrationTokenDocument
)
const { data } = useAuthenticatedGraphQLQuery(meQuery)
if (!healthInfo) return
if (!healthInfo || !data) return
return (
<div className="flex w-full flex-col gap-3">
@ -115,47 +53,24 @@ function MainPanel() {
</a>
</span>
<Separator />
<div className="mt-4 rounded-lg bg-zinc-100 p-4 dark:bg-zinc-800">
<span className="font-bold">Workers</span>
{!!registrationTokenRes?.registrationToken && (
<div className="flex items-center gap-1">
Registeration token:{' '}
<span className="rounded-lg text-sm text-red-600">
{registrationTokenRes.registrationToken}
</span>
<CopyButton value={registrationTokenRes.registrationToken} />
</div>
)}
<div className="mt-4 flex flex-col gap-4 lg:flex-row lg:flex-wrap">
{!!workers?.[WorkerKind.Completion] && (
<>
{workers[WorkerKind.Completion].map((worker, i) => {
return <WorkerCard key={i} {...worker} />
})}
</>
)}
{!!workers?.[WorkerKind.Chat] && (
<>
{workers[WorkerKind.Chat].map((worker, i) => {
return <WorkerCard key={i} {...worker} />
})}
</>
)}
<WorkerCard
addr="localhost"
name="Code Search Index"
kind="INDEX"
arch=""
device={healthInfo.device}
cudaDevices={healthInfo.cuda_devices}
cpuCount={healthInfo.cpu_count}
cpuInfo={healthInfo.cpu_info}
/>
</div>
<div className="flex items-center">
<span className="mr-2">Token:</span>
<code className="rounded-lg text-sm text-red-600">
{data.me.authToken}
</code>
<CopyButton value={data.me.authToken} />
</div>
<p>
Use credentials above for IDE extensions / plugins authentication, see{' '}
<a
className="underline"
target="_blank"
href="https://tabby.tabbyml.com/docs/extensions/configurations#server"
>
configurations
</a>{' '}
for details
</p>
</div>
)
}

View File

@ -0,0 +1,81 @@
'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 { graphql } from '@/lib/gql/generates'
import { useGraphQLForm } from '@/lib/tabby/gql'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
const createInvitation = graphql(/* GraphQL */ `
mutation CreateInvitation($email: String!) {
createInvitation(email: $email)
}
`)
const formSchema = z.object({
email: z.string().email('Invalid email address')
})
export default function CreateInvitationForm({
onCreated
}: {
onCreated: () => void
}) {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema)
})
const { isSubmitting } = form.formState
const { onSubmit } = useGraphQLForm(createInvitation, {
onSuccess: () => {
form.reset({ email: '' })
onCreated()
},
onError: (path, message) => form.setError(path as any, { message })
})
return (
<Form {...form}>
<div className="flex flex-col items-end gap-2">
<form
className="flex w-full items-center gap-2"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder="Email"
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<Button type="submit" disabled={isSubmitting}>
Invite
</Button>
</form>
<FormMessage className="text-center" />
</div>
</Form>
)
}

View File

@ -0,0 +1,95 @@
'use client'
import React, { useEffect, useState } from 'react'
import moment from 'moment'
import { graphql } from '@/lib/gql/generates'
import { useAuthenticatedGraphQLQuery, useGraphQLForm } from '@/lib/tabby/gql'
import { Button } from '@/components/ui/button'
import { IconTrash } from '@/components/ui/icons'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table'
import { CopyButton } from '@/components/copy-button'
import CreateInvitationForm from './create-invitation-form'
const listInvitations = graphql(/* GraphQL */ `
query ListInvitations {
invitations {
id
email
code
createdAt
}
}
`)
const deleteInvitationMutation = graphql(/* GraphQL */ `
mutation DeleteInvitation($id: Int!) {
deleteInvitation(id: $id)
}
`)
export default function InvitationTable() {
const { data, mutate } = useAuthenticatedGraphQLQuery(listInvitations)
const invitations = data?.invitations
const [origin, setOrigin] = useState('')
useEffect(() => {
setOrigin(new URL(window.location.href).origin)
}, [])
const { onSubmit: deleteInvitation } = useGraphQLForm(
deleteInvitationMutation,
{
onSuccess: () => mutate()
}
)
return (
invitations && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Invitee</TableHead>
<TableHead>Created</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invitations.map((x, i) => {
const link = `${origin}/auth/signup?invitationCode=${x.code}`
return (
<TableRow key={i}>
<TableCell className="w-[300px] font-medium">
{x.email}
</TableCell>
<TableCell>{moment.utc(x.createdAt).fromNow()}</TableCell>
<TableCell className="flex items-center">
<CopyButton value={link} />
<Button
size="icon"
variant="hover-destructive"
onClick={() => deleteInvitation({ id: x.id })}
>
<IconTrash />
</Button>
</TableCell>
</TableRow>
)
})}
<TableRow>
<TableCell className="p-2">
<CreateInvitationForm onCreated={() => mutate()} />
</TableCell>
</TableRow>
</TableBody>
</Table>
)
)
}

View File

@ -0,0 +1,26 @@
'use client'
import { CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import InvitationTable from './invitation-table'
export default function Team() {
return (
<div>
<div>
<CardHeader>
<CardTitle>Invites</CardTitle>
</CardHeader>
<CardContent className="p-4">
<InvitationTable />
</CardContent>
</div>
<div>
<CardHeader>
<CardTitle>Users</CardTitle>
</CardHeader>
<CardContent className="p-4"></CardContent>
</div>
</div>
)
}

View File

@ -0,0 +1,15 @@
import { Metadata } from 'next'
import Team from './components/team'
export const metadata: Metadata = {
title: 'Team Management'
}
export default function IndexPage() {
return (
<div className="p-4 lg:p-16">
<Team />
</div>
)
}

View File

@ -1,5 +1,7 @@
import { Metadata } from 'next'
import { Header } from '@/components/header'
export const metadata: Metadata = {
title: 'API'
}
@ -7,5 +9,10 @@ export const metadata: Metadata = {
const serverUrl = process.env.NEXT_PUBLIC_TABBY_SERVER_URL || ''
export default function IndexPage() {
return <iframe className="grow" src={`${serverUrl}/swagger-ui`} />
return (
<>
<Header />
<iframe className="grow" src={`${serverUrl}/swagger-ui`} />
</>
)
}

View File

@ -21,7 +21,7 @@ export function Header() {
useAuthenticatedSession()
const { data } = useHealth()
const workers = useWorkers(data)
const workers = useWorkers()
const isChatEnabled = has(workers, WorkerKind.Chat)
const version = data?.version?.git_describe
const { data: latestRelease } = useLatestRelease()
@ -34,6 +34,9 @@ export function Header() {
<Link href="/" className={cn(buttonVariants({ variant: 'link' }))}>
Dashboard
</Link>
<Link href="/api" className={cn(buttonVariants({ variant: 'link' }))}>
API
</Link>
{isChatEnabled && (
<Link
href="/playground"

54
ee/tabby-ui/components/slack-dialog.tsx vendored Normal file
View File

@ -0,0 +1,54 @@
'use client'
import { useEffect, useState } from 'react'
import { useSession } from '@/lib/tabby/auth'
import { buttonVariants } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { IconSlack } from '@/components/ui/icons'
const COMMUNITY_DIALOG_SHOWN_KEY = 'community-dialog-shown'
export default function SlackDialog() {
const { status } = useSession()
const [open, setOpen] = useState(false)
useEffect(() => {
if (status !== 'authenticated') return
if (!localStorage.getItem(COMMUNITY_DIALOG_SHOWN_KEY)) {
setOpen(true)
localStorage.setItem(COMMUNITY_DIALOG_SHOWN_KEY, 'true')
}
}, [status])
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader className="gap-3">
<DialogTitle>Join the Tabby community</DialogTitle>
<DialogDescription>
Connect with other contributors building Tabby. Share knowledge, get
help, and contribute to the open-source project.
</DialogDescription>
</DialogHeader>
<DialogFooter className="sm:justify-start">
<a
target="_blank"
href="https://join.slack.com/t/tabbycommunity/shared_invite/zt-1xeiddizp-bciR2RtFTaJ37RBxr8VxpA"
className={buttonVariants()}
>
<IconSlack className="-ml-2 h-8 w-8" />
Join us on Slack
</a>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -13,6 +13,8 @@ const buttonVariants = cva(
'bg-primary text-primary-foreground shadow-md hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
'hover-destructive':
'shadow-none hover:bg-destructive/90 hover:text-destructive-foreground',
outline:
'border border-input hover:bg-accent hover:text-accent-foreground',
secondary:

View File

@ -615,6 +615,51 @@ function IconUnlock({ className, ...props }: React.ComponentProps<'svg'>) {
)
}
function IconHome({ className, ...props }: React.ComponentProps<'svg'>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn('h-4 w-4', className)}
{...props}
>
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
)
}
function IconNetwork({ className, ...props }: React.ComponentProps<'svg'>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn('h-4 w-4', className)}
{...props}
>
<rect x="16" y="16" width="6" height="6" rx="1" />
<rect x="2" y="16" width="6" height="6" rx="1" />
<rect x="9" y="2" width="6" height="6" rx="1" />
<path d="M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3" />
<path d="M12 12V8" />
</svg>
)
}
export {
IconEdit,
IconNextChat,
@ -647,5 +692,7 @@ export {
IconNotice,
IconSymbolFunction,
IconLogout,
IconUnlock
IconUnlock,
IconHome,
IconNetwork
}

117
ee/tabby-ui/components/ui/table.tsx vendored Normal file
View File

@ -0,0 +1,117 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
))
Table.displayName = 'Table'
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
))
TableHeader.displayName = 'TableHeader'
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
))
TableBody.displayName = 'TableBody'
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
className
)}
{...props}
/>
))
TableFooter.displayName = 'TableFooter'
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
className
)}
{...props}
/>
))
TableRow.displayName = 'TableRow'
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
className
)}
{...props}
/>
))
TableHead.displayName = 'TableHead'
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
{...props}
/>
))
TableCell.displayName = 'TableCell'
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn('mt-4 text-sm text-muted-foreground', className)}
{...props}
/>
))
TableCaption.displayName = 'TableCaption'
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption
}

View File

@ -5,7 +5,7 @@ import { graphql } from '@/lib/gql/generates'
import { Worker, WorkerKind } from '@/lib/gql/generates/graphql'
import { useAuthenticatedGraphQLQuery } from '@/lib/tabby/gql'
import type { HealthInfo } from './use-health'
import { useHealth, type HealthInfo } from './use-health'
const modelNameMap: Record<WorkerKind, 'chat_model' | 'model'> = {
[WorkerKind.Chat]: 'chat_model',
@ -43,7 +43,8 @@ export const getAllWorkersDocument = graphql(/* GraphQL */ `
}
`)
function useWorkers(healthInfo?: HealthInfo) {
function useWorkers() {
const { data: healthInfo } = useHealth()
const { data } = useAuthenticatedGraphQLQuery(getAllWorkersDocument)
let workers = data?.workers

View File

@ -30,7 +30,7 @@ export function useGraphQLForm<
) {
const { data } = useSession()
const accessToken = data?.accessToken
const onSubmit = async (variables: TVariables) => {
const onSubmit = async (variables?: TVariables) => {
let res
try {
res = await gqlClient.request({
@ -43,6 +43,7 @@ export function useGraphQLForm<
: undefined
})
} catch (err) {
console.error('err', err)
const { errors = [] } = (err as any).response as GraphQLResponse
for (const error of errors) {
if (error.extensions && error.extensions['validation-errors']) {

View File

@ -43,6 +43,7 @@
"graphql-request": "^6.1.0",
"jwt-decode": "^4.0.0",
"lodash-es": "^4.17.21",
"moment": "^2.29.4",
"nanoid": "^4.0.2",
"next": "^13.4.7",
"next-themes": "^0.2.1",

View File

@ -5250,6 +5250,11 @@ minimist@^1.2.0, minimist@^1.2.6:
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
moment@^2.29.4:
version "2.29.4"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108"
integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==
mri@^1.1.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b"

View File

@ -50,6 +50,10 @@ impl DbConn {
}
pub async fn create_invitation(&self, email: String) -> Result<i32> {
if self.get_user_by_email(&email).await?.is_some() {
return Err(anyhow!("User already registered"));
}
let code = Uuid::new_v4().to_string();
let res = self
.conn
@ -60,9 +64,6 @@ impl DbConn {
Ok(rowid)
})
.await?;
if res != 1 {
return Err(anyhow!("failed to create invitation"));
}
Ok(res as i32)
}