Xstate Auth migration (#250)
* auth migrate progress, web only * wrap home in state provider * use consistent logged spelling * use createActorContext * typo * fix wraping problem
This commit is contained in:
		@ -19,6 +19,7 @@
 | 
			
		||||
    "@types/react-dom": "^18.0.0",
 | 
			
		||||
    "@uiw/codemirror-extensions-langs": "^4.21.9",
 | 
			
		||||
    "@uiw/react-codemirror": "^4.15.1",
 | 
			
		||||
    "@xstate/react": "^3.2.2",
 | 
			
		||||
    "crypto-js": "^4.1.1",
 | 
			
		||||
    "formik": "^2.4.3",
 | 
			
		||||
    "http-server": "^14.1.1",
 | 
			
		||||
@ -42,6 +43,7 @@
 | 
			
		||||
    "wasm-pack": "^0.12.1",
 | 
			
		||||
    "web-vitals": "^2.1.0",
 | 
			
		||||
    "ws": "^8.13.0",
 | 
			
		||||
    "xstate": "^4.38.2",
 | 
			
		||||
    "zustand": "^4.1.4"
 | 
			
		||||
  },
 | 
			
		||||
  "scripts": {
 | 
			
		||||
 | 
			
		||||
@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react'
 | 
			
		||||
import { App } from './App'
 | 
			
		||||
import { describe, test, vi } from 'vitest'
 | 
			
		||||
import { BrowserRouter } from 'react-router-dom'
 | 
			
		||||
import { GlobalStateProvider } from './hooks/useAuthMachine'
 | 
			
		||||
 | 
			
		||||
let listener: ((rect: any) => void) | undefined = undefined
 | 
			
		||||
;(global as any).ResizeObserver = class ResizeObserver {
 | 
			
		||||
@ -27,9 +28,9 @@ describe('App tests', () => {
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
    render(
 | 
			
		||||
      <BrowserRouter>
 | 
			
		||||
      <TestWrap>
 | 
			
		||||
        <App />
 | 
			
		||||
      </BrowserRouter>
 | 
			
		||||
      </TestWrap>
 | 
			
		||||
    )
 | 
			
		||||
    const linkElement = screen.getByText(/Variables/i)
 | 
			
		||||
    expect(linkElement).toBeInTheDocument()
 | 
			
		||||
@ -37,3 +38,12 @@ describe('App tests', () => {
 | 
			
		||||
    vi.restoreAllMocks()
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
function TestWrap({ children }: { children: React.ReactNode }) {
 | 
			
		||||
  // wrap in router and xState context
 | 
			
		||||
  return (
 | 
			
		||||
    <BrowserRouter>
 | 
			
		||||
      <GlobalStateProvider>{children}</GlobalStateProvider>
 | 
			
		||||
    </BrowserRouter>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -48,6 +48,7 @@ import { writeTextFile } from '@tauri-apps/api/fs'
 | 
			
		||||
import { PROJECT_ENTRYPOINT } from './lib/tauriFS'
 | 
			
		||||
import { IndexLoaderData } from './Router'
 | 
			
		||||
import { toast } from 'react-hot-toast'
 | 
			
		||||
import { useAuthMachine } from './hooks/useAuthMachine'
 | 
			
		||||
 | 
			
		||||
export function App() {
 | 
			
		||||
  const { code: loadedCode, project } = useLoaderData() as IndexLoaderData
 | 
			
		||||
@ -81,7 +82,6 @@ export function App() {
 | 
			
		||||
    isMouseDownInStream,
 | 
			
		||||
    cmdId,
 | 
			
		||||
    setCmdId,
 | 
			
		||||
    token,
 | 
			
		||||
    formatCode,
 | 
			
		||||
    debugPanel,
 | 
			
		||||
    theme,
 | 
			
		||||
@ -121,7 +121,6 @@ export function App() {
 | 
			
		||||
    isMouseDownInStream: s.isMouseDownInStream,
 | 
			
		||||
    cmdId: s.cmdId,
 | 
			
		||||
    setCmdId: s.setCmdId,
 | 
			
		||||
    token: s.token,
 | 
			
		||||
    formatCode: s.formatCode,
 | 
			
		||||
    debugPanel: s.debugPanel,
 | 
			
		||||
    addKCLError: s.addKCLError,
 | 
			
		||||
@ -134,6 +133,7 @@ export function App() {
 | 
			
		||||
    setStreamDimensions: s.setStreamDimensions,
 | 
			
		||||
    streamDimensions: s.streamDimensions,
 | 
			
		||||
  }))
 | 
			
		||||
  const [token] = useAuthMachine((s) => s?.context?.token)
 | 
			
		||||
 | 
			
		||||
  const editorTheme = theme === Themes.System ? getSystemTheme() : theme
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										33
									
								
								src/Auth.tsx
									
									
									
									
									
								
							
							
						
						
									
										33
									
								
								src/Auth.tsx
									
									
									
									
									
								
							@ -1,38 +1,11 @@
 | 
			
		||||
import useSWR from 'swr'
 | 
			
		||||
import fetcher from './lib/fetcher'
 | 
			
		||||
import withBaseUrl from './lib/withBaseURL'
 | 
			
		||||
import { User, useStore } from './useStore'
 | 
			
		||||
import { useNavigate } from 'react-router-dom'
 | 
			
		||||
import { useEffect } from 'react'
 | 
			
		||||
import { isTauri } from './lib/isTauri'
 | 
			
		||||
import Loading from './components/Loading'
 | 
			
		||||
import { paths } from './Router'
 | 
			
		||||
import { useAuthMachine } from './hooks/useAuthMachine'
 | 
			
		||||
 | 
			
		||||
// Wrapper around protected routes, used in src/Router.tsx
 | 
			
		||||
export const Auth = ({ children }: React.PropsWithChildren) => {
 | 
			
		||||
  const { data: user, isLoading } = useSWR<
 | 
			
		||||
    User | Partial<{ error_code: string }>
 | 
			
		||||
  >(withBaseUrl('/user'), fetcher)
 | 
			
		||||
  const { token, setUser } = useStore((s) => ({
 | 
			
		||||
    token: s.token,
 | 
			
		||||
    setUser: s.setUser,
 | 
			
		||||
  }))
 | 
			
		||||
  const navigate = useNavigate()
 | 
			
		||||
  const [isLoggedIn] = useAuthMachine((s) => s.matches('checkIfLoggedIn'))
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (user && 'id' in user) setUser(user)
 | 
			
		||||
  }, [user, setUser])
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (
 | 
			
		||||
      (isTauri() && !token) ||
 | 
			
		||||
      (!isTauri() && !isLoading && !(user && 'id' in user))
 | 
			
		||||
    ) {
 | 
			
		||||
      navigate(paths.SIGN_IN)
 | 
			
		||||
    }
 | 
			
		||||
  }, [user, token, navigate, isLoading])
 | 
			
		||||
 | 
			
		||||
  return isLoading ? (
 | 
			
		||||
  return isLoggedIn ? (
 | 
			
		||||
    <Loading>Loading KittyCAD Modeling App...</Loading>
 | 
			
		||||
  ) : (
 | 
			
		||||
    <>{children}</>
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										235
									
								
								src/Router.tsx
									
									
									
									
									
								
							
							
						
						
									
										235
									
								
								src/Router.tsx
									
									
									
									
									
								
							@ -24,6 +24,7 @@ import {
 | 
			
		||||
} from './lib/tauriFS'
 | 
			
		||||
import { metadata, type Metadata } from 'tauri-plugin-fs-extra-api'
 | 
			
		||||
import DownloadAppBanner from './components/DownloadAppBanner'
 | 
			
		||||
import { GlobalStateProvider } from './hooks/useAuthMachine'
 | 
			
		||||
 | 
			
		||||
const prependRoutes =
 | 
			
		||||
  (routesObject: Record<string, string>) => (prepend: string) => {
 | 
			
		||||
@ -58,124 +59,142 @@ export type HomeLoaderData = {
 | 
			
		||||
  projects: ProjectWithEntryPointMetadata[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const router = createBrowserRouter([
 | 
			
		||||
  {
 | 
			
		||||
    path: paths.INDEX,
 | 
			
		||||
    loader: () =>
 | 
			
		||||
      isTauri() ? redirect(paths.HOME) : redirect(paths.FILE + '/new'),
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    path: paths.FILE + '/:id',
 | 
			
		||||
    element: (
 | 
			
		||||
      <Auth>
 | 
			
		||||
        <Outlet />
 | 
			
		||||
        <App />
 | 
			
		||||
        {!isTauri() && import.meta.env.PROD && <DownloadAppBanner />}
 | 
			
		||||
      </Auth>
 | 
			
		||||
    ),
 | 
			
		||||
    errorElement: <ErrorPage />,
 | 
			
		||||
    id: paths.FILE,
 | 
			
		||||
    loader: async ({
 | 
			
		||||
      request,
 | 
			
		||||
      params,
 | 
			
		||||
    }): Promise<IndexLoaderData | Response> => {
 | 
			
		||||
      const store = localStorage.getItem('store')
 | 
			
		||||
      if (store === null) {
 | 
			
		||||
        return redirect(paths.ONBOARDING.INDEX)
 | 
			
		||||
      } else {
 | 
			
		||||
        const status = JSON.parse(store).state.onboardingStatus || ''
 | 
			
		||||
        const notEnRouteToOnboarding =
 | 
			
		||||
          !request.url.includes(paths.ONBOARDING.INDEX) &&
 | 
			
		||||
          request.method === 'GET'
 | 
			
		||||
        // '' is the initial state, 'done' and 'dismissed' are the final states
 | 
			
		||||
        const hasValidOnboardingStatus =
 | 
			
		||||
          (status !== undefined && status.length === 0) ||
 | 
			
		||||
          !(status === 'done' || status === 'dismissed')
 | 
			
		||||
        const shouldRedirectToOnboarding =
 | 
			
		||||
          notEnRouteToOnboarding && hasValidOnboardingStatus
 | 
			
		||||
type CreateBrowserRouterArg = Parameters<typeof createBrowserRouter>[0]
 | 
			
		||||
 | 
			
		||||
        if (shouldRedirectToOnboarding) {
 | 
			
		||||
          return redirect(makeUrlPathRelative(paths.ONBOARDING.INDEX) + status)
 | 
			
		||||
const addGlobalContextToElements = (
 | 
			
		||||
  routes: CreateBrowserRouterArg
 | 
			
		||||
): CreateBrowserRouterArg =>
 | 
			
		||||
  routes.map((route) =>
 | 
			
		||||
    'element' in route
 | 
			
		||||
      ? {
 | 
			
		||||
          ...route,
 | 
			
		||||
          element: <GlobalStateProvider>{route.element}</GlobalStateProvider>,
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      : route
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
      if (params.id && params.id !== 'new') {
 | 
			
		||||
        // Note that PROJECT_ENTRYPOINT is hardcoded until we support multiple files
 | 
			
		||||
        const code = await readTextFile(params.id + '/' + PROJECT_ENTRYPOINT)
 | 
			
		||||
        const entrypoint_metadata = await metadata(
 | 
			
		||||
          params.id + '/' + PROJECT_ENTRYPOINT
 | 
			
		||||
        )
 | 
			
		||||
        const children = await readDir(params.id)
 | 
			
		||||
const router = createBrowserRouter(
 | 
			
		||||
  addGlobalContextToElements([
 | 
			
		||||
    {
 | 
			
		||||
      path: paths.INDEX,
 | 
			
		||||
      loader: () =>
 | 
			
		||||
        isTauri() ? redirect(paths.HOME) : redirect(paths.FILE + '/new'),
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      path: paths.FILE + '/:id',
 | 
			
		||||
      element: (
 | 
			
		||||
        <Auth>
 | 
			
		||||
          <Outlet />
 | 
			
		||||
          <App />
 | 
			
		||||
          {!isTauri() && import.meta.env.PROD && <DownloadAppBanner />}
 | 
			
		||||
        </Auth>
 | 
			
		||||
      ),
 | 
			
		||||
      errorElement: <ErrorPage />,
 | 
			
		||||
      id: paths.FILE,
 | 
			
		||||
      loader: async ({
 | 
			
		||||
        request,
 | 
			
		||||
        params,
 | 
			
		||||
      }): Promise<IndexLoaderData | Response> => {
 | 
			
		||||
        const store = localStorage.getItem('store')
 | 
			
		||||
        if (store === null) {
 | 
			
		||||
          return redirect(paths.ONBOARDING.INDEX)
 | 
			
		||||
        } else {
 | 
			
		||||
          const status = JSON.parse(store).state.onboardingStatus || ''
 | 
			
		||||
          const notEnRouteToOnboarding =
 | 
			
		||||
            !request.url.includes(paths.ONBOARDING.INDEX) &&
 | 
			
		||||
            request.method === 'GET'
 | 
			
		||||
          // '' is the initial state, 'done' and 'dismissed' are the final states
 | 
			
		||||
          const hasValidOnboardingStatus =
 | 
			
		||||
            (status !== undefined && status.length === 0) ||
 | 
			
		||||
            !(status === 'done' || status === 'dismissed')
 | 
			
		||||
          const shouldRedirectToOnboarding =
 | 
			
		||||
            notEnRouteToOnboarding && hasValidOnboardingStatus
 | 
			
		||||
 | 
			
		||||
          if (shouldRedirectToOnboarding) {
 | 
			
		||||
            return redirect(
 | 
			
		||||
              makeUrlPathRelative(paths.ONBOARDING.INDEX) + status
 | 
			
		||||
            )
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (params.id && params.id !== 'new') {
 | 
			
		||||
          // Note that PROJECT_ENTRYPOINT is hardcoded until we support multiple files
 | 
			
		||||
          const code = await readTextFile(params.id + '/' + PROJECT_ENTRYPOINT)
 | 
			
		||||
          const entrypoint_metadata = await metadata(
 | 
			
		||||
            params.id + '/' + PROJECT_ENTRYPOINT
 | 
			
		||||
          )
 | 
			
		||||
          const children = await readDir(params.id)
 | 
			
		||||
 | 
			
		||||
          return {
 | 
			
		||||
            code,
 | 
			
		||||
            project: {
 | 
			
		||||
              name: params.id.slice(params.id.lastIndexOf('/') + 1),
 | 
			
		||||
              path: params.id,
 | 
			
		||||
              children,
 | 
			
		||||
              entrypoint_metadata,
 | 
			
		||||
            },
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return {
 | 
			
		||||
          code,
 | 
			
		||||
          project: {
 | 
			
		||||
            name: params.id.slice(params.id.lastIndexOf('/') + 1),
 | 
			
		||||
            path: params.id,
 | 
			
		||||
            children,
 | 
			
		||||
            entrypoint_metadata,
 | 
			
		||||
          },
 | 
			
		||||
          code: '',
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        code: '',
 | 
			
		||||
      }
 | 
			
		||||
      },
 | 
			
		||||
      children: [
 | 
			
		||||
        {
 | 
			
		||||
          path: makeUrlPathRelative(paths.SETTINGS),
 | 
			
		||||
          element: <Settings />,
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          path: makeUrlPathRelative(paths.ONBOARDING.INDEX),
 | 
			
		||||
          element: <Onboarding />,
 | 
			
		||||
          children: onboardingRoutes,
 | 
			
		||||
        },
 | 
			
		||||
      ],
 | 
			
		||||
    },
 | 
			
		||||
    children: [
 | 
			
		||||
      {
 | 
			
		||||
        path: makeUrlPathRelative(paths.SETTINGS),
 | 
			
		||||
        element: <Settings />,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        path: makeUrlPathRelative(paths.ONBOARDING.INDEX),
 | 
			
		||||
        element: <Onboarding />,
 | 
			
		||||
        children: onboardingRoutes,
 | 
			
		||||
      },
 | 
			
		||||
    ],
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    path: paths.HOME,
 | 
			
		||||
    element: (
 | 
			
		||||
      <Auth>
 | 
			
		||||
        <Outlet />
 | 
			
		||||
        <Home />
 | 
			
		||||
      </Auth>
 | 
			
		||||
    ),
 | 
			
		||||
    loader: async () => {
 | 
			
		||||
      if (!isTauri()) {
 | 
			
		||||
        return redirect(paths.FILE + '/new')
 | 
			
		||||
      }
 | 
			
		||||
    {
 | 
			
		||||
      path: paths.HOME,
 | 
			
		||||
      element: (
 | 
			
		||||
        <Auth>
 | 
			
		||||
          <Outlet />
 | 
			
		||||
          <Home />
 | 
			
		||||
        </Auth>
 | 
			
		||||
      ),
 | 
			
		||||
      loader: async () => {
 | 
			
		||||
        if (!isTauri()) {
 | 
			
		||||
          return redirect(paths.FILE + '/new')
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
      const projectDir = await initializeProjectDirectory()
 | 
			
		||||
      const projectsNoMeta = (await readDir(projectDir.dir)).filter(
 | 
			
		||||
        isProjectDirectory
 | 
			
		||||
      )
 | 
			
		||||
      const projects = await Promise.all(
 | 
			
		||||
        projectsNoMeta.map(async (p) => ({
 | 
			
		||||
          entrypoint_metadata: await metadata(
 | 
			
		||||
            p.path + '/' + PROJECT_ENTRYPOINT
 | 
			
		||||
          ),
 | 
			
		||||
          ...p,
 | 
			
		||||
        }))
 | 
			
		||||
      )
 | 
			
		||||
        const projectDir = await initializeProjectDirectory()
 | 
			
		||||
        const projectsNoMeta = (await readDir(projectDir.dir)).filter(
 | 
			
		||||
          isProjectDirectory
 | 
			
		||||
        )
 | 
			
		||||
        const projects = await Promise.all(
 | 
			
		||||
          projectsNoMeta.map(async (p) => ({
 | 
			
		||||
            entrypoint_metadata: await metadata(
 | 
			
		||||
              p.path + '/' + PROJECT_ENTRYPOINT
 | 
			
		||||
            ),
 | 
			
		||||
            ...p,
 | 
			
		||||
          }))
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        projects,
 | 
			
		||||
      }
 | 
			
		||||
        return {
 | 
			
		||||
          projects,
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      children: [
 | 
			
		||||
        {
 | 
			
		||||
          path: makeUrlPathRelative(paths.SETTINGS),
 | 
			
		||||
          element: <Settings />,
 | 
			
		||||
        },
 | 
			
		||||
      ],
 | 
			
		||||
    },
 | 
			
		||||
    children: [
 | 
			
		||||
      {
 | 
			
		||||
        path: makeUrlPathRelative(paths.SETTINGS),
 | 
			
		||||
        element: <Settings />,
 | 
			
		||||
      },
 | 
			
		||||
    ],
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    path: paths.SIGN_IN,
 | 
			
		||||
    element: <SignIn />,
 | 
			
		||||
  },
 | 
			
		||||
])
 | 
			
		||||
    {
 | 
			
		||||
      path: paths.SIGN_IN,
 | 
			
		||||
      element: <SignIn />,
 | 
			
		||||
    },
 | 
			
		||||
  ])
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * All routes in the app, used in src/index.tsx
 | 
			
		||||
 | 
			
		||||
@ -1,8 +1,8 @@
 | 
			
		||||
import { Toolbar } from '../Toolbar'
 | 
			
		||||
import { useStore } from '../useStore'
 | 
			
		||||
import UserSidebarMenu from './UserSidebarMenu'
 | 
			
		||||
import { ProjectWithEntryPointMetadata } from '../Router'
 | 
			
		||||
import ProjectSidebarMenu from './ProjectSidebarMenu'
 | 
			
		||||
import { useAuthMachine } from '../hooks/useAuthMachine'
 | 
			
		||||
 | 
			
		||||
interface AppHeaderProps extends React.PropsWithChildren {
 | 
			
		||||
  showToolbar?: boolean
 | 
			
		||||
@ -18,9 +18,7 @@ export const AppHeader = ({
 | 
			
		||||
  className = '',
 | 
			
		||||
  enableMenu = false,
 | 
			
		||||
}: AppHeaderProps) => {
 | 
			
		||||
  const { user } = useStore((s) => ({
 | 
			
		||||
    user: s.user,
 | 
			
		||||
  }))
 | 
			
		||||
  const [user] = useAuthMachine((s) => s?.context?.user)
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <header
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,10 @@
 | 
			
		||||
import { fireEvent, render, screen } from '@testing-library/react'
 | 
			
		||||
import { User } from '../useStore'
 | 
			
		||||
import UserSidebarMenu from './UserSidebarMenu'
 | 
			
		||||
import { BrowserRouter } from 'react-router-dom'
 | 
			
		||||
import { Models } from '@kittycad/lib'
 | 
			
		||||
import { GlobalStateProvider } from '../hooks/useAuthMachine'
 | 
			
		||||
 | 
			
		||||
type User = Models['User_type']
 | 
			
		||||
 | 
			
		||||
describe('UserSidebarMenu tests', () => {
 | 
			
		||||
  test("Renders user's name and email if available", () => {
 | 
			
		||||
@ -12,12 +15,18 @@ describe('UserSidebarMenu tests', () => {
 | 
			
		||||
      image: 'https://placekitten.com/200/200',
 | 
			
		||||
      created_at: 'yesteryear',
 | 
			
		||||
      updated_at: 'today',
 | 
			
		||||
      company: 'Test Company',
 | 
			
		||||
      discord: 'Test User#1234',
 | 
			
		||||
      github: 'testuser',
 | 
			
		||||
      phone: '555-555-5555',
 | 
			
		||||
      first_name: 'Test',
 | 
			
		||||
      last_name: 'User',
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    render(
 | 
			
		||||
      <BrowserRouter>
 | 
			
		||||
      <TestWrap>
 | 
			
		||||
        <UserSidebarMenu user={userWellFormed} />
 | 
			
		||||
      </BrowserRouter>
 | 
			
		||||
      </TestWrap>
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    fireEvent.click(screen.getByTestId('user-sidebar-toggle'))
 | 
			
		||||
@ -35,12 +44,19 @@ describe('UserSidebarMenu tests', () => {
 | 
			
		||||
      image: 'https://placekitten.com/200/200',
 | 
			
		||||
      created_at: 'yesteryear',
 | 
			
		||||
      updated_at: 'today',
 | 
			
		||||
      company: 'Test Company',
 | 
			
		||||
      discord: 'Test User#1234',
 | 
			
		||||
      github: 'testuser',
 | 
			
		||||
      phone: '555-555-5555',
 | 
			
		||||
      first_name: '',
 | 
			
		||||
      last_name: '',
 | 
			
		||||
      name: '',
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    render(
 | 
			
		||||
      <BrowserRouter>
 | 
			
		||||
      <TestWrap>
 | 
			
		||||
        <UserSidebarMenu user={userNoName} />
 | 
			
		||||
      </BrowserRouter>
 | 
			
		||||
      </TestWrap>
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    fireEvent.click(screen.getByTestId('user-sidebar-toggle'))
 | 
			
		||||
@ -55,14 +71,30 @@ describe('UserSidebarMenu tests', () => {
 | 
			
		||||
      email: 'kittycad.sidebar.test@example.com',
 | 
			
		||||
      created_at: 'yesteryear',
 | 
			
		||||
      updated_at: 'today',
 | 
			
		||||
      company: 'Test Company',
 | 
			
		||||
      discord: 'Test User#1234',
 | 
			
		||||
      github: 'testuser',
 | 
			
		||||
      phone: '555-555-5555',
 | 
			
		||||
      first_name: 'Test',
 | 
			
		||||
      last_name: 'User',
 | 
			
		||||
      image: '',
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    render(
 | 
			
		||||
      <BrowserRouter>
 | 
			
		||||
      <TestWrap>
 | 
			
		||||
        <UserSidebarMenu user={userNoAvatar} />
 | 
			
		||||
      </BrowserRouter>
 | 
			
		||||
      </TestWrap>
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    expect(screen.getByTestId('user-sidebar-toggle')).toHaveTextContent('Menu')
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
function TestWrap({ children }: { children: React.ReactNode }) {
 | 
			
		||||
  // wrap in router and xState context
 | 
			
		||||
  return (
 | 
			
		||||
    <BrowserRouter>
 | 
			
		||||
      <GlobalStateProvider>{children}</GlobalStateProvider>
 | 
			
		||||
    </BrowserRouter>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,4 @@
 | 
			
		||||
import { Popover } from '@headlessui/react'
 | 
			
		||||
import { User, useStore } from '../useStore'
 | 
			
		||||
import { ActionButton } from './ActionButton'
 | 
			
		||||
import { faBars, faGear, faSignOutAlt } from '@fortawesome/free-solid-svg-icons'
 | 
			
		||||
import { faGithub } from '@fortawesome/free-brands-svg-icons'
 | 
			
		||||
@ -7,14 +6,16 @@ import { useNavigate } from 'react-router-dom'
 | 
			
		||||
import { useState } from 'react'
 | 
			
		||||
import { paths } from '../Router'
 | 
			
		||||
import makeUrlPathRelative from '../lib/makeUrlPathRelative'
 | 
			
		||||
import { useAuthMachine } from '../hooks/useAuthMachine'
 | 
			
		||||
import { Models } from '@kittycad/lib'
 | 
			
		||||
 | 
			
		||||
type User = Models['User_type']
 | 
			
		||||
 | 
			
		||||
const UserSidebarMenu = ({ user }: { user?: User }) => {
 | 
			
		||||
  const displayedName = getDisplayName(user)
 | 
			
		||||
  const [imageLoadFailed, setImageLoadFailed] = useState(false)
 | 
			
		||||
  const navigate = useNavigate()
 | 
			
		||||
  const { setToken } = useStore((s) => ({
 | 
			
		||||
    setToken: s.setToken,
 | 
			
		||||
  }))
 | 
			
		||||
  const [_, send] = useAuthMachine()
 | 
			
		||||
 | 
			
		||||
  // Fallback logic for displaying user's "name":
 | 
			
		||||
  // 1. user.name
 | 
			
		||||
@ -119,10 +120,7 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
 | 
			
		||||
              </ActionButton>
 | 
			
		||||
              <ActionButton
 | 
			
		||||
                Element="button"
 | 
			
		||||
                onClick={() => {
 | 
			
		||||
                  setToken('')
 | 
			
		||||
                  navigate(paths.SIGN_IN)
 | 
			
		||||
                }}
 | 
			
		||||
                onClick={() => send('logout')}
 | 
			
		||||
                icon={{
 | 
			
		||||
                  icon: faSignOutAlt,
 | 
			
		||||
                  bgClassName: 'bg-destroy-80',
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										54
									
								
								src/hooks/useAuthMachine.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								src/hooks/useAuthMachine.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,54 @@
 | 
			
		||||
import { createActorContext } from '@xstate/react'
 | 
			
		||||
import { useNavigate } from 'react-router-dom'
 | 
			
		||||
import { paths } from '../Router'
 | 
			
		||||
import { authMachine, TOKEN_PERSIST_KEY } from '../lib/authMachine'
 | 
			
		||||
import withBaseUrl from '../lib/withBaseURL'
 | 
			
		||||
 | 
			
		||||
export const AuthMachineContext = createActorContext(authMachine)
 | 
			
		||||
 | 
			
		||||
export const GlobalStateProvider = ({
 | 
			
		||||
  children,
 | 
			
		||||
}: {
 | 
			
		||||
  children: React.ReactNode
 | 
			
		||||
}) => {
 | 
			
		||||
  const navigate = useNavigate()
 | 
			
		||||
  return (
 | 
			
		||||
    <AuthMachineContext.Provider
 | 
			
		||||
      machine={() =>
 | 
			
		||||
        authMachine.withConfig({
 | 
			
		||||
          actions: {
 | 
			
		||||
            goToSignInPage: () => {
 | 
			
		||||
              navigate(paths.SIGN_IN)
 | 
			
		||||
              logout()
 | 
			
		||||
            },
 | 
			
		||||
            goToIndexPage: () => navigate(paths.INDEX),
 | 
			
		||||
          },
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
    >
 | 
			
		||||
      {children}
 | 
			
		||||
    </AuthMachineContext.Provider>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function useAuthMachine<T>(
 | 
			
		||||
  selector: (
 | 
			
		||||
    state: Parameters<Parameters<typeof AuthMachineContext.useSelector>[0]>[0]
 | 
			
		||||
  ) => T = () => null as T
 | 
			
		||||
): [T, ReturnType<typeof AuthMachineContext.useActor>[1]] {
 | 
			
		||||
  // useActor api normally `[state, send] = useActor`
 | 
			
		||||
  // we're only interested in send because of the selector
 | 
			
		||||
  const send = AuthMachineContext.useActor()[1]
 | 
			
		||||
 | 
			
		||||
  const selection = AuthMachineContext.useSelector(selector)
 | 
			
		||||
  return [selection, send]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function logout() {
 | 
			
		||||
  const url = withBaseUrl('/logout')
 | 
			
		||||
  localStorage.removeItem(TOKEN_PERSIST_KEY)
 | 
			
		||||
  return fetch(url, {
 | 
			
		||||
    method: 'POST',
 | 
			
		||||
    credentials: 'include',
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
@ -363,7 +363,6 @@ export class EngineCommandManager {
 | 
			
		||||
    this.onHoverCallback = callback
 | 
			
		||||
  }
 | 
			
		||||
  onClick(callback: (selection?: SelectionsArgs) => void) {
 | 
			
		||||
    // TODO talk to the gang about this
 | 
			
		||||
    // It's when the user clicks on a part in the 3d scene, and so the engine should tell the
 | 
			
		||||
    // frontend about that (with it's id) so that the FE can put the user's cursor on the right
 | 
			
		||||
    // line of code
 | 
			
		||||
 | 
			
		||||
@ -136,11 +136,6 @@ export const lineTo: SketchLineHelper = {
 | 
			
		||||
      sourceRange,
 | 
			
		||||
      data,
 | 
			
		||||
    })
 | 
			
		||||
    // engineCommandManager.sendModellingCommand({
 | 
			
		||||
    //   id,
 | 
			
		||||
    //   params: [lineData, previousSketch],
 | 
			
		||||
    //   range: sourceRange,
 | 
			
		||||
    // })
 | 
			
		||||
    const currentPath: Path = {
 | 
			
		||||
      type: 'toPoint',
 | 
			
		||||
      to,
 | 
			
		||||
@ -673,11 +668,6 @@ export const angledLine: SketchLineHelper = {
 | 
			
		||||
      sourceRange,
 | 
			
		||||
      data,
 | 
			
		||||
    })
 | 
			
		||||
    // engineCommandManager.sendModellingCommand({
 | 
			
		||||
    //   id,
 | 
			
		||||
    //   params: [lineData, previousSketch],
 | 
			
		||||
    //   range: sourceRange,
 | 
			
		||||
    // })
 | 
			
		||||
    const currentPath: Path = {
 | 
			
		||||
      type: 'toPoint',
 | 
			
		||||
      to,
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										102
									
								
								src/lib/authMachine.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								src/lib/authMachine.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,102 @@
 | 
			
		||||
import { createMachine, assign } from 'xstate'
 | 
			
		||||
import { Models } from '@kittycad/lib'
 | 
			
		||||
import withBaseURL from '../lib/withBaseURL'
 | 
			
		||||
 | 
			
		||||
export interface UserContext {
 | 
			
		||||
  user?: Models['User_type']
 | 
			
		||||
  token?: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type Events =
 | 
			
		||||
  | {
 | 
			
		||||
      type: 'logout'
 | 
			
		||||
    }
 | 
			
		||||
  | {
 | 
			
		||||
      type: 'tryLogin'
 | 
			
		||||
      token?: string
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
export const TOKEN_PERSIST_KEY = 'TOKEN_PERSIST_KEY'
 | 
			
		||||
const persistedToken = localStorage?.getItem(TOKEN_PERSIST_KEY) || ''
 | 
			
		||||
 | 
			
		||||
export const authMachine = createMachine<UserContext, Events>(
 | 
			
		||||
  {
 | 
			
		||||
    id: 'Auth',
 | 
			
		||||
    initial: 'checkIfLoggedIn',
 | 
			
		||||
    states: {
 | 
			
		||||
      checkIfLoggedIn: {
 | 
			
		||||
        id: 'check-if-logged-in',
 | 
			
		||||
        invoke: {
 | 
			
		||||
          src: 'getUser',
 | 
			
		||||
          id: 'check-logged-in',
 | 
			
		||||
          onDone: [
 | 
			
		||||
            {
 | 
			
		||||
              target: 'loggedIn',
 | 
			
		||||
              actions: assign({
 | 
			
		||||
                user: (context, event) => event.data,
 | 
			
		||||
              }),
 | 
			
		||||
            },
 | 
			
		||||
          ],
 | 
			
		||||
          onError: [
 | 
			
		||||
            {
 | 
			
		||||
              target: 'loggedOut',
 | 
			
		||||
              actions: assign({
 | 
			
		||||
                user: () => undefined,
 | 
			
		||||
              }),
 | 
			
		||||
            },
 | 
			
		||||
          ],
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      loggedIn: {
 | 
			
		||||
        entry: ['goToIndexPage'],
 | 
			
		||||
        on: {
 | 
			
		||||
          logout: {
 | 
			
		||||
            target: 'loggedOut',
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      loggedOut: {
 | 
			
		||||
        entry: ['goToSignInPage'],
 | 
			
		||||
        on: {
 | 
			
		||||
          tryLogin: {
 | 
			
		||||
            target: 'checkIfLoggedIn',
 | 
			
		||||
            actions: assign({
 | 
			
		||||
              token: (context, event) => {
 | 
			
		||||
                const token = event.token || ''
 | 
			
		||||
                localStorage.setItem(TOKEN_PERSIST_KEY, token)
 | 
			
		||||
                return token
 | 
			
		||||
              },
 | 
			
		||||
            }),
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    schema: { events: {} as { type: 'logout' } | { type: 'tryLogin' } },
 | 
			
		||||
    predictableActionArguments: true,
 | 
			
		||||
    preserveActionOrder: true,
 | 
			
		||||
    context: { token: persistedToken },
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    actions: {},
 | 
			
		||||
    services: { getUser },
 | 
			
		||||
    guards: {},
 | 
			
		||||
    delays: {},
 | 
			
		||||
  }
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
async function getUser(context: UserContext) {
 | 
			
		||||
  const url = withBaseURL('/user')
 | 
			
		||||
  const headers: { [key: string]: string } = {
 | 
			
		||||
    'Content-Type': 'application/json',
 | 
			
		||||
  }
 | 
			
		||||
  if (!context.token && '__TAURI__' in window) throw 'not log in'
 | 
			
		||||
  if (context.token) headers['Authorization'] = `Bearer ${context.token}`
 | 
			
		||||
  const response = await fetch(url, {
 | 
			
		||||
    method: 'GET',
 | 
			
		||||
    credentials: 'include',
 | 
			
		||||
    headers,
 | 
			
		||||
  })
 | 
			
		||||
  const user = await response.json()
 | 
			
		||||
  if ('error_code' in user) throw new Error(user.message)
 | 
			
		||||
  return user
 | 
			
		||||
}
 | 
			
		||||
@ -1,10 +1,10 @@
 | 
			
		||||
import { useStore } from '../useStore'
 | 
			
		||||
import { useAuthMachine } from '../hooks/useAuthMachine'
 | 
			
		||||
 | 
			
		||||
export default async function fetcher<JSON = any>(
 | 
			
		||||
  input: RequestInfo,
 | 
			
		||||
  init: RequestInit = {}
 | 
			
		||||
): Promise<JSON> {
 | 
			
		||||
  const { token } = useStore.getState()
 | 
			
		||||
  const [token] = useAuthMachine((s) => s?.context?.token)
 | 
			
		||||
  const headers = { ...init.headers } as Record<string, string>
 | 
			
		||||
  if (token) {
 | 
			
		||||
    headers.Authorization = `Bearer ${token}`
 | 
			
		||||
 | 
			
		||||
@ -7,13 +7,14 @@ import { useNavigate } from 'react-router-dom'
 | 
			
		||||
import { VITE_KC_SITE_BASE_URL, VITE_KC_API_BASE_URL } from '../env'
 | 
			
		||||
import { getSystemTheme } from '../lib/getSystemTheme'
 | 
			
		||||
import { paths } from '../Router'
 | 
			
		||||
import { useAuthMachine } from '../hooks/useAuthMachine'
 | 
			
		||||
 | 
			
		||||
const SignIn = () => {
 | 
			
		||||
  const navigate = useNavigate()
 | 
			
		||||
  const { setToken, theme } = useStore((s) => ({
 | 
			
		||||
    setToken: s.setToken,
 | 
			
		||||
  const { theme } = useStore((s) => ({
 | 
			
		||||
    theme: s.theme,
 | 
			
		||||
  }))
 | 
			
		||||
  const [_, send] = useAuthMachine()
 | 
			
		||||
  const appliedTheme = theme === Themes.System ? getSystemTheme() : theme
 | 
			
		||||
  const signInTauri = async () => {
 | 
			
		||||
    // We want to invoke our command to login via device auth.
 | 
			
		||||
@ -21,8 +22,7 @@ const SignIn = () => {
 | 
			
		||||
      const token: string = await invoke('login', {
 | 
			
		||||
        host: VITE_KC_API_BASE_URL,
 | 
			
		||||
      })
 | 
			
		||||
      setToken(token)
 | 
			
		||||
      navigate(paths.INDEX)
 | 
			
		||||
      send({ type: 'tryLogin', token })
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error('login button', error)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -114,20 +114,6 @@ interface DefaultDir {
 | 
			
		||||
 | 
			
		||||
export type PaneType = 'code' | 'variables' | 'debug' | 'kclErrors' | 'logs'
 | 
			
		||||
 | 
			
		||||
// TODO: import real OpenAPI User type from schema
 | 
			
		||||
export interface User {
 | 
			
		||||
  company?: string
 | 
			
		||||
  created_at: string
 | 
			
		||||
  email: string
 | 
			
		||||
  first_name?: string
 | 
			
		||||
  id: string
 | 
			
		||||
  image?: string
 | 
			
		||||
  last_name?: string
 | 
			
		||||
  name?: string
 | 
			
		||||
  phone?: string
 | 
			
		||||
  updated_at: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface StoreState {
 | 
			
		||||
  editorView: EditorView | null
 | 
			
		||||
  setEditorView: (editorView: EditorView) => void
 | 
			
		||||
@ -219,10 +205,6 @@ export interface StoreState {
 | 
			
		||||
    path: string
 | 
			
		||||
  }[]
 | 
			
		||||
  setHomeMenuItems: (items: { name: string; path: string }[]) => void
 | 
			
		||||
  token: string
 | 
			
		||||
  setToken: (token: string) => void
 | 
			
		||||
  user?: User
 | 
			
		||||
  setUser: (user: User | undefined) => void
 | 
			
		||||
  debugPanel: boolean
 | 
			
		||||
  setDebugPanel: (debugPanel: boolean) => void
 | 
			
		||||
}
 | 
			
		||||
@ -423,10 +405,6 @@ export const useStore = create<StoreState>()(
 | 
			
		||||
      setHomeShowMenu: (showHomeMenu) => set({ showHomeMenu }),
 | 
			
		||||
      homeMenuItems: [],
 | 
			
		||||
      setHomeMenuItems: (homeMenuItems) => set({ homeMenuItems }),
 | 
			
		||||
      token: '',
 | 
			
		||||
      setToken: (token) => set({ token }),
 | 
			
		||||
      user: undefined,
 | 
			
		||||
      setUser: (user) => set({ user }),
 | 
			
		||||
      debugPanel: false,
 | 
			
		||||
      setDebugPanel: (debugPanel) => set({ debugPanel }),
 | 
			
		||||
    }),
 | 
			
		||||
@ -441,7 +419,6 @@ export const useStore = create<StoreState>()(
 | 
			
		||||
              'defaultProjectName',
 | 
			
		||||
              'defaultUnitSystem',
 | 
			
		||||
              'defaultBaseUnit',
 | 
			
		||||
              'token',
 | 
			
		||||
              'debugPanel',
 | 
			
		||||
              'onboardingStatus',
 | 
			
		||||
              'theme',
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										17
									
								
								yarn.lock
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								yarn.lock
									
									
									
									
									
								
							@ -2506,6 +2506,14 @@
 | 
			
		||||
    loupe "^2.3.6"
 | 
			
		||||
    pretty-format "^29.5.0"
 | 
			
		||||
 | 
			
		||||
"@xstate/react@^3.2.2":
 | 
			
		||||
  version "3.2.2"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@xstate/react/-/react-3.2.2.tgz#ddf0f9d75e2c19375b1e1b7335e72cb99762aed8"
 | 
			
		||||
  integrity sha512-feghXWLedyq8JeL13yda3XnHPZKwYDN5HPBLykpLeuNpr9178tQd2/3d0NrH6gSd0sG5mLuLeuD+ck830fgzLQ==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    use-isomorphic-layout-effect "^1.1.2"
 | 
			
		||||
    use-sync-external-store "^1.0.0"
 | 
			
		||||
 | 
			
		||||
acorn-jsx@^5.3.2:
 | 
			
		||||
  version "5.3.2"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
 | 
			
		||||
@ -5893,7 +5901,7 @@ use-composed-ref@^1.3.0:
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/use-composed-ref/-/use-composed-ref-1.3.0.tgz#3d8104db34b7b264030a9d916c5e94fbe280dbda"
 | 
			
		||||
  integrity sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ==
 | 
			
		||||
 | 
			
		||||
use-isomorphic-layout-effect@^1.1.1:
 | 
			
		||||
use-isomorphic-layout-effect@^1.1.1, use-isomorphic-layout-effect@^1.1.2:
 | 
			
		||||
  version "1.1.2"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb"
 | 
			
		||||
  integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==
 | 
			
		||||
@ -5905,7 +5913,7 @@ use-latest@^1.2.1:
 | 
			
		||||
  dependencies:
 | 
			
		||||
    use-isomorphic-layout-effect "^1.1.1"
 | 
			
		||||
 | 
			
		||||
use-sync-external-store@1.2.0, use-sync-external-store@^1.2.0:
 | 
			
		||||
use-sync-external-store@1.2.0, use-sync-external-store@^1.0.0, use-sync-external-store@^1.2.0:
 | 
			
		||||
  version "1.2.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
 | 
			
		||||
  integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
 | 
			
		||||
@ -6112,6 +6120,11 @@ ws@^8.13.0:
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/ws/-/ws-8.13.0.tgz#9a9fb92f93cf41512a0735c8f4dd09b8a1211cd0"
 | 
			
		||||
  integrity sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==
 | 
			
		||||
 | 
			
		||||
xstate@^4.38.2:
 | 
			
		||||
  version "4.38.2"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/xstate/-/xstate-4.38.2.tgz#1b74544fc9c8c6c713ba77f81c6017e65aa89804"
 | 
			
		||||
  integrity sha512-Fba/DwEPDLneHT3tbJ9F3zafbQXszOlyCJyQqqdzmtlY/cwE2th462KK48yaANf98jHlP6lJvxfNtN0LFKXPQg==
 | 
			
		||||
 | 
			
		||||
yallist@^3.0.2:
 | 
			
		||||
  version "3.1.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user