feat: Add setup page (#3476)

This commit is contained in:
Bruno Quaresma
2022-08-11 17:22:46 +00:00
committed by GitHub
parent 604f211674
commit 4a17e0d91f
15 changed files with 624 additions and 124 deletions
+2 -2
View File
@@ -1,10 +1,10 @@
import axios from "axios"
import { postFirstUser } from "../src/api/api"
import { createFirstUser } from "../src/api/api"
import * as constants from "./constants"
const globalSetup = async (): Promise<void> => {
axios.defaults.baseURL = `http://localhost:${constants.basePort}`
await postFirstUser({
await createFirstUser({
email: constants.email,
organization: constants.organization,
username: constants.username,
+2
View File
@@ -1,4 +1,5 @@
import { useSelector } from "@xstate/react"
import { SetupPage } from "pages/SetupPage/SetupPage"
import { FC, lazy, Suspense, useContext } from "react"
import { Navigate, Route, Routes } from "react-router-dom"
import { selectPermissions } from "xServices/auth/authSelectors"
@@ -47,6 +48,7 @@ export const AppRouter: FC = () => {
/>
<Route path="login" element={<LoginPage />} />
<Route path="setup" element={<SetupPage />} />
<Route path="healthz" element={<HealthzPage />} />
<Route
path="cli-auth"
+18 -1
View File
@@ -282,7 +282,24 @@ export const suspendUser = async (userId: TypesGen.User["id"]): Promise<TypesGen
return response.data
}
export const postFirstUser = async (
// API definition:
// https://github.com/coder/coder/blob/db665e7261f3c24a272ccec48233a3e276878239/coderd/users.go#L33-L53
export const hasFirstUser = async (): Promise<boolean> => {
try {
// If it is success, it is true
await axios.get("/api/v2/users/first")
return true
} catch (error) {
// If it returns a 404, it is false
if (axios.isAxiosError(error) && error.response?.status === 404) {
return false
}
throw error
}
}
export const createFirstUser = async (
req: TypesGen.CreateFirstUserRequest,
): Promise<TypesGen.CreateFirstUserResponse> => {
const response = await axios.post(`/api/v2/users/first`, req)
@@ -0,0 +1,35 @@
import { makeStyles } from "@material-ui/core/styles"
import { FC } from "react"
import { Footer } from "../../components/Footer/Footer"
export const useStyles = makeStyles((theme) => ({
root: {
height: "100vh",
display: "flex",
justifyContent: "center",
alignItems: "center",
},
layout: {
display: "flex",
flexDirection: "column",
alignItems: "center",
},
container: {
marginTop: theme.spacing(-8),
minWidth: "320px",
maxWidth: "320px",
},
}))
export const SignInLayout: FC = ({ children }) => {
const styles = useStyles()
return (
<div className={styles.root}>
<div className={styles.layout}>
<div className={styles.container}>{children}</div>
<Footer />
</div>
</div>
)
}
+10 -2
View File
@@ -3,7 +3,15 @@ import Typography from "@material-ui/core/Typography"
import { FC } from "react"
import { CoderIcon } from "../Icons/CoderIcon"
export const Welcome: FC = () => {
const Language = {
defaultMessage: (
<>
Welcome to <strong>Coder</strong>
</>
),
}
export const Welcome: FC<{ message?: JSX.Element }> = ({ message = Language.defaultMessage }) => {
const styles = useStyles()
return (
@@ -12,7 +20,7 @@ export const Welcome: FC = () => {
<CoderIcon className={styles.logo} />
</div>
<Typography className={styles.title} variant="h1">
Welcome to <strong>Coder</strong>
{message}
</Typography>
</div>
)
+16 -1
View File
@@ -1,4 +1,4 @@
import { act, screen } from "@testing-library/react"
import { act, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { rest } from "msw"
import { Language } from "../../components/SignInForm/SignInForm"
@@ -89,4 +89,19 @@ describe("LoginPage", () => {
await screen.findByText(Language.passwordSignIn)
await screen.findByText(Language.githubSignIn)
})
it("redirects to the setup page if there is no first user", async () => {
// Given
server.use(
rest.get("/api/v2/users/first", async (req, res, ctx) => {
return res(ctx.status(404))
}),
)
// When
render(<LoginPage />)
// Then
await waitFor(() => expect(history.location.pathname).toEqual("/setup"))
})
})
+18 -44
View File
@@ -1,39 +1,18 @@
import { makeStyles } from "@material-ui/core/styles"
import { useActor } from "@xstate/react"
import { SignInLayout } from "components/SignInLayout/SignInLayout"
import React, { useContext } from "react"
import { Helmet } from "react-helmet"
import { Navigate, useLocation } from "react-router-dom"
import { Footer } from "../../components/Footer/Footer"
import { LoginErrors, SignInForm } from "../../components/SignInForm/SignInForm"
import { pageTitle } from "../../util/page"
import { retrieveRedirect } from "../../util/redirect"
import { XServiceContext } from "../../xServices/StateContext"
export const useStyles = makeStyles((theme) => ({
root: {
height: "100vh",
display: "flex",
justifyContent: "center",
alignItems: "center",
},
layout: {
display: "flex",
flexDirection: "column",
alignItems: "center",
},
container: {
marginTop: theme.spacing(-8),
minWidth: "320px",
maxWidth: "320px",
},
}))
interface LocationState {
isRedirect: boolean
}
export const LoginPage: React.FC = () => {
const styles = useStyles()
const location = useLocation()
const xServices = useContext(XServiceContext)
const [authState, authSend] = useActor(xServices.authXService)
@@ -41,40 +20,35 @@ export const LoginPage: React.FC = () => {
const redirectTo = retrieveRedirect(location.search)
const locationState = location.state ? (location.state as LocationState) : null
const isRedirected = locationState ? locationState.isRedirect : false
const { authError, getUserError, checkPermissionsError, getMethodsError } = authState.context
const onSubmit = async ({ email, password }: { email: string; password: string }) => {
authSend({ type: "SIGN_IN", email, password })
}
const { authError, getUserError, checkPermissionsError, getMethodsError } = authState.context
if (authState.matches("signedIn")) {
return <Navigate to={redirectTo} replace />
} else {
return (
<div className={styles.root}>
<>
<Helmet>
<title>{pageTitle("Login")}</title>
</Helmet>
<div className={styles.layout}>
<div className={styles.container}>
<SignInForm
authMethods={authState.context.methods}
redirectTo={redirectTo}
isLoading={isLoading}
loginErrors={{
[LoginErrors.AUTH_ERROR]: authError,
[LoginErrors.GET_USER_ERROR]: isRedirected ? getUserError : null,
[LoginErrors.CHECK_PERMISSIONS_ERROR]: checkPermissionsError,
[LoginErrors.GET_METHODS_ERROR]: getMethodsError,
}}
onSubmit={onSubmit}
/>
</div>
<Footer />
</div>
</div>
<SignInLayout>
<SignInForm
authMethods={authState.context.methods}
redirectTo={redirectTo}
isLoading={isLoading}
loginErrors={{
[LoginErrors.AUTH_ERROR]: authError,
[LoginErrors.GET_USER_ERROR]: isRedirected ? getUserError : null,
[LoginErrors.CHECK_PERMISSIONS_ERROR]: checkPermissionsError,
[LoginErrors.GET_METHODS_ERROR]: getMethodsError,
}}
onSubmit={onSubmit}
/>
</SignInLayout>
</>
)
}
}
@@ -0,0 +1,99 @@
import { screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import * as API from "api/api"
import { rest } from "msw"
import { history, MockUser, render } from "testHelpers/renderHelpers"
import { server } from "testHelpers/server"
import { Language as SetupLanguage } from "xServices/setup/setupXService"
import { SetupPage } from "./SetupPage"
import { Language as PageViewLanguage } from "./SetupPageView"
const fillForm = async ({
username = "someuser",
email = "someone@coder.com",
password = "password",
organization = "Coder",
}: {
username?: string
email?: string
password?: string
organization?: string
} = {}) => {
const usernameField = screen.getByLabelText(PageViewLanguage.usernameLabel)
const emailField = screen.getByLabelText(PageViewLanguage.emailLabel)
const passwordField = screen.getByLabelText(PageViewLanguage.passwordLabel)
const organizationField = screen.getByLabelText(PageViewLanguage.organizationLabel)
await userEvent.type(organizationField, organization)
await userEvent.type(usernameField, username)
await userEvent.type(emailField, email)
await userEvent.type(passwordField, password)
const submitButton = screen.getByRole("button", { name: PageViewLanguage.create })
submitButton.click()
}
describe("Setup Page", () => {
beforeEach(() => {
history.replace("/setup")
// appear logged out
server.use(
rest.get("/api/v2/users/me", (req, res, ctx) => {
return res(ctx.status(401), ctx.json({ message: "no user here" }))
}),
)
})
it("shows validation error message", async () => {
render(<SetupPage />)
await fillForm({ email: "test" })
const errorMessage = await screen.findByText(PageViewLanguage.emailInvalid)
expect(errorMessage).toBeDefined()
})
it("shows generic error message", async () => {
jest.spyOn(API, "createFirstUser").mockRejectedValueOnce({
data: "unknown error",
})
render(<SetupPage />)
await fillForm()
const errorMessage = await screen.findByText(SetupLanguage.createFirstUserError)
expect(errorMessage).toBeDefined()
})
it("shows API error message", async () => {
const fieldErrorMessage = "invalid username"
server.use(
rest.post("/api/v2/users/first", async (req, res, ctx) => {
return res(
ctx.status(400),
ctx.json({
message: "invalid field",
validations: [
{
detail: fieldErrorMessage,
field: "username",
},
],
}),
)
}),
)
render(<SetupPage />)
await fillForm()
const errorMessage = await screen.findByText(fieldErrorMessage)
expect(errorMessage).toBeDefined()
})
it("redirects to workspaces page when success", async () => {
render(<SetupPage />)
// simulates the user will be authenticated
server.use(
rest.get("/api/v2/users/me", (req, res, ctx) => {
return res(ctx.status(200), ctx.json(MockUser))
}),
)
await fillForm()
await waitFor(() => expect(history.location.pathname).toEqual("/workspaces"))
})
})
+47
View File
@@ -0,0 +1,47 @@
import { useActor, useMachine } from "@xstate/react"
import { FC, useContext, useEffect } from "react"
import { Helmet } from "react-helmet"
import { useNavigate } from "react-router-dom"
import { pageTitle } from "util/page"
import { setupMachine } from "xServices/setup/setupXService"
import { XServiceContext } from "xServices/StateContext"
import { SetupPageView } from "./SetupPageView"
export const SetupPage: FC = () => {
const navigate = useNavigate()
const xServices = useContext(XServiceContext)
const [authState, authSend] = useActor(xServices.authXService)
const [setupState, setupSend] = useMachine(setupMachine, {
actions: {
onCreateFirstUser: ({ firstUser }) => {
if (!firstUser) {
throw new Error("First user was not defined.")
}
authSend({ type: "SIGN_IN", email: firstUser.email, password: firstUser.password })
},
},
})
const { createFirstUserFormErrors, createFirstUserErrorMessage } = setupState.context
useEffect(() => {
if (authState.matches("signedIn")) {
return navigate("/workspaces")
}
}, [authState, navigate])
return (
<>
<Helmet>
<title>{pageTitle("Set up your account")}</title>
</Helmet>
<SetupPageView
isLoading={setupState.hasTag("loading")}
formErrors={createFirstUserFormErrors}
genericError={createFirstUserErrorMessage}
onSubmit={(firstUser) => {
setupSend({ type: "CREATE_FIRST_USER", firstUser })
}}
/>
</>
)
}
@@ -0,0 +1,40 @@
import { action } from "@storybook/addon-actions"
import { Story } from "@storybook/react"
import { SetupPageView, SetupPageViewProps } from "./SetupPageView"
export default {
title: "pages/SetupPageView",
component: SetupPageView,
}
const Template: Story<SetupPageViewProps> = (args: SetupPageViewProps) => (
<SetupPageView {...args} />
)
export const Ready = Template.bind({})
Ready.args = {
onSubmit: action("submit"),
isCreating: false,
}
export const UnknownError = Template.bind({})
UnknownError.args = {
onSubmit: action("submit"),
isCreating: false,
genericError: "Something went wrong",
}
export const FormError = Template.bind({})
FormError.args = {
onSubmit: action("submit"),
isCreating: false,
formErrors: {
username: "Username taken",
},
}
export const Loading = Template.bind({})
Loading.args = {
onSubmit: action("submit"),
isCreating: true,
}
+108
View File
@@ -0,0 +1,108 @@
import FormHelperText from "@material-ui/core/FormHelperText"
import TextField from "@material-ui/core/TextField"
import { LoadingButton } from "components/LoadingButton/LoadingButton"
import { SignInLayout } from "components/SignInLayout/SignInLayout"
import { Stack } from "components/Stack/Stack"
import { Welcome } from "components/Welcome/Welcome"
import { FormikContextType, FormikErrors, useFormik } from "formik"
import { getFormHelpers, nameValidator, onChangeTrimmed } from "util/formUtils"
import * as Yup from "yup"
import * as TypesGen from "../../api/typesGenerated"
export const Language = {
emailLabel: "Email",
passwordLabel: "Password",
usernameLabel: "Username",
organizationLabel: "Organization name",
emailInvalid: "Please enter a valid email address.",
emailRequired: "Please enter an email address.",
passwordRequired: "Please enter a password.",
organizationRequired: "Please enter an organization name.",
create: "Setup account",
welcomeMessage: (
<>
Set up <strong>your account</strong>
</>
),
}
const validationSchema = Yup.object({
email: Yup.string().trim().email(Language.emailInvalid).required(Language.emailRequired),
password: Yup.string().required(Language.passwordRequired),
organization: Yup.string().required(Language.organizationRequired),
username: nameValidator(Language.usernameLabel),
})
export interface SetupPageViewProps {
onSubmit: (firstUser: TypesGen.CreateFirstUserRequest) => void
formErrors?: FormikErrors<TypesGen.CreateFirstUserRequest>
genericError?: string
isLoading?: boolean
}
export const SetupPageView: React.FC<SetupPageViewProps> = ({
onSubmit,
formErrors,
genericError,
isLoading,
}) => {
const form: FormikContextType<TypesGen.CreateFirstUserRequest> =
useFormik<TypesGen.CreateFirstUserRequest>({
initialValues: {
email: "",
password: "",
username: "",
organization: "",
},
validationSchema,
onSubmit,
})
const getFieldHelpers = getFormHelpers<TypesGen.CreateFirstUserRequest>(form, formErrors)
return (
<SignInLayout>
<Welcome message={Language.welcomeMessage} />
<form onSubmit={form.handleSubmit}>
<Stack>
<TextField
{...getFieldHelpers("organization")}
onChange={onChangeTrimmed(form)}
autoFocus
fullWidth
label={Language.organizationLabel}
variant="outlined"
/>
<TextField
{...getFieldHelpers("username")}
onChange={onChangeTrimmed(form)}
autoComplete="username"
fullWidth
label={Language.usernameLabel}
variant="outlined"
/>
<TextField
{...getFieldHelpers("email")}
onChange={onChangeTrimmed(form)}
autoComplete="email"
fullWidth
label={Language.emailLabel}
variant="outlined"
/>
<TextField
{...getFieldHelpers("password")}
autoComplete="current-password"
fullWidth
id="password"
label={Language.passwordLabel}
type="password"
variant="outlined"
/>
{genericError && <FormHelperText error>{genericError}</FormHelperText>}
<LoadingButton fullWidth variant="contained" loading={isLoading} type="submit">
{Language.create}
</LoadingButton>
</Stack>
</form>
</SignInLayout>
)
}
+8
View File
@@ -87,6 +87,14 @@ export const handlers = [
return res(ctx.status(200), ctx.json(M.MockWorkspace))
}),
// First user
rest.get("/api/v2/users/first", async (req, res, ctx) => {
return res(ctx.status(200))
}),
rest.post("/api/v2/users/first", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockUser))
}),
// workspaces
rest.get("/api/v2/workspaces", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json([M.MockWorkspace]))
+6 -1
View File
@@ -29,11 +29,16 @@ export const XServiceProvider: React.FC = ({ children }) => {
const redirectToUsersPage = () => {
navigate("users")
}
const redirectToSetupPage = () => {
navigate("setup")
}
return (
<XServiceContext.Provider
value={{
authXService: useInterpret(authMachine),
authXService: useInterpret(() =>
authMachine.withConfig({ actions: { redirectToSetupPage } }),
),
buildInfoXService: useInterpret(buildInfoMachine),
usersXService: useInterpret(() =>
usersMachine.withConfig({ actions: { redirectToUsersPage } }),
+107 -73
View File
@@ -80,75 +80,8 @@ export type AuthEvent =
| { type: "CONFIRM_REGENERATE_SSH_KEY" }
| { type: "CANCEL_REGENERATE_SSH_KEY" }
const sshState = {
initial: "idle",
states: {
idle: {
on: {
GET_SSH_KEY: {
target: "gettingSSHKey",
},
},
},
gettingSSHKey: {
entry: "clearGetSSHKeyError",
invoke: {
src: "getSSHKey",
onDone: [
{
actions: ["assignSSHKey"],
target: "#authState.signedIn.ssh.loaded",
},
],
onError: [
{
actions: "assignGetSSHKeyError",
target: "#authState.signedIn.ssh.idle",
},
],
},
},
loaded: {
initial: "idle",
states: {
idle: {
on: {
REGENERATE_SSH_KEY: {
target: "confirmSSHKeyRegenerate",
},
},
},
confirmSSHKeyRegenerate: {
on: {
CANCEL_REGENERATE_SSH_KEY: "idle",
CONFIRM_REGENERATE_SSH_KEY: "regeneratingSSHKey",
},
},
regeneratingSSHKey: {
entry: "clearRegenerateSSHKeyError",
invoke: {
src: "regenerateSSHKey",
onDone: [
{
actions: ["assignSSHKey", "notifySuccessSSHKeyRegenerated"],
target: "#authState.signedIn.ssh.loaded.idle",
},
],
onError: [
{
actions: ["assignRegenerateSSHKeyError"],
target: "#authState.signedIn.ssh.loaded.idle",
},
],
},
},
},
},
},
}
export const authMachine =
/** @xstate-layout N4IgpgJg5mDOIC5QEMCuAXAFgZXc9YAdLAJZQB2kA8hgMTYCSA4gHID6DLioADgPal0JPuW4gAHogDsABgCshOQA4AzABY5ARgBMUgJxrtcgDQgAnok0zNSwkpkrNKvQDY3aqS6kBfb6bRYuPhEpBQk5FAM5LQQIkThAG58ANYhZORRYvyCwqJIEohyMlKE2mrFKo7aLq5SJuaILtq2mjZqrboy2s4+fiABOHgExOnhkdFgAE6TfJOEPAA2+ABmswC2IxSZ+dkkQiJikgiyCsrqWroGRqYWCHoq2oQyDpouXa1qGmq+-hiDwYQYOghBEAKqwKYxOKERIpIhAgCyYCyAj2uUOiF0mieTik3UqMhqSleN0QhgUOhUbzUKhk9jKch+-T+QWGQJBUHBkKmMzmixW60BYHQSJROQO+SOUmxVKUniUcjkUgVLjlpOOCqecj0LyKmmVeiUTIGrPhwo5SKwfAgsChlBh5CSqSFIuFmGt8B2qP2eVARxUeNKCo02k0cllTRc6qUalsCtU731DikvV+gSGZuBY0t7pttB5s3mS3Qq0mG0Rbo9YrREr9iHUMkIUnKHSklQVKnqtz0BkImjUbmeShcVmejL6Jozm0oECi8xmyxIC3iEGXtFBAAUACIAQQAKgBRNgbgBKVAAYgwADIH6s+jEIOV6Qgj1oxnTBkfq7qNlxyT4aC4saaPcVLGiyU6hDOc48AuS5EKgPAQPgYwbnBa6xPasLOpOAJQZAMHoQhSEoREaF8Iuy4ILCADGKEiAA2jIAC6d7opKiBPi+rRtB+-5fg0CDqM+YafG8+jWLI2jgemeHpAR5DzhR8GEIhyEcuRlFgPm0yFvyJaCrhwz4bOimwcpy6qSRGlEdRjp8HRPpMaxXrir6BSPvo3Fvu0zT8Zo6oDmoTb-p8cjaFcsjqDJ-zGfJpn0Mw7BUKCe5sbWHm6CU2jWKGnhaFSeLqv2jZKMOehOE49i0v+MWmtOYw0OgdrxPZzpQU16XuUcAC0-aPGGSgVQOTS4ko2jfjGhC0gB1WeDIBguHVkGjBETU6byRYCmW06da5NbdZiKalLl+p-k4XgTYJNhxuVNKGCB77fEy5DWnAYhGWkFDUBgXUPoqLiKOoxIqGVejNKoxXPCUMgjZ8zj6sORoThBclhBE2y8N67F1o+xIvhoejavqEX-gFgl6IGFWOC2bzWNqy0AuyYxcpMf0cQghjqn+JT6GJeJynIqrI2msWZhalY2uzuN9dojwpn+epDvqHjRkUL7KBDEljiLzKyXF32mUpWkwquRCvQeuls-t94c606s8c0A5C00UjqqDja5cUXQRXiDiMwb0FmURpuWQW1tY25D7242jsxn+bi6IFhh2K4qjKP+fsqAHX1B8bKkkGb0seR0gNx87idu4JtIwzoxTdELzbheOov1SZhEWcR6moURxdHCOgPhsBoNDRDKju84hCfHS4ZAXPqg59OCn58ufeFODQPD2DY-FUqGsASmdShgvKP67nClrwgQu2EPIPb2V4+CVNf7w5TOphs22en2LDVrb9Ns4w8j1coU8iafDKJVWQagDDqmOsOKBVhXDgweNJb+ppL59TcH2ZQw1E5jSurcYK8CHCODfE0B4vRfBAA */
/** @xstate-layout N4IgpgJg5mDOIC5QEMCuAXAFgZXc9YAdLAJZQB2kA8hgMTYCSA4gHID6DLioADgPal0JPuW4gAHogBsATimEAzDIAcAFgUB2VTJ26ANCACeiAIwaADKsLKFtu-dsBfRwbRZc+IqQolyUBuS0ECJEvgBufADWXmTkAWL8gsKiSBKICubKhKpSyhoArAbGCGaqGoRSlVXVlfnOrhg4eATEsb7+gWAATl18XYQ8ADb4AGZ9ALatFPGpiSRCImKSCLLySmqa2ro6RaYFAEzWDscK9SBuTZ6EMOhCfgCqsN1BIYThUUQ3ALJgCQLzySW6Uy2VyBV2JXyUhMFRqcLqLnOjQ8LRudygj2e3V6-SGowm1zA6B+fySi1Sy32MnyhHyyksYK2ug0EJM1IURxO9jOFxRnyJ6IACt1xiRYKQRLAXpQ3uQItFCABjTBgRWRYVdUXi5LwWb-BYpUDLZRScygvKFIyIGQaQ4FHnI5r827tDVaiXkKXYvoDYboMaapUqtVusUe3W8fWAimIE1mnIW1l0rImOE1BENdxOwkuvw-LB8CBS4Iy94K75EzCFiMgOYGoEIZRUwgyVT5BQmfaW4omGxZGxcuwOrNXNHtfNVou0b24v0ByYVgtF0kA8lG2PN1vtzvd0zszmD06I3nZ7yUCABAa9EYkQahCB32j3QUAEQAggAVACibEFACUqAAMQYAAZL8V3rGMSjbGQKihLsISkOlaWHS4WjPSBLx4a9byIVAeAgfBXRwx8S1COUPkIE8rgwi9yCvPgbzvQh8MIoUSLABB3kVIiRAAbXMABdCDo3XaD8lgpCpAQq0EA0eTUL5KZzywjiWIIoi-EFDjpx6H08X9AlqPQ2JMPo7DGNw9S2OIyy7y4iieINAThL1MlDTScTJPg3dGxkfZFNPUy6OIWBMDeB8wFoJgvw-NhsGwAAJNgAGkvwATREtdPM7VQrE0XyTF7coB0PQKaOCy9xXCsc-ASxKUrAQxpXI+UiGMmIKDM0KaoFdp6sawwHIiJzkhcrKPOWIqkOscFZNTfYOXtY9HQqrqQuqnN0QGprdJxX18UDDrlO6zbaqgHahu43jyHGtzV0m0x9jyxQ5p7ExzBhUrB3Kkz1qqsLCEGPhkAgSAIsfP8vxilgvz-T8f3q1KMomht9g+8ocnMGQCtZDRZAPLkMyREc-pU+jNuB0HwcVEQb01S6-zAGBKC6TxaAAYTfFgOa-EC2ChmG4YR+KkuRzL7sgsT0bMQhzCUcxpMK20vsPBRieO2iAfCqmwYgJU6ZIBmksGpmWe6dmOaoFhgL-L4Behr9Yfh79ReStKJcjdy0Yx0Fsdx+b23MX7OvJnqgZBvXCC6ZmwFZzSLpN3ayNlNqqNWsnTsB3XwZj822e2pOrscm67q9h6GzMBQrDy7cZJ7DR1ZQlbSdDrOdcj3PY-jwuGt2mcDsMo6M7bjbs87-W87ji3e8G4a+FG-ihNRqCq5rtsO3r0wpG0ZvMzQ0eqtVVAunmQwIai5931d7Avw5+4-wYD9PdrKNsqmsoOQyBM3sQZ6pBDidDax9T7oHPqxBO2AQFnxaqnSimtKoU2gWA6ykDkHFxGqXZektRI5U-ooBkiZZIKFyHvEmB8gFH0VCfM+qDtroL2vpOcRkR6UKQdQ0B4CNL0I4Wfeei9brYPLlLPBjcCE-18m2KwGtWFa0CIwVgbAqD3A-CvaWWgYSEN-iUDIZpUxpiqBoQBZ52g0HQLAssoczFqM8k2WCW5N6+X2OYeShMTgyNbspUxdAB4GXnMpaxOD35-w0XLCRrJ0ZWH0QYqQRiW4UOVKqSI7RAJG1gOgTEXQLEUQVMdRJaoUlpIyU8Lo-CsGuWEbg5YmhCD7B3nU-YA5lA6HVhCZxYjoRshyDvJQ+NVCAIAO7IABH4QCfQPwqlSV0dJmT6DMHYJwGx1Sm7Yx0I3SwziTBOLqWaLQdIpC2HyKoLZ5gESInIIWOAYgEHrUCZU4JJRsY0iIT2akZoPEUJMX4GY9zHoIDbIcdGLzt4qFhDEpCgDzqZKWYgVQ+xWSyCyOC2okK+paRFGGHUML-mmnNNorZbIwUxI+Upc6E5qzYq2LUuQwKSitnymrI8+8lJyIYkxe8zELlfj0l0bFVcaRlCklvRspzjGILZVZEgkVCAzj5Y3AV+MfIQjyEy8hLLxUWXZRfOVRVsiKqVhCRuhxtgaBVY3A5MgxX-XMmpCB7E7K-CCX8oq+RnnaIKJa+J6rrUSrvHykwHZZq+X2bScwYbzUSTUBCr1QUfWbSlX6p1lctlusKp2Q4JLY1hzOmixOfdii-MrlIiotKPpyCtdm8e1N9YJsdYWqCmyshyH9vigoVhkWxIre3CO1aDbkHpuMRm3cZ51tft7BtqhzAZoVga+aGhexuOOJmtalaO69qnj3fqRc+UKHpIQIq87S25GXZnMea69Y7qhPuswxVCptnKNsOQ7Z2xUhPYfCmYV-WBtLVOjkJqzUkP6TGldp10EX0IFynlcroRywsI4iEu6ArAdPVQmhKDa0yqg0m1e+NNFwZ3BCNswdkPvuIGB2tcqakuPlgR4h2MWzMgAxartwDeEoLtf1dB-rXVBoQyQljqHOFfq+q2v9jHG7mqUKqm55N-UuN4-NT6DGdD5C0Gp-Ypq31eL8HcsdFcG0yA+rB5QtHijOKOYoNWgD8nJNGUU6F2GxK9LlvSTIcLGn5C2ZUNpLj5CxIUFSD6XmuyDOGeiMZXQJlgCmTMkp2LGm1OUFCHQORGkyEVqoZQbTNly2-poaERy6kh0pYl5LrZpLNIy1l2Sm5dAkPRs4wz+NlDOGcEAA */
createMachine(
{
context: {
@@ -188,6 +121,9 @@ export const authMachine =
regenerateSSHKey: {
data: TypesGen.GitSSHKey
}
hasFirstUser: {
data: boolean
}
},
},
id: "authState",
@@ -226,14 +162,14 @@ export const authMachine =
id: "getMe",
onDone: [
{
actions: ["assignMe"],
actions: "assignMe",
target: "gettingPermissions",
},
],
onError: [
{
actions: "assignGetUserError",
target: "gettingMethods",
target: "checkingFirstUser",
},
],
},
@@ -246,7 +182,7 @@ export const authMachine =
id: "checkPermissions",
onDone: [
{
actions: ["assignPermissions"],
actions: "assignPermissions",
target: "signedIn",
},
],
@@ -316,7 +252,76 @@ export const authMachine =
},
},
},
ssh: sshState,
ssh: {
initial: "idle",
states: {
idle: {
on: {
GET_SSH_KEY: {
target: "gettingSSHKey",
},
},
},
gettingSSHKey: {
entry: "clearGetSSHKeyError",
invoke: {
src: "getSSHKey",
onDone: [
{
actions: "assignSSHKey",
target: "loaded",
},
],
onError: [
{
actions: "assignGetSSHKeyError",
target: "idle",
},
],
},
},
loaded: {
initial: "idle",
states: {
idle: {
on: {
REGENERATE_SSH_KEY: {
target: "confirmSSHKeyRegenerate",
},
},
},
confirmSSHKeyRegenerate: {
on: {
CANCEL_REGENERATE_SSH_KEY: {
target: "idle",
},
CONFIRM_REGENERATE_SSH_KEY: {
target: "regeneratingSSHKey",
},
},
},
regeneratingSSHKey: {
entry: "clearRegenerateSSHKeyError",
invoke: {
src: "regenerateSSHKey",
onDone: [
{
actions: ["assignSSHKey", "notifySuccessSSHKeyRegenerated"],
target: "idle",
},
],
onError: [
{
actions: "assignRegenerateSSHKeyError",
target: "idle",
},
],
},
},
},
},
},
},
security: {
initial: "idle",
states: {
@@ -338,7 +343,7 @@ export const authMachine =
src: "updateSecurity",
onDone: [
{
actions: ["notifySuccessSecurityUpdate"],
actions: "notifySuccessSecurityUpdate",
target: "#authState.signedIn.security.idle.noError",
},
],
@@ -378,6 +383,30 @@ export const authMachine =
},
tags: "loading",
},
checkingFirstUser: {
invoke: {
src: "hasFirstUser",
onDone: [
{
cond: "isTrue",
target: "gettingMethods",
},
{
target: "waitingForTheFirstUser",
},
],
onError: "signedOut",
},
tags: "loading",
},
waitingForTheFirstUser: {
entry: "redirectToSetupPage",
on: {
SIGN_IN: {
target: "signingIn",
},
},
},
},
},
{
@@ -414,6 +443,8 @@ export const authMachine =
// SSH
getSSHKey: () => API.getUserSSHKey(),
regenerateSSHKey: () => API.regenerateUserSSHKey(),
// First user
hasFirstUser: () => API.hasFirstUser(),
},
actions: {
assignMe: assign({
@@ -496,5 +527,8 @@ export const authMachine =
displaySuccess(Language.successRegenerateSSHKey)
},
},
guards: {
isTrue: (_, event) => event.data,
},
},
)
+108
View File
@@ -0,0 +1,108 @@
import * as API from "api/api"
import {
ApiError,
FieldErrors,
getErrorMessage,
hasApiFieldErrors,
isApiError,
mapApiErrorToFieldErrors,
} from "api/errors"
import * as TypesGen from "api/typesGenerated"
import { assign, createMachine } from "xstate"
export const Language = {
createFirstUserError: "Failed to create the user.",
}
export interface SetupContext {
createFirstUserErrorMessage?: string
createFirstUserFormErrors?: FieldErrors
firstUser?: TypesGen.CreateFirstUserRequest
}
export type SetupEvent = { type: "CREATE_FIRST_USER"; firstUser: TypesGen.CreateFirstUserRequest }
export const setupMachine =
/** @xstate-layout N4IgpgJg5mDOIC5QGUwBcCuAHZaCGaYAdAJYQA2YAxAMIBKAogIIAqDA+gGICSdyL7AKrIGdRKCwB7WCTQlJAO3EgAHogDMAViKaAnPoDsABgBMRgCy6jpkwBoQAT0QBGI+qIA2N0ePOAHJqa-qYAviH2qJg4+IREAMYATmAEJApQnCQJsGiCsGAJVBCKxKkAbpIA1sSJyYQZWTl5CcpSMnKKymoI6ibuzmZGuiYG6kYemn4eHvZOCObOzjom-X7qHsZGmuq6mmER6Ni4BNVJKWn12bn5VPkJkglEWOQEAGb3ALbxp3WZl00t0lk8iUSFUGl07g8-XMxlWmmsWnUMxcUyIzg86hhPgWvg8JjC4RACkkEDgykihxiJQoYABbWBnUQflcRAMZlc6jWwwMfmRCDM2ksfgMzl0Bisbj85j8exAFOixy+tVS6V+jXydKBHVBXQAtDsiOyeVp-OoFrpnHyzboiKZ1CMDCNzEY-H4xbL5UdYi81VcEjRvpBNe0QaAuoEbZzpfaRh4rFM+eYTOZbcsTBD0b0bB6DgrCMGGTrELrzYajM5jUFVubLY4NIsvKM2eMtKZNOMCSEgA */
createMachine(
{
tsTypes: {} as import("./setupXService.typegen").Typegen0,
schema: {
context: {} as SetupContext,
events: {} as SetupEvent,
services: {} as {
createFirstUser: {
data: TypesGen.CreateFirstUserResponse
}
},
},
id: "SetupState",
initial: "idle",
states: {
idle: {
on: {
CREATE_FIRST_USER: {
actions: "assignFirstUserData",
target: "creatingFirstUser",
},
},
},
creatingFirstUser: {
entry: "clearCreateFirstUserError",
invoke: {
src: "createFirstUser",
id: "createFirstUser",
onDone: [
{
actions: "onCreateFirstUser",
target: "firstUserCreated",
},
],
onError: [
{
actions: "assignCreateFirstUserFormErrors",
cond: "hasFieldErrors",
target: "idle",
},
{
actions: "assignCreateFirstUserError",
target: "idle",
},
],
},
tags: "loading",
},
firstUserCreated: {
tags: "loading",
type: "final",
},
},
},
{
services: {
createFirstUser: (_, event) => API.createFirstUser(event.firstUser),
},
guards: {
hasFieldErrors: (_, event) => isApiError(event.data) && hasApiFieldErrors(event.data),
},
actions: {
assignFirstUserData: assign({
firstUser: (_, event) => event.firstUser,
}),
assignCreateFirstUserError: assign({
createFirstUserErrorMessage: (_, event) =>
getErrorMessage(event.data, Language.createFirstUserError),
}),
assignCreateFirstUserFormErrors: assign({
// the guard ensures it is ApiError
createFirstUserFormErrors: (_, event) =>
mapApiErrorToFieldErrors((event.data as ApiError).response.data),
}),
clearCreateFirstUserError: assign((context: SetupContext) => ({
...context,
createFirstUserErrorMessage: undefined,
createFirstUserFormErrors: undefined,
})),
},
},
)