feat(webserver): implement optional secure access for dashboard (#983)

* feat: implement optional access secure for login flow

* update

* fix

* remove useless import

* [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-08 12:16:26 +08:00 committed by GitHub
parent 8b97ec5d0c
commit c303c00b68
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 138 additions and 81 deletions

View File

@ -11,7 +11,7 @@ export default function Signup() {
const title = isAdmin ? 'Create an admin account' : 'Create an account'
const description = isAdmin
? 'The admin account has access to invite collaborators and manage Tabby configuration'
? 'After creating an admin account, your instance is secured, and only registered users can access it.'
: 'Fill form below to create your account'
if (isAdmin || invitationCode) {

View File

@ -12,14 +12,8 @@ import { useWorkers } from '@/lib/hooks/use-workers'
import { WorkerKind } from '@/lib/gql/generates/graphql'
import { has } from 'lodash-es'
import { ThemeToggle } from './theme-toggle'
import { useSession } from '@/lib/tabby/auth'
import { useRouter } from 'next/navigation'
import { graphql } from '@/lib/gql/generates'
import { useGraphQLQuery } from '@/lib/tabby/gql'
export function Header() {
useRequireAuth()
const { data } = useHealth()
const workers = useWorkers(data)
const isChatEnabled = has(workers, WorkerKind.Chat)
@ -80,30 +74,3 @@ function isNewVersionAvailable(version?: string, latestRelease?: ReleaseInfo) {
return true
}
}
export const getIsAdminInitialized = graphql(/* GraphQL */ `
query GetIsAdminInitialized {
isAdminInitialized
}
`)
function useRequireAuth() {
const { data, isLoading } = useGraphQLQuery(
getIsAdminInitialized,
undefined,
{}
)
const router = useRouter()
const { status } = useSession()
React.useEffect(() => {
if (isLoading) return
if (status !== 'unauthenticated') return
if (data!.isAdminInitialized) {
router.replace('/auth/signin')
} else {
router.replace('/auth/signup?isAdmin=true')
}
}, [data, isLoading, status])
}

View File

@ -594,6 +594,27 @@ function IconLogout({ className, ...props }: React.ComponentProps<'svg'>) {
)
}
function IconUnlock({ 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 width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 9.9-1" />
</svg>
)
}
export {
IconEdit,
IconNextChat,
@ -625,5 +646,6 @@ export {
IconSlack,
IconNotice,
IconSymbolFunction,
IconLogout
IconLogout,
IconUnlock
}

View File

@ -1,20 +1,53 @@
import { useSession, useSignOut } from '@/lib/tabby/auth'
import {
useAuthenticatedSession,
useIsAdminInitialized,
useSession,
useSignOut
} from '@/lib/tabby/auth'
import { cn } from '@/lib/utils'
import { IconLogout } from './ui/icons'
import { IconLogout, IconUnlock } from './ui/icons'
import Link from 'next/link'
import React from 'react'
export default function UserPanel() {
const { data: session, status } = useSession()
const signOut = useSignOut()
const isAdminInitialized = useIsAdminInitialized()
const Component = isAdminInitialized ? UserInfoPanel : EnableAdminPanel
if (status !== 'authenticated') return
return (
<div className="py-4 flex justify-center text-sm font-medium">
<span className={cn('flex items-center gap-2')}>
<Component className={cn('flex items-center gap-2')} />
</div>
)
}
function UserInfoPanel({ className }: React.ComponentProps<'span'>) {
const session = useAuthenticatedSession()
const signOut = useSignOut()
return (
session && (
<span className={className}>
<span title="Sign out">
<IconLogout className="cursor-pointer" onClick={signOut} />
</span>
{session.email}
</span>
</div>
)
)
}
function EnableAdminPanel({ className }: React.ComponentProps<'span'>) {
return (
<Link
className={cn('cursor-pointer', className)}
title="Authentication is currently not enabled. Click to view details"
href={{
pathname: '/auth/signup',
query: { isAdmin: true }
}}
>
<IconUnlock /> Secure Access
</Link>
)
}

View File

@ -19,12 +19,12 @@ const documents = {
types.TokenAuthDocument,
'\n mutation register(\n $email: String!\n $password1: String!\n $password2: String!\n $invitationCode: String\n ) {\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 GetIsAdminInitialized {\n isAdminInitialized\n }\n':
types.GetIsAdminInitializedDocument,
'\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':
types.RefreshTokenDocument
types.RefreshTokenDocument,
'\n query GetIsAdminInitialized {\n isAdminInitialized\n }\n':
types.GetIsAdminInitializedDocument
}
/**
@ -59,12 +59,6 @@ export function graphql(
export function graphql(
source: '\n mutation register(\n $email: String!\n $password1: String!\n $password2: String!\n $invitationCode: String\n ) {\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(\n $email: String!\n $password1: String!\n $password2: String!\n $invitationCode: String\n ) {\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.
*/
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.
*/
@ -77,6 +71,12 @@ export function graphql(
export function graphql(
source: '\n mutation refreshToken($refreshToken: String!) {\n refreshToken(refreshToken: $refreshToken) {\n accessToken\n refreshToken\n }\n }\n'
): (typeof documents)['\n mutation refreshToken($refreshToken: String!) {\n refreshToken(refreshToken: $refreshToken) {\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.
*/
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] ?? {}

View File

@ -176,15 +176,6 @@ export type RegisterMutation = {
}
}
export type GetIsAdminInitializedQueryVariables = Exact<{
[key: string]: never
}>
export type GetIsAdminInitializedQuery = {
__typename?: 'Query'
isAdminInitialized: boolean
}
export type GetWorkersQueryVariables = Exact<{ [key: string]: never }>
export type GetWorkersQuery = {
@ -215,6 +206,15 @@ export type RefreshTokenMutation = {
}
}
export type GetIsAdminInitializedQueryVariables = Exact<{
[key: string]: never
}>
export type GetIsAdminInitializedQuery = {
__typename?: 'Query'
isAdminInitialized: boolean
}
export const GetRegistrationTokenDocument = {
kind: 'Document',
definitions: [
@ -405,25 +405,6 @@ export const RegisterDocument = {
}
]
} as unknown as DocumentNode<RegisterMutation, RegisterMutationVariables>
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 GetWorkersDocument = {
kind: 'Document',
definitions: [
@ -508,3 +489,22 @@ export const RefreshTokenDocument = {
RefreshTokenMutation,
RefreshTokenMutationVariables
>
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
>

View File

@ -1,8 +1,9 @@
import * as React from 'react'
import { graphql } from '@/lib/gql/generates'
import useInterval from '@/lib/hooks/use-interval'
import { gqlClient } from '@/lib/tabby/gql'
import { gqlClient, useGraphQLQuery } from '@/lib/tabby/gql'
import { jwtDecode } from 'jwt-decode'
import { useRouter } from 'next/navigation'
interface AuthData {
accessToken: string
@ -241,6 +242,40 @@ function useSession(): Session {
}
}
export const getIsAdminInitialized = graphql(/* GraphQL */ `
query GetIsAdminInitialized {
isAdminInitialized
}
`)
function useIsAdminInitialized() {
const { data } = useGraphQLQuery(getIsAdminInitialized)
return data?.isAdminInitialized
}
function useAuthenticatedSession() {
const { data } = useGraphQLQuery(getIsAdminInitialized)
const router = useRouter()
const { data: session, status } = useSession()
React.useEffect(() => {
if (!data?.isAdminInitialized) return
if (status === 'unauthenticated') {
router.replace('/auth/signin')
}
}, [data, status])
return session
}
export type { AuthStore, User, Session }
export { AuthProvider, useSignIn, useSignOut, useSession }
export {
AuthProvider,
useSignIn,
useSignOut,
useSession,
useIsAdminInitialized,
useAuthenticatedSession
}