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:
Kurt Hutten
2023-08-22 05:34:20 +10:00
committed by GitHub
parent 25392824cb
commit 965d2b23cf
16 changed files with 370 additions and 203 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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,7 +59,22 @@ export type HomeLoaderData = {
projects: ProjectWithEntryPointMetadata[]
}
const router = createBrowserRouter([
type CreateBrowserRouterArg = Parameters<typeof createBrowserRouter>[0]
const addGlobalContextToElements = (
routes: CreateBrowserRouterArg
): CreateBrowserRouterArg =>
routes.map((route) =>
'element' in route
? {
...route,
element: <GlobalStateProvider>{route.element}</GlobalStateProvider>,
}
: route
)
const router = createBrowserRouter(
addGlobalContextToElements([
{
path: paths.INDEX,
loader: () =>
@ -95,7 +111,9 @@ const router = createBrowserRouter([
notEnRouteToOnboarding && hasValidOnboardingStatus
if (shouldRedirectToOnboarding) {
return redirect(makeUrlPathRelative(paths.ONBOARDING.INDEX) + status)
return redirect(
makeUrlPathRelative(paths.ONBOARDING.INDEX) + status
)
}
}
@ -176,6 +194,7 @@ const router = createBrowserRouter([
element: <SignIn />,
},
])
)
/**
* All routes in the app, used in src/index.tsx

View File

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

View File

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

View File

@ -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',

View 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',
})
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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',

View File

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