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, + })), + }, + }, + ) pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy