Skip to content

Commit 4a17e0d

Browse files
feat: Add setup page (#3476)
1 parent 604f211 commit 4a17e0d

File tree

15 files changed

+624
-124
lines changed

15 files changed

+624
-124
lines changed

site/e2e/globalSetup.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import axios from "axios"
2-
import { postFirstUser } from "../src/api/api"
2+
import { createFirstUser } from "../src/api/api"
33
import * as constants from "./constants"
44

55
const globalSetup = async (): Promise<void> => {
66
axios.defaults.baseURL = `http://localhost:${constants.basePort}`
7-
await postFirstUser({
7+
await createFirstUser({
88
email: constants.email,
99
organization: constants.organization,
1010
username: constants.username,

site/src/AppRouter.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useSelector } from "@xstate/react"
2+
import { SetupPage } from "pages/SetupPage/SetupPage"
23
import { FC, lazy, Suspense, useContext } from "react"
34
import { Navigate, Route, Routes } from "react-router-dom"
45
import { selectPermissions } from "xServices/auth/authSelectors"
@@ -47,6 +48,7 @@ export const AppRouter: FC = () => {
4748
/>
4849

4950
<Route path="login" element={<LoginPage />} />
51+
<Route path="setup" element={<SetupPage />} />
5052
<Route path="healthz" element={<HealthzPage />} />
5153
<Route
5254
path="cli-auth"

site/src/api/api.ts

+18-1
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,24 @@ export const suspendUser = async (userId: TypesGen.User["id"]): Promise<TypesGen
282282
return response.data
283283
}
284284

285-
export const postFirstUser = async (
285+
// API definition:
286+
// https://github.com/coder/coder/blob/db665e7261f3c24a272ccec48233a3e276878239/coderd/users.go#L33-L53
287+
export const hasFirstUser = async (): Promise<boolean> => {
288+
try {
289+
// If it is success, it is true
290+
await axios.get("/api/v2/users/first")
291+
return true
292+
} catch (error) {
293+
// If it returns a 404, it is false
294+
if (axios.isAxiosError(error) && error.response?.status === 404) {
295+
return false
296+
}
297+
298+
throw error
299+
}
300+
}
301+
302+
export const createFirstUser = async (
286303
req: TypesGen.CreateFirstUserRequest,
287304
): Promise<TypesGen.CreateFirstUserResponse> => {
288305
const response = await axios.post(`/api/v2/users/first`, req)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { makeStyles } from "@material-ui/core/styles"
2+
import { FC } from "react"
3+
import { Footer } from "../../components/Footer/Footer"
4+
5+
export const useStyles = makeStyles((theme) => ({
6+
root: {
7+
height: "100vh",
8+
display: "flex",
9+
justifyContent: "center",
10+
alignItems: "center",
11+
},
12+
layout: {
13+
display: "flex",
14+
flexDirection: "column",
15+
alignItems: "center",
16+
},
17+
container: {
18+
marginTop: theme.spacing(-8),
19+
minWidth: "320px",
20+
maxWidth: "320px",
21+
},
22+
}))
23+
24+
export const SignInLayout: FC = ({ children }) => {
25+
const styles = useStyles()
26+
27+
return (
28+
<div className={styles.root}>
29+
<div className={styles.layout}>
30+
<div className={styles.container}>{children}</div>
31+
<Footer />
32+
</div>
33+
</div>
34+
)
35+
}

site/src/components/Welcome/Welcome.tsx

+10-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,15 @@ import Typography from "@material-ui/core/Typography"
33
import { FC } from "react"
44
import { CoderIcon } from "../Icons/CoderIcon"
55

6-
export const Welcome: FC = () => {
6+
const Language = {
7+
defaultMessage: (
8+
<>
9+
Welcome to <strong>Coder</strong>
10+
</>
11+
),
12+
}
13+
14+
export const Welcome: FC<{ message?: JSX.Element }> = ({ message = Language.defaultMessage }) => {
715
const styles = useStyles()
816

917
return (
@@ -12,7 +20,7 @@ export const Welcome: FC = () => {
1220
<CoderIcon className={styles.logo} />
1321
</div>
1422
<Typography className={styles.title} variant="h1">
15-
Welcome to <strong>Coder</strong>
23+
{message}
1624
</Typography>
1725
</div>
1826
)

site/src/pages/LoginPage/LoginPage.test.tsx

+16-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { act, screen } from "@testing-library/react"
1+
import { act, screen, waitFor } from "@testing-library/react"
22
import userEvent from "@testing-library/user-event"
33
import { rest } from "msw"
44
import { Language } from "../../components/SignInForm/SignInForm"
@@ -89,4 +89,19 @@ describe("LoginPage", () => {
8989
await screen.findByText(Language.passwordSignIn)
9090
await screen.findByText(Language.githubSignIn)
9191
})
92+
93+
it("redirects to the setup page if there is no first user", async () => {
94+
// Given
95+
server.use(
96+
rest.get("/api/v2/users/first", async (req, res, ctx) => {
97+
return res(ctx.status(404))
98+
}),
99+
)
100+
101+
// When
102+
render(<LoginPage />)
103+
104+
// Then
105+
await waitFor(() => expect(history.location.pathname).toEqual("/setup"))
106+
})
92107
})
+18-44
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,54 @@
1-
import { makeStyles } from "@material-ui/core/styles"
21
import { useActor } from "@xstate/react"
2+
import { SignInLayout } from "components/SignInLayout/SignInLayout"
33
import React, { useContext } from "react"
44
import { Helmet } from "react-helmet"
55
import { Navigate, useLocation } from "react-router-dom"
6-
import { Footer } from "../../components/Footer/Footer"
76
import { LoginErrors, SignInForm } from "../../components/SignInForm/SignInForm"
87
import { pageTitle } from "../../util/page"
98
import { retrieveRedirect } from "../../util/redirect"
109
import { XServiceContext } from "../../xServices/StateContext"
1110

12-
export const useStyles = makeStyles((theme) => ({
13-
root: {
14-
height: "100vh",
15-
display: "flex",
16-
justifyContent: "center",
17-
alignItems: "center",
18-
},
19-
layout: {
20-
display: "flex",
21-
flexDirection: "column",
22-
alignItems: "center",
23-
},
24-
container: {
25-
marginTop: theme.spacing(-8),
26-
minWidth: "320px",
27-
maxWidth: "320px",
28-
},
29-
}))
30-
3111
interface LocationState {
3212
isRedirect: boolean
3313
}
3414

3515
export const LoginPage: React.FC = () => {
36-
const styles = useStyles()
3716
const location = useLocation()
3817
const xServices = useContext(XServiceContext)
3918
const [authState, authSend] = useActor(xServices.authXService)
4019
const isLoading = authState.hasTag("loading")
4120
const redirectTo = retrieveRedirect(location.search)
4221
const locationState = location.state ? (location.state as LocationState) : null
4322
const isRedirected = locationState ? locationState.isRedirect : false
23+
const { authError, getUserError, checkPermissionsError, getMethodsError } = authState.context
4424

4525
const onSubmit = async ({ email, password }: { email: string; password: string }) => {
4626
authSend({ type: "SIGN_IN", email, password })
4727
}
4828

49-
const { authError, getUserError, checkPermissionsError, getMethodsError } = authState.context
50-
5129
if (authState.matches("signedIn")) {
5230
return <Navigate to={redirectTo} replace />
5331
} else {
5432
return (
55-
<div className={styles.root}>
33+
<>
5634
<Helmet>
5735
<title>{pageTitle("Login")}</title>
5836
</Helmet>
59-
<div className={styles.layout}>
60-
<div className={styles.container}>
61-
<SignInForm
62-
authMethods={authState.context.methods}
63-
redirectTo={redirectTo}
64-
isLoading={isLoading}
65-
loginErrors={{
66-
[LoginErrors.AUTH_ERROR]: authError,
67-
[LoginErrors.GET_USER_ERROR]: isRedirected ? getUserError : null,
68-
[LoginErrors.CHECK_PERMISSIONS_ERROR]: checkPermissionsError,
69-
[LoginErrors.GET_METHODS_ERROR]: getMethodsError,
70-
}}
71-
onSubmit={onSubmit}
72-
/>
73-
</div>
74-
75-
<Footer />
76-
</div>
77-
</div>
37+
<SignInLayout>
38+
<SignInForm
39+
authMethods={authState.context.methods}
40+
redirectTo={redirectTo}
41+
isLoading={isLoading}
42+
loginErrors={{
43+
[LoginErrors.AUTH_ERROR]: authError,
44+
[LoginErrors.GET_USER_ERROR]: isRedirected ? getUserError : null,
45+
[LoginErrors.CHECK_PERMISSIONS_ERROR]: checkPermissionsError,
46+
[LoginErrors.GET_METHODS_ERROR]: getMethodsError,
47+
}}
48+
onSubmit={onSubmit}
49+
/>
50+
</SignInLayout>
51+
</>
7852
)
7953
}
8054
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { screen, waitFor } from "@testing-library/react"
2+
import userEvent from "@testing-library/user-event"
3+
import * as API from "api/api"
4+
import { rest } from "msw"
5+
import { history, MockUser, render } from "testHelpers/renderHelpers"
6+
import { server } from "testHelpers/server"
7+
import { Language as SetupLanguage } from "xServices/setup/setupXService"
8+
import { SetupPage } from "./SetupPage"
9+
import { Language as PageViewLanguage } from "./SetupPageView"
10+
11+
const fillForm = async ({
12+
username = "someuser",
13+
email = "someone@coder.com",
14+
password = "password",
15+
organization = "Coder",
16+
}: {
17+
username?: string
18+
email?: string
19+
password?: string
20+
organization?: string
21+
} = {}) => {
22+
const usernameField = screen.getByLabelText(PageViewLanguage.usernameLabel)
23+
const emailField = screen.getByLabelText(PageViewLanguage.emailLabel)
24+
const passwordField = screen.getByLabelText(PageViewLanguage.passwordLabel)
25+
const organizationField = screen.getByLabelText(PageViewLanguage.organizationLabel)
26+
await userEvent.type(organizationField, organization)
27+
await userEvent.type(usernameField, username)
28+
await userEvent.type(emailField, email)
29+
await userEvent.type(passwordField, password)
30+
const submitButton = screen.getByRole("button", { name: PageViewLanguage.create })
31+
submitButton.click()
32+
}
33+
34+
describe("Setup Page", () => {
35+
beforeEach(() => {
36+
history.replace("/setup")
37+
// appear logged out
38+
server.use(
39+
rest.get("/api/v2/users/me", (req, res, ctx) => {
40+
return res(ctx.status(401), ctx.json({ message: "no user here" }))
41+
}),
42+
)
43+
})
44+
45+
it("shows validation error message", async () => {
46+
render(<SetupPage />)
47+
await fillForm({ email: "test" })
48+
const errorMessage = await screen.findByText(PageViewLanguage.emailInvalid)
49+
expect(errorMessage).toBeDefined()
50+
})
51+
52+
it("shows generic error message", async () => {
53+
jest.spyOn(API, "createFirstUser").mockRejectedValueOnce({
54+
data: "unknown error",
55+
})
56+
render(<SetupPage />)
57+
await fillForm()
58+
const errorMessage = await screen.findByText(SetupLanguage.createFirstUserError)
59+
expect(errorMessage).toBeDefined()
60+
})
61+
62+
it("shows API error message", async () => {
63+
const fieldErrorMessage = "invalid username"
64+
server.use(
65+
rest.post("/api/v2/users/first", async (req, res, ctx) => {
66+
return res(
67+
ctx.status(400),
68+
ctx.json({
69+
message: "invalid field",
70+
validations: [
71+
{
72+
detail: fieldErrorMessage,
73+
field: "username",
74+
},
75+
],
76+
}),
77+
)
78+
}),
79+
)
80+
render(<SetupPage />)
81+
await fillForm()
82+
const errorMessage = await screen.findByText(fieldErrorMessage)
83+
expect(errorMessage).toBeDefined()
84+
})
85+
86+
it("redirects to workspaces page when success", async () => {
87+
render(<SetupPage />)
88+
89+
// simulates the user will be authenticated
90+
server.use(
91+
rest.get("/api/v2/users/me", (req, res, ctx) => {
92+
return res(ctx.status(200), ctx.json(MockUser))
93+
}),
94+
)
95+
96+
await fillForm()
97+
await waitFor(() => expect(history.location.pathname).toEqual("/workspaces"))
98+
})
99+
})
+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { useActor, useMachine } from "@xstate/react"
2+
import { FC, useContext, useEffect } from "react"
3+
import { Helmet } from "react-helmet"
4+
import { useNavigate } from "react-router-dom"
5+
import { pageTitle } from "util/page"
6+
import { setupMachine } from "xServices/setup/setupXService"
7+
import { XServiceContext } from "xServices/StateContext"
8+
import { SetupPageView } from "./SetupPageView"
9+
10+
export const SetupPage: FC = () => {
11+
const navigate = useNavigate()
12+
const xServices = useContext(XServiceContext)
13+
const [authState, authSend] = useActor(xServices.authXService)
14+
const [setupState, setupSend] = useMachine(setupMachine, {
15+
actions: {
16+
onCreateFirstUser: ({ firstUser }) => {
17+
if (!firstUser) {
18+
throw new Error("First user was not defined.")
19+
}
20+
authSend({ type: "SIGN_IN", email: firstUser.email, password: firstUser.password })
21+
},
22+
},
23+
})
24+
const { createFirstUserFormErrors, createFirstUserErrorMessage } = setupState.context
25+
26+
useEffect(() => {
27+
if (authState.matches("signedIn")) {
28+
return navigate("/workspaces")
29+
}
30+
}, [authState, navigate])
31+
32+
return (
33+
<>
34+
<Helmet>
35+
<title>{pageTitle("Set up your account")}</title>
36+
</Helmet>
37+
<SetupPageView
38+
isLoading={setupState.hasTag("loading")}
39+
formErrors={createFirstUserFormErrors}
40+
genericError={createFirstUserErrorMessage}
41+
onSubmit={(firstUser) => {
42+
setupSend({ type: "CREATE_FIRST_USER", firstUser })
43+
}}
44+
/>
45+
</>
46+
)
47+
}

0 commit comments

Comments
 (0)