feat: implement tabby/auth (#969)
* feat: implement tabby/auth * update * [autofix.ci] apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>support-auth-token
parent
3953fb2617
commit
bec96a176a
|
|
@ -5,11 +5,14 @@ import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||||
import { ThemeProviderProps } from 'next-themes/dist/types'
|
import { ThemeProviderProps } from 'next-themes/dist/types'
|
||||||
|
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
|
import { AuthProvider } from '@/lib/tabby/auth'
|
||||||
|
|
||||||
export function Providers({ children, ...props }: ThemeProviderProps) {
|
export function Providers({ children, ...props }: ThemeProviderProps) {
|
||||||
return (
|
return (
|
||||||
<NextThemesProvider {...props}>
|
<NextThemesProvider {...props}>
|
||||||
<TooltipProvider>{children}</TooltipProvider>
|
<TooltipProvider>
|
||||||
|
<AuthProvider>{children}</AuthProvider>
|
||||||
|
</TooltipProvider>
|
||||||
</NextThemesProvider>
|
</NextThemesProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,9 @@ const documents = {
|
||||||
'\n query GetRegistrationToken {\n registrationToken\n }\n':
|
'\n query GetRegistrationToken {\n registrationToken\n }\n':
|
||||||
types.GetRegistrationTokenDocument,
|
types.GetRegistrationTokenDocument,
|
||||||
'\n query GetWorkers {\n workers {\n kind\n name\n addr\n device\n arch\n cpuInfo\n cpuCount\n cudaDevices\n }\n }\n':
|
'\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
|
types.GetWorkersDocument,
|
||||||
|
'\n mutation refreshToken($refreshToken: String!) {\n refreshToken(refreshToken: $refreshToken) {\n accessToken\n refreshToken\n }\n }\n':
|
||||||
|
types.RefreshTokenDocument
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -45,6 +47,12 @@ export function graphql(
|
||||||
export function graphql(
|
export function graphql(
|
||||||
source: '\n query GetWorkers {\n workers {\n kind\n name\n addr\n device\n arch\n cpuInfo\n cpuCount\n cudaDevices\n }\n }\n'
|
source: '\n query GetWorkers {\n workers {\n kind\n name\n addr\n device\n arch\n cpuInfo\n cpuCount\n cudaDevices\n }\n }\n'
|
||||||
): (typeof documents)['\n query GetWorkers {\n workers {\n kind\n name\n addr\n device\n arch\n cpuInfo\n cpuCount\n cudaDevices\n }\n }\n']
|
): (typeof documents)['\n query GetWorkers {\n workers {\n kind\n name\n addr\n device\n arch\n cpuInfo\n cpuCount\n cudaDevices\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 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']
|
||||||
|
|
||||||
export function graphql(source: string) {
|
export function graphql(source: string) {
|
||||||
return (documents as any)[source] ?? {}
|
return (documents as any)[source] ?? {}
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,19 @@ export type GetWorkersQuery = {
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RefreshTokenMutationVariables = Exact<{
|
||||||
|
refreshToken: Scalars['String']['input']
|
||||||
|
}>
|
||||||
|
|
||||||
|
export type RefreshTokenMutation = {
|
||||||
|
__typename?: 'Mutation'
|
||||||
|
refreshToken: {
|
||||||
|
__typename?: 'RefreshTokenResponse'
|
||||||
|
accessToken: string
|
||||||
|
refreshToken: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const GetRegistrationTokenDocument = {
|
export const GetRegistrationTokenDocument = {
|
||||||
kind: 'Document',
|
kind: 'Document',
|
||||||
definitions: [
|
definitions: [
|
||||||
|
|
@ -214,3 +227,55 @@ export const GetWorkersDocument = {
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
} as unknown as DocumentNode<GetWorkersQuery, GetWorkersQueryVariables>
|
} as unknown as DocumentNode<GetWorkersQuery, GetWorkersQueryVariables>
|
||||||
|
export const RefreshTokenDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'mutation',
|
||||||
|
name: { kind: 'Name', value: 'refreshToken' },
|
||||||
|
variableDefinitions: [
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'refreshToken' }
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'refreshToken' },
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'refreshToken' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'refreshToken' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'accessToken' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'refreshToken' } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
} as unknown as DocumentNode<
|
||||||
|
RefreshTokenMutation,
|
||||||
|
RefreshTokenMutationVariables
|
||||||
|
>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
export default function useInterval(
|
||||||
|
callback: () => void,
|
||||||
|
delay: number | null
|
||||||
|
): React.MutableRefObject<number | null> {
|
||||||
|
const savedCallback = React.useRef(callback)
|
||||||
|
const intervalRef = React.useRef<number | null>(null)
|
||||||
|
|
||||||
|
// Remember the latest callback if it changes.
|
||||||
|
React.useEffect(() => {
|
||||||
|
savedCallback.current = callback
|
||||||
|
}, [callback])
|
||||||
|
|
||||||
|
// Set up the interval.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const tick = () => savedCallback.current()
|
||||||
|
|
||||||
|
if (typeof delay === 'number') {
|
||||||
|
intervalRef.current = window.setInterval(tick, delay * 60 * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
window.clearTimeout(intervalRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [delay])
|
||||||
|
|
||||||
|
return intervalRef
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,237 @@
|
||||||
|
import * as React from 'react'
|
||||||
|
import { graphql } from '@/lib/gql/generates'
|
||||||
|
import useInterval from '@/lib/hooks/use-interval'
|
||||||
|
import { gqlClient } from '@/lib/tabby-gql-client'
|
||||||
|
import { jwtDecode } from 'jwt-decode'
|
||||||
|
|
||||||
|
interface AuthData {
|
||||||
|
accessToken: string
|
||||||
|
refreshToken: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthState =
|
||||||
|
| {
|
||||||
|
status: 'authenticated'
|
||||||
|
data: AuthData
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'loading' | 'unauthenticated'
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AuthActionType {
|
||||||
|
Init,
|
||||||
|
SignIn,
|
||||||
|
SignOut,
|
||||||
|
Refresh
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InitAction {
|
||||||
|
type: AuthActionType.Init
|
||||||
|
data: AuthData | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SignInAction {
|
||||||
|
type: AuthActionType.SignIn
|
||||||
|
data: AuthData
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SignOutAction {
|
||||||
|
type: AuthActionType.SignOut
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RefreshAction {
|
||||||
|
type: AuthActionType.Refresh
|
||||||
|
data: AuthData
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthActions = InitAction | SignInAction | SignOutAction | RefreshAction
|
||||||
|
|
||||||
|
function authReducer(state: AuthState, action: AuthActions): AuthState {
|
||||||
|
switch (action.type) {
|
||||||
|
case AuthActionType.Init:
|
||||||
|
case AuthActionType.SignIn:
|
||||||
|
case AuthActionType.Refresh:
|
||||||
|
if (action.data) {
|
||||||
|
return {
|
||||||
|
status: 'authenticated',
|
||||||
|
data: action.data
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
status: 'unauthenticated',
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case AuthActionType.SignOut:
|
||||||
|
TokenStorage.reset()
|
||||||
|
return {
|
||||||
|
status: 'unauthenticated',
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TokenStorage {
|
||||||
|
static authName = '_tabby_auth'
|
||||||
|
|
||||||
|
initialState(): AuthData | null {
|
||||||
|
const authData = localStorage.getItem(TokenStorage.authName)
|
||||||
|
if (authData) {
|
||||||
|
return JSON.parse(authData)
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
persist(state: AuthData) {
|
||||||
|
localStorage.setItem(TokenStorage.authName, JSON.stringify(state))
|
||||||
|
}
|
||||||
|
|
||||||
|
static reset() {
|
||||||
|
localStorage.removeItem(TokenStorage.authName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthProviderProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthStore {
|
||||||
|
authState: AuthState | null
|
||||||
|
dispatch: React.Dispatch<AuthActions>
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = React.createContext<AuthStore | null>(null)
|
||||||
|
|
||||||
|
const refreshTokenMutation = graphql(/* GraphQL */ `
|
||||||
|
mutation refreshToken($refreshToken: String!) {
|
||||||
|
refreshToken(refreshToken: $refreshToken) {
|
||||||
|
accessToken
|
||||||
|
refreshToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
async function doRefresh(token: string, dispatch: React.Dispatch<AuthActions>) {
|
||||||
|
let action: AuthActions
|
||||||
|
try {
|
||||||
|
action = {
|
||||||
|
type: AuthActionType.Refresh,
|
||||||
|
data: (
|
||||||
|
await gqlClient.request(refreshTokenMutation, { refreshToken: token })
|
||||||
|
).refreshToken
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to refresh token', err)
|
||||||
|
action = {
|
||||||
|
type: AuthActionType.SignOut
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(action)
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthProvider: React.FunctionComponent<AuthProviderProps> = ({
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
|
const storage = new TokenStorage()
|
||||||
|
|
||||||
|
const [authState, dispatch] = React.useReducer(authReducer, {
|
||||||
|
status: 'loading',
|
||||||
|
data: null
|
||||||
|
})
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const data = storage.initialState()
|
||||||
|
if (data?.refreshToken) {
|
||||||
|
doRefresh(data.refreshToken, dispatch)
|
||||||
|
} else {
|
||||||
|
dispatch({ type: AuthActionType.Init, data: null })
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
authState.data && storage.persist(authState.data)
|
||||||
|
}, [authState])
|
||||||
|
|
||||||
|
useInterval(async () => {
|
||||||
|
if (authState.status !== 'authenticated') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await doRefresh(authState.data.refreshToken, dispatch)
|
||||||
|
}, 5)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ authState, dispatch }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useAuthStore(): AuthStore {
|
||||||
|
const context = React.useContext(AuthContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
'AuthProvider is missing. Please add the AuthProvider at root level'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSignIn(): (params: AuthData) => Promise<boolean> {
|
||||||
|
const { dispatch } = useAuthStore()
|
||||||
|
return async data => {
|
||||||
|
dispatch({
|
||||||
|
type: AuthActionType.SignIn,
|
||||||
|
data
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSignOut(): () => Promise<void> {
|
||||||
|
const { dispatch } = useAuthStore()
|
||||||
|
return async () => {
|
||||||
|
dispatch({ type: AuthActionType.SignOut })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
email: string
|
||||||
|
isAdmin: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type Session =
|
||||||
|
| {
|
||||||
|
data: null
|
||||||
|
status: 'loading' | 'unauthenticated'
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
data: User
|
||||||
|
status: 'authenticated'
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSession(): Session {
|
||||||
|
const { authState } = useAuthStore()
|
||||||
|
if (authState?.status == 'authenticated') {
|
||||||
|
const { user } = jwtDecode<{ user: User }>(authState.data.accessToken)
|
||||||
|
return {
|
||||||
|
data: user,
|
||||||
|
status: authState.status
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
status: authState?.status ?? 'loading',
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { AuthStore, User, Session }
|
||||||
|
|
||||||
|
export { AuthProvider, useSignIn, useSignOut, useSession }
|
||||||
|
|
@ -40,6 +40,7 @@
|
||||||
"focus-trap-react": "^10.1.1",
|
"focus-trap-react": "^10.1.1",
|
||||||
"graphql": "^16.8.1",
|
"graphql": "^16.8.1",
|
||||||
"graphql-request": "^6.1.0",
|
"graphql-request": "^6.1.0",
|
||||||
|
"jwt-decode": "^4.0.0",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"nanoid": "^4.0.2",
|
"nanoid": "^4.0.2",
|
||||||
"next": "^13.4.7",
|
"next": "^13.4.7",
|
||||||
|
|
|
||||||
|
|
@ -4422,6 +4422,11 @@ jsonify@^0.0.1:
|
||||||
object.assign "^4.1.4"
|
object.assign "^4.1.4"
|
||||||
object.values "^1.1.6"
|
object.values "^1.1.6"
|
||||||
|
|
||||||
|
jwt-decode@^4.0.0:
|
||||||
|
version "4.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-4.0.0.tgz#2270352425fd413785b2faf11f6e755c5151bd4b"
|
||||||
|
integrity sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==
|
||||||
|
|
||||||
katex@^0.16.0:
|
katex@^0.16.0:
|
||||||
version "0.16.8"
|
version "0.16.8"
|
||||||
resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.8.tgz#89b453f40e8557f423f31a1009e9298dd99d5ceb"
|
resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.8.tgz#89b453f40e8557f423f31a1009e9298dd99d5ceb"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue