mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: Add setup page (#3476)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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]))
|
||||
|
||||
@@ -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 } }),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
},
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user