diff --git a/site/e2e/globalSetup.ts b/site/e2e/globalSetup.ts index 76405f82bdb71..0845d96ada1ed 100644 --- a/site/e2e/globalSetup.ts +++ b/site/e2e/globalSetup.ts @@ -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 => { axios.defaults.baseURL = `http://localhost:${constants.basePort}` - await postFirstUser({ + await createFirstUser({ email: constants.email, organization: constants.organization, username: constants.username, diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 332f283505989..d10db7b1557c2 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -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 = () => { /> } /> + } /> } /> => { + 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 => { const response = await axios.post(`/api/v2/users/first`, req) diff --git a/site/src/components/SignInLayout/SignInLayout.tsx b/site/src/components/SignInLayout/SignInLayout.tsx new file mode 100644 index 0000000000000..bce7e586b5e0f --- /dev/null +++ b/site/src/components/SignInLayout/SignInLayout.tsx @@ -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 ( +
+
+
{children}
+
+
+
+ ) +} diff --git a/site/src/components/Welcome/Welcome.tsx b/site/src/components/Welcome/Welcome.tsx index 382bdbf221c6d..d0687c5ffb563 100644 --- a/site/src/components/Welcome/Welcome.tsx +++ b/site/src/components/Welcome/Welcome.tsx @@ -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 Coder + + ), +} + +export const Welcome: FC<{ message?: JSX.Element }> = ({ message = Language.defaultMessage }) => { const styles = useStyles() return ( @@ -12,7 +20,7 @@ export const Welcome: FC = () => { - Welcome to Coder + {message} ) diff --git a/site/src/pages/LoginPage/LoginPage.test.tsx b/site/src/pages/LoginPage/LoginPage.test.tsx index 1651f78d47cd7..ec1dcf4c16fc7 100644 --- a/site/src/pages/LoginPage/LoginPage.test.tsx +++ b/site/src/pages/LoginPage/LoginPage.test.tsx @@ -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() + + // Then + await waitFor(() => expect(history.location.pathname).toEqual("/setup")) + }) }) diff --git a/site/src/pages/LoginPage/LoginPage.tsx b/site/src/pages/LoginPage/LoginPage.tsx index b84dac9c87106..a8d7c5e90bc76 100644 --- a/site/src/pages/LoginPage/LoginPage.tsx +++ b/site/src/pages/LoginPage/LoginPage.tsx @@ -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 } else { return ( -
+ <> {pageTitle("Login")} -
-
- -
- -
-
-
+ + + + ) } } diff --git a/site/src/pages/SetupPage/SetupPage.test.tsx b/site/src/pages/SetupPage/SetupPage.test.tsx new file mode 100644 index 0000000000000..36dab887c1338 --- /dev/null +++ b/site/src/pages/SetupPage/SetupPage.test.tsx @@ -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() + 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() + 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() + await fillForm() + const errorMessage = await screen.findByText(fieldErrorMessage) + expect(errorMessage).toBeDefined() + }) + + it("redirects to workspaces page when success", async () => { + render() + + // 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")) + }) +}) diff --git a/site/src/pages/SetupPage/SetupPage.tsx b/site/src/pages/SetupPage/SetupPage.tsx new file mode 100644 index 0000000000000..f4d11960871de --- /dev/null +++ b/site/src/pages/SetupPage/SetupPage.tsx @@ -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 ( + <> + + {pageTitle("Set up your account")} + + { + setupSend({ type: "CREATE_FIRST_USER", firstUser }) + }} + /> + + ) +} diff --git a/site/src/pages/SetupPage/SetupPageView.stories.tsx b/site/src/pages/SetupPage/SetupPageView.stories.tsx new file mode 100644 index 0000000000000..b3a5684806642 --- /dev/null +++ b/site/src/pages/SetupPage/SetupPageView.stories.tsx @@ -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 = (args: SetupPageViewProps) => ( + +) + +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, +} diff --git a/site/src/pages/SetupPage/SetupPageView.tsx b/site/src/pages/SetupPage/SetupPageView.tsx new file mode 100644 index 0000000000000..bcfa67a31de16 --- /dev/null +++ b/site/src/pages/SetupPage/SetupPageView.tsx @@ -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 your account + + ), +} + +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 + genericError?: string + isLoading?: boolean +} + +export const SetupPageView: React.FC = ({ + onSubmit, + formErrors, + genericError, + isLoading, +}) => { + const form: FormikContextType = + useFormik({ + initialValues: { + email: "", + password: "", + username: "", + organization: "", + }, + validationSchema, + onSubmit, + }) + const getFieldHelpers = getFormHelpers(form, formErrors) + + return ( + + +
+ + + + + + {genericError && {genericError}} + + {Language.create} + + +
+
+ ) +} diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 7cf968fe8d00d..da7b0ebeda7a7 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -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])) diff --git a/site/src/xServices/StateContext.tsx b/site/src/xServices/StateContext.tsx index c9628cfe2608e..94873667d2698 100644 --- a/site/src/xServices/StateContext.tsx +++ b/site/src/xServices/StateContext.tsx @@ -29,11 +29,16 @@ export const XServiceProvider: React.FC = ({ children }) => { const redirectToUsersPage = () => { navigate("users") } + const redirectToSetupPage = () => { + navigate("setup") + } return ( + authMachine.withConfig({ actions: { redirectToSetupPage } }), + ), buildInfoXService: useInterpret(buildInfoMachine), usersXService: useInterpret(() => usersMachine.withConfig({ actions: { redirectToUsersPage } }), diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts index f07660885b275..8ac6d887d75e4 100644 --- a/site/src/xServices/auth/authXService.ts +++ b/site/src/xServices/auth/authXService.ts @@ -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, + }, }, ) diff --git a/site/src/xServices/setup/setupXService.ts b/site/src/xServices/setup/setupXService.ts new file mode 100644 index 0000000000000..564d1fb6b9d14 --- /dev/null +++ b/site/src/xServices/setup/setupXService.ts @@ -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, + })), + }, + }, + )