Skip to content

feat: Add setup page #3476

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 19 commits into from
Aug 11, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions site/e2e/globalSetup.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
2 changes: 2 additions & 0 deletions site/src/AppRouter.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"
Expand Down
19 changes: 18 additions & 1 deletion site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
35 changes: 35 additions & 0 deletions site/src/components/SignInLayout/SignInLayout.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
}
12 changes: 10 additions & 2 deletions site/src/components/Welcome/Welcome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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>
)
Expand Down
17 changes: 16 additions & 1 deletion site/src/pages/LoginPage/LoginPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"))
})
})
62 changes: 18 additions & 44 deletions site/src/pages/LoginPage/LoginPage.tsx
Original file line number Diff line number Diff line change
@@ -1,80 +1,54 @@
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)
const isLoading = authState.hasTag("loading")
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>
</>
)
}
}
99 changes: 99 additions & 0 deletions site/src/pages/SetupPage/SetupPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 47 additions & 0 deletions site/src/pages/SetupPage/SetupPage.tsx
Original file line number Diff line number Diff line change
@@ -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 })
}}
/>
</>
)
}
Loading