From 4183a4e01c1df31db10e2420175211579f618a2d Mon Sep 17 00:00:00 2001 From: Bryan Date: Fri, 21 Jan 2022 11:34:26 -0800 Subject: [PATCH] feat: Initial Login flow (#42) This just implements a basic sign-in flow, using the new endpoints in #29 : ![2022-01-20 12 35 30](https://user-images.githubusercontent.com/88213859/150418044-85900d1f-8890-4c60-baae-234342de71fa.gif) This brings over several dependencies that are necessary: - `formik` - `yep` Ports over some v1 code to bootstrap it: - `FormTextField` - `PasswordField` - `CoderIcon` And implements basic sign-in: Fixes #37 Fixes #43 This does not implement it navbar integration (importantly - there is no way to sign out yet, unless you manually delete your `session_token`). I'll do that in the next PR - figured this was big enough to get reviewed. --- _jest/setupTests.ts | 7 + jest.config.js | 3 + package.json | 14 +- site/api.ts | 23 +++ site/components/Button/LoadingButton.test.tsx | 23 +++ site/components/Button/LoadingButton.tsx | 45 +++++ site/components/Button/index.ts | 3 +- site/components/Form/FormTextField.test.tsx | 77 ++++++++ site/components/Form/FormTextField.tsx | 170 ++++++++++++++++++ site/components/Form/PasswordField.test.tsx | 14 ++ site/components/Form/PasswordField.tsx | 40 +++++ site/components/Form/index.tsx | 1 + site/components/Icons/CoderIcon.tsx | 17 ++ site/components/Icons/index.ts | 1 + .../Loader/FullScreenLoader.test.tsx | 14 ++ site/components/Loader/FullScreenLoader.tsx | 26 +++ site/components/Navbar/index.tsx | 9 +- site/components/Page/Footer.tsx | 1 + site/components/SignIn/SignInForm.test.tsx | 61 +++++++ site/components/SignIn/SignInForm.tsx | 146 +++++++++++++++ site/components/SignIn/Welcome.tsx | 42 +++++ site/components/SignIn/index.tsx | 1 + site/contexts/UserContext.test.tsx | 100 +++++++++++ site/contexts/UserContext.tsx | 51 ++++++ site/pages/_app.tsx | 81 +++------ site/pages/index.tsx | 19 +- site/pages/login.tsx | 30 ++++ yarn.lock | 79 +++++++- 28 files changed, 1029 insertions(+), 69 deletions(-) create mode 100644 _jest/setupTests.ts create mode 100644 site/api.ts create mode 100644 site/components/Button/LoadingButton.test.tsx create mode 100644 site/components/Button/LoadingButton.tsx create mode 100644 site/components/Form/FormTextField.test.tsx create mode 100644 site/components/Form/FormTextField.tsx create mode 100644 site/components/Form/PasswordField.test.tsx create mode 100644 site/components/Form/PasswordField.tsx create mode 100644 site/components/Form/index.tsx create mode 100644 site/components/Icons/CoderIcon.tsx create mode 100644 site/components/Loader/FullScreenLoader.test.tsx create mode 100644 site/components/Loader/FullScreenLoader.tsx create mode 100644 site/components/SignIn/SignInForm.test.tsx create mode 100644 site/components/SignIn/SignInForm.tsx create mode 100644 site/components/SignIn/Welcome.tsx create mode 100644 site/components/SignIn/index.tsx create mode 100644 site/contexts/UserContext.test.tsx create mode 100644 site/contexts/UserContext.tsx create mode 100644 site/pages/login.tsx diff --git a/_jest/setupTests.ts b/_jest/setupTests.ts new file mode 100644 index 0000000000..a1b73e2f52 --- /dev/null +++ b/_jest/setupTests.ts @@ -0,0 +1,7 @@ +/** + * Global setup for our Jest tests + */ + +// Set up 'next-router-mock' to with our front-end tests: +// https://github.com/scottrippey/next-router-mock#quick-start +jest.mock("next/router", () => require("next-router-mock")) diff --git a/jest.config.js b/jest.config.js index 189cfc63b3..8008e25790 100644 --- a/jest.config.js +++ b/jest.config.js @@ -5,6 +5,7 @@ module.exports = { displayName: "test", preset: "ts-jest", roots: ["/site"], + setupFilesAfterEnv: ["/_jest/setupTests.ts"], transform: { "^.+\\.tsx?$": "ts-jest", }, @@ -26,8 +27,10 @@ module.exports = { "/site/**/*.tsx", "!/site/**/*.stories.tsx", "!/site/.next/**/*.*", + "!/site/api.ts", "!/site/dev.ts", "!/site/next-env.d.ts", "!/site/next.config.js", + "!/site/out/**/*.*", ], } diff --git a/package.json b/package.json index 2fa249f1df..6ae8df91d9 100644 --- a/package.json +++ b/package.json @@ -40,18 +40,28 @@ "eslint-plugin-react": "7.28.0", "eslint-plugin-react-hooks": "4.3.0", "express": "4.17.2", + "formik": "2.2.9", "http-proxy-middleware": "2.0.1", "jest": "27.4.7", "jest-runner-eslint": "1.0.0", "next": "12.0.7", + "next-router-mock": "^0.6.5", "prettier": "2.5.1", "react": "17.0.2", "react-dom": "17.0.2", "sql-formatter": "^4.0.2", + "swr": "1.1.2", "ts-jest": "27.1.2", "ts-loader": "9.2.6", "ts-node": "10.4.0", - "typescript": "4.5.4" + "typescript": "4.5.4", + "yup": "0.32.11" }, - "dependencies": {} + "dependencies": {}, + "browserslist": [ + "chrome 66", + "firefox 63", + "edge 79", + "safari 13.1" + ] } diff --git a/site/api.ts b/site/api.ts new file mode 100644 index 0000000000..82d3303d45 --- /dev/null +++ b/site/api.ts @@ -0,0 +1,23 @@ +interface LoginResponse { + session_token: string +} + +export const login = async (email: string, password: string): Promise => { + const response = await fetch("/api/v2/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email, + password, + }), + }) + + const body = await response.json() + if (!response.ok) { + throw new Error(body.message) + } + + return body +} diff --git a/site/components/Button/LoadingButton.test.tsx b/site/components/Button/LoadingButton.test.tsx new file mode 100644 index 0000000000..1110766150 --- /dev/null +++ b/site/components/Button/LoadingButton.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from "@testing-library/react" +import React from "react" +import { LoadingButton } from "./LoadingButton" + +describe("LoadingButton", () => { + it("renders", async () => { + // When + render(Sign In) + + // Then + const element = await screen.findByText("Sign In") + expect(element).toBeDefined() + }) + + it("shows spinner if loading is set to true", async () => { + // When + render(Sign in) + + // Then + const spinnerElement = await screen.findByRole("progressbar") + expect(spinnerElement).toBeDefined() + }) +}) diff --git a/site/components/Button/LoadingButton.tsx b/site/components/Button/LoadingButton.tsx new file mode 100644 index 0000000000..f03b694095 --- /dev/null +++ b/site/components/Button/LoadingButton.tsx @@ -0,0 +1,45 @@ +import Button, { ButtonProps } from "@material-ui/core/Button" +import CircularProgress from "@material-ui/core/CircularProgress" +import { makeStyles } from "@material-ui/core/styles" +import * as React from "react" + +export interface LoadingButtonProps extends ButtonProps { + /** Whether or not to disable the button and show a spinner */ + loading?: boolean +} + +/** + * LoadingButton is a small wrapper around Material-UI's button to show a loading spinner + * + * In Material-UI 5+ - this is built-in, but since we're on an earlier version, + * we have to roll our own. + */ +export const LoadingButton: React.FC = ({ loading = false, children, ...rest }) => { + const styles = useStyles() + const hidden = loading ? { opacity: 0 } : undefined + + return ( + + ) +} + +const useStyles = makeStyles((theme) => ({ + loader: { + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + height: 18, + width: 18, + }, + spinner: { + color: theme.palette.text.disabled, + }, +})) diff --git a/site/components/Button/index.ts b/site/components/Button/index.ts index b5c759c0b3..318c0c3739 100644 --- a/site/components/Button/index.ts +++ b/site/components/Button/index.ts @@ -1 +1,2 @@ -export { SplitButton } from "./SplitButton" +export * from "./SplitButton" +export * from "./LoadingButton" diff --git a/site/components/Form/FormTextField.test.tsx b/site/components/Form/FormTextField.test.tsx new file mode 100644 index 0000000000..87653463c6 --- /dev/null +++ b/site/components/Form/FormTextField.test.tsx @@ -0,0 +1,77 @@ +import { act, fireEvent, render, screen } from "@testing-library/react" +import { useFormik } from "formik" +import React from "react" +import * as yup from "yup" +import { formTextFieldFactory, FormTextFieldProps } from "./FormTextField" + +namespace Helpers { + export interface FormValues { + name: string + } + + export const requiredValidationMsg = "required" + + const FormTextField = formTextFieldFactory() + + export const Component: React.FC, "form" | "formFieldName">> = (props) => { + const form = useFormik({ + initialValues: { + name: "", + }, + onSubmit: (values, helpers) => { + return helpers.setSubmitting(false) + }, + validationSchema: yup.object({ + name: yup.string().required(requiredValidationMsg), + }), + }) + + return + } +} + +describe("FormTextField", () => { + describe("helperText", () => { + it("uses helperText prop when there are no errors", () => { + // Given + const props = { + helperText: "testing", + } + + // When + const { queryByText } = render() + + // Then + expect(queryByText(props.helperText)).toBeDefined() + }) + + it("uses validation message when there are errors", () => { + // Given + const props = {} + + // When + const { container } = render() + const el = container.firstChild + + // Then + expect(el).toBeDefined() + expect(screen.queryByText(Helpers.requiredValidationMsg)).toBeNull() + + // When + act(() => { + fireEvent.focus(el as Element) + }) + + // Then + expect(screen.queryByText(Helpers.requiredValidationMsg)).toBeNull() + + // When + act(() => { + fireEvent.blur(el as Element) + }) + + // Then + expect(screen.queryByText(Helpers.requiredValidationMsg)).toBeDefined() + }) + }) +}) diff --git a/site/components/Form/FormTextField.tsx b/site/components/Form/FormTextField.tsx new file mode 100644 index 0000000000..db76173846 --- /dev/null +++ b/site/components/Form/FormTextField.tsx @@ -0,0 +1,170 @@ +import TextField, { TextFieldProps } from "@material-ui/core/TextField" +import React from "react" +import { PasswordField } from "./PasswordField" +import { FormikContextType } from "formik" + +/** + * FormFieldProps are required props for creating form fields using a factory. + */ +export interface FormFieldProps { + /** + * form is a reference to a form or subform and is used to compute common + * states such as error and helper text + */ + form: FormikContextType + /** + * formFieldName is a field name associated with the form schema. + */ + formFieldName: keyof T +} + +/** + * FormTextFieldProps extends form-related MUI TextFieldProps with Formik + * props. The passed in form is used to compute error states and configure + * change handlers. `formFieldName` represents the key of a Formik value + * that's associated to this component. + */ +export interface FormTextFieldProps + extends Pick< + TextFieldProps, + | "autoComplete" + | "autoFocus" + | "children" + | "className" + | "disabled" + | "fullWidth" + | "helperText" + | "id" + | "InputLabelProps" + | "InputProps" + | "inputProps" + | "label" + | "margin" + | "multiline" + | "onChange" + | "placeholder" + | "required" + | "rows" + | "select" + | "SelectProps" + | "style" + | "type" + >, + FormFieldProps { + /** + * eventTransform is an optional transformer on the event data before it is + * processed by formik. + * + * @example + * { + * return str.replace(" ", "-") + * }} + * /> + */ + eventTransform?: (value: string) => unknown + /** + * isPassword uses a PasswordField component when `true`; otherwise a + * TextField component is used. + */ + isPassword?: boolean + /** + * displayValueOverride allows displaying a different value in the field + * without changing the actual underlying value. + */ + displayValueOverride?: string + + variant?: "outlined" | "filled" | "standard" +} + +/** + * Factory function for creating a formik TextField + * + * @example + * interface FormValues { + * username: string + * } + * + * // Use the factory to create a FormTextField associated to this form + * const FormTextField = formTextFieldFactory() + * + * const MyComponent: React.FC = () => { + * const form = useFormik() + * + * return ( + * + * ) + * } + */ +export const formTextFieldFactory = (): React.FC> => { + const component: React.FC> = ({ + children, + disabled, + displayValueOverride, + eventTransform, + form, + formFieldName, + helperText, + isPassword = false, + InputProps, + onChange, + type, + variant = "outlined", + ...rest + }) => { + const isError = form.touched[formFieldName] && Boolean(form.errors[formFieldName]) + + // Conversion to a string primitive is necessary as formFieldName is an in + // indexable type such as a string, number or enum. + const fieldId = String(formFieldName) + + const Component = isPassword ? PasswordField : TextField + const inputType = isPassword ? undefined : type + + return ( + { + if (typeof onChange !== "undefined") { + onChange(e) + } + + const event = e + if (typeof eventTransform !== "undefined") { + // TODO(Grey): Asserting the type as a string here is not quite + // right in that when an input is of type="number", the value will + // be a number. Type asserting is better than conversion for this + // reason, but perhaps there's a better way to do this without any + // assertions. + event.target.value = eventTransform(e.target.value) as string + } + form.handleChange(event) + }} + type={inputType} + value={displayValueOverride || form.values[formFieldName]} + > + {children} + + ) + } + + // Required when using an anonymous factory function + component.displayName = "FormTextField" + return component +} diff --git a/site/components/Form/PasswordField.test.tsx b/site/components/Form/PasswordField.test.tsx new file mode 100644 index 0000000000..ffc003d905 --- /dev/null +++ b/site/components/Form/PasswordField.test.tsx @@ -0,0 +1,14 @@ +import { render, screen } from "@testing-library/react" +import React from "react" +import { PasswordField } from "./PasswordField" + +describe("PasswordField", () => { + it("renders", async () => { + // When + render() + + // Then + const element = await screen.findByText("Enter password") + expect(element).toBeDefined() + }) +}) diff --git a/site/components/Form/PasswordField.tsx b/site/components/Form/PasswordField.tsx new file mode 100644 index 0000000000..462c164318 --- /dev/null +++ b/site/components/Form/PasswordField.tsx @@ -0,0 +1,40 @@ +import IconButton from "@material-ui/core/IconButton" +import InputAdornment from "@material-ui/core/InputAdornment" +import { makeStyles } from "@material-ui/core/styles" +import TextField, { TextFieldProps } from "@material-ui/core/TextField" +import VisibilityOffOutlined from "@material-ui/icons/VisibilityOffOutlined" +import VisibilityOutlined from "@material-ui/icons/VisibilityOutlined" +import React, { useCallback, useState } from "react" + +type PasswordFieldProps = Omit + +export const PasswordField: React.FC = ({ variant = "outlined", ...rest }) => { + const styles = useStyles() + const [showPassword, setShowPassword] = useState(false) + + const handleVisibilityChange = useCallback(() => setShowPassword((showPassword) => !showPassword), []) + const VisibilityIcon = showPassword ? VisibilityOffOutlined : VisibilityOutlined + + return ( + + + + + + ), + }} + /> + ) +} + +const useStyles = makeStyles({ + visibilityIcon: { + fontSize: 20, + }, +}) diff --git a/site/components/Form/index.tsx b/site/components/Form/index.tsx new file mode 100644 index 0000000000..4e916cdae4 --- /dev/null +++ b/site/components/Form/index.tsx @@ -0,0 +1 @@ +export * from "./FormTextField" diff --git a/site/components/Icons/CoderIcon.tsx b/site/components/Icons/CoderIcon.tsx new file mode 100644 index 0000000000..9a391d889d --- /dev/null +++ b/site/components/Icons/CoderIcon.tsx @@ -0,0 +1,17 @@ +import SvgIcon, { SvgIconProps } from "@material-ui/core/SvgIcon" +import React from "react" + +/** + * CoderIcon represents the cloud with brackets Coder brand icon. It does not + * contain additional aspects, like the word 'Coder'. + */ +export const CoderIcon = (props: SvgIconProps): JSX.Element => ( + + + + + + + + +) diff --git a/site/components/Icons/index.ts b/site/components/Icons/index.ts index fa15f30a7d..c135b8db26 100644 --- a/site/components/Icons/index.ts +++ b/site/components/Icons/index.ts @@ -1,2 +1,3 @@ +export { CoderIcon } from "./CoderIcon" export { Logo } from "./Logo" export { WorkspacesIcon } from "./WorkspacesIcon" diff --git a/site/components/Loader/FullScreenLoader.test.tsx b/site/components/Loader/FullScreenLoader.test.tsx new file mode 100644 index 0000000000..31a40dbfc4 --- /dev/null +++ b/site/components/Loader/FullScreenLoader.test.tsx @@ -0,0 +1,14 @@ +import { render, screen } from "@testing-library/react" +import React from "react" +import { FullScreenLoader } from "./FullScreenLoader" + +describe("FullScreenLoader", () => { + it("renders", async () => { + // When + render() + + // Then + const element = await screen.findByRole("progressbar") + expect(element).toBeDefined() + }) +}) diff --git a/site/components/Loader/FullScreenLoader.tsx b/site/components/Loader/FullScreenLoader.tsx new file mode 100644 index 0000000000..7fbd290adc --- /dev/null +++ b/site/components/Loader/FullScreenLoader.tsx @@ -0,0 +1,26 @@ +import CircularProgress from "@material-ui/core/CircularProgress" +import { makeStyles } from "@material-ui/core/styles" +import React from "react" + +export const useStyles = makeStyles(() => ({ + root: { + position: "absolute", + top: "0", + left: "0", + right: "0", + bottom: "0", + display: "flex", + justifyContent: "center", + alignItems: "center", + }, +})) + +export const FullScreenLoader: React.FC = () => { + const styles = useStyles() + + return ( +
+ +
+ ) +} diff --git a/site/components/Navbar/index.tsx b/site/components/Navbar/index.tsx index 8b73020e60..b9fd1e3d95 100644 --- a/site/components/Navbar/index.tsx +++ b/site/components/Navbar/index.tsx @@ -3,12 +3,16 @@ import Button from "@material-ui/core/Button" import List from "@material-ui/core/List" import ListSubheader from "@material-ui/core/ListSubheader" import { makeStyles } from "@material-ui/core/styles" - import Link from "next/link" +import { User } from "../../contexts/UserContext" import { Logo } from "../Icons" -export const Navbar: React.FC = () => { +export interface NavbarProps { + user?: User +} + +export const Navbar: React.FC = () => { const styles = useStyles() return (
@@ -35,6 +39,7 @@ const useStyles = makeStyles((theme) => ({ root: { position: "relative", display: "flex", + flex: "0", flexDirection: "row", justifyContent: "center", alignItems: "center", diff --git a/site/components/Page/Footer.tsx b/site/components/Page/Footer.tsx index 390ccdc477..b0f1e4b225 100644 --- a/site/components/Page/Footer.tsx +++ b/site/components/Page/Footer.tsx @@ -26,6 +26,7 @@ const useFooterStyles = makeStyles((theme) => ({ root: { textAlign: "center", marginBottom: theme.spacing(5), + flex: "0", }, copyRight: { backgroundColor: theme.palette.background.default, diff --git a/site/components/SignIn/SignInForm.test.tsx b/site/components/SignIn/SignInForm.test.tsx new file mode 100644 index 0000000000..0f1797de4b --- /dev/null +++ b/site/components/SignIn/SignInForm.test.tsx @@ -0,0 +1,61 @@ +import React from "react" +import singletonRouter from "next/router" +import mockRouter from "next-router-mock" +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" + +import { SignInForm } from "./SignInForm" + +describe("SignInForm", () => { + beforeEach(() => { + mockRouter.setCurrentUrl("/login") + }) + + it("renders content", async () => { + // When + render() + + // Then + await screen.findByText("Sign In", { exact: false }) + }) + + it("shows an error message if SignIn fails", async () => { + // Given + const loginHandler = (_email: string, _password: string) => Promise.reject("Unacceptable credentials") + + // When + // Render the component + const { container } = render() + const inputs = container.querySelectorAll("input") + // Set username / password + fireEvent.change(inputs[0], { target: { value: "test@coder.com" } }) + fireEvent.change(inputs[1], { target: { value: "password" } }) + // Click sign-in + const elem = await screen.findByText("Sign In") + act(() => elem.click()) + + // Then + // Should see an error message + const errorMessage = await screen.findByText("The username or password is incorrect.") + expect(errorMessage).toBeDefined() + }) + + it("redirects when login is complete", async () => { + // Given + const loginHandler = (_email: string, _password: string) => Promise.resolve() + + // When + // Render the component + const { container } = render() + // Set user / password + const inputs = container.querySelectorAll("input") + fireEvent.change(inputs[0], { target: { value: "test@coder.com" } }) + fireEvent.change(inputs[1], { target: { value: "password" } }) + // Click sign-in + const elem = await screen.findByText("Sign In") + act(() => elem.click()) + + // Then + // Should redirect because login was successfully + await waitFor(() => expect(singletonRouter).toMatchObject({ asPath: "/" })) + }) +}) diff --git a/site/components/SignIn/SignInForm.tsx b/site/components/SignIn/SignInForm.tsx new file mode 100644 index 0000000000..b66735c5da --- /dev/null +++ b/site/components/SignIn/SignInForm.tsx @@ -0,0 +1,146 @@ +import { makeStyles } from "@material-ui/core/styles" +import { FormikContextType, useFormik } from "formik" +import { useRouter } from "next/router" +import React from "react" +import { useSWRConfig } from "swr" +import * as Yup from "yup" + +import { Welcome } from "./Welcome" +import { formTextFieldFactory } from "../Form" +import * as API from "./../../api" +import { LoadingButton } from "./../Button" + +/** + * BuiltInAuthFormValues describes a form using built-in (email/password) + * authentication. This form may not always be present depending on external + * auth providers available and administrative configurations + */ +interface BuiltInAuthFormValues { + email: string + password: string +} + +const validationSchema = Yup.object({ + email: Yup.string().required("Email is required."), + password: Yup.string(), +}) + +const FormTextField = formTextFieldFactory() + +const useStyles = makeStyles((theme) => ({ + loginBtnWrapper: { + marginTop: theme.spacing(6), + borderTop: `1px solid ${theme.palette.action.disabled}`, + paddingTop: theme.spacing(3), + }, + loginTypeToggleWrapper: { + marginTop: theme.spacing(2), + display: "flex", + justifyContent: "center", + }, + loginTypeToggleBtn: { + color: theme.palette.text.primary, + // We want opacity so that this isn't super highlighted for the user. + // In most cases, they shouldn't want to switch login types. + opacity: 0.5, + "&:hover": { + cursor: "pointer", + opacity: 1, + textDecoration: "underline", + }, + }, + loginTypeToggleBtnFocusVisible: { + opacity: 1, + textDecoration: "underline", + }, + loginTypeBtn: { + backgroundColor: "#2A2B45", + textTransform: "none", + + "&:not(:first-child)": { + marginTop: theme.spacing(2), + }, + }, + submitBtn: { + marginTop: theme.spacing(2), + }, +})) + +export interface SignInProps { + loginHandler?: (email: string, password: string) => Promise +} + +export const SignInForm: React.FC = ({ + loginHandler = (email: string, password: string) => API.login(email, password), +}) => { + const router = useRouter() + const styles = useStyles() + const { mutate } = useSWRConfig() + + const form: FormikContextType = useFormik({ + initialValues: { + email: "", + password: "", + }, + validationSchema, + onSubmit: async ({ email, password }, helpers) => { + try { + await loginHandler(email, password) + // Tell SWR to invalidate the cache for the user endpoint + mutate("/api/v2/user") + router.push("/") + } catch (err) { + helpers.setFieldError("password", "The username or password is incorrect.") + } + }, + }) + + return ( + <> + +
+
+ email.trim()} + form={form} + formFieldName="email" + fullWidth + inputProps={{ + id: "signin-form-inpt-email", + }} + margin="none" + placeholder="Email" + variant="outlined" + /> + +
+
+ + Sign In + +
+
+ + ) +} diff --git a/site/components/SignIn/Welcome.tsx b/site/components/SignIn/Welcome.tsx new file mode 100644 index 0000000000..b208c80e16 --- /dev/null +++ b/site/components/SignIn/Welcome.tsx @@ -0,0 +1,42 @@ +import { makeStyles } from "@material-ui/core/styles" +import { CoderIcon } from "../Icons" +import React from "react" +import Typography from "@material-ui/core/Typography" + +export const Welcome: React.FC = () => { + const styles = useStyles() + + return ( +
+
+ +
+ + <> + Welcome to +
+ Coder + +
+
+ ) +} + +const useStyles = makeStyles((theme) => ({ + logoBox: { + display: "flex", + justifyContent: "center", + }, + logo: { + width: 80, + height: 56, + color: theme.palette.text.primary, + }, + title: { + fontSize: 24, + letterSpacing: -0.3, + marginBottom: theme.spacing(3), + marginTop: theme.spacing(6), + textAlign: "center", + }, +})) diff --git a/site/components/SignIn/index.tsx b/site/components/SignIn/index.tsx new file mode 100644 index 0000000000..6ea6f3de7d --- /dev/null +++ b/site/components/SignIn/index.tsx @@ -0,0 +1 @@ +export * from "./SignInForm" diff --git a/site/contexts/UserContext.test.tsx b/site/contexts/UserContext.test.tsx new file mode 100644 index 0000000000..8b529841cb --- /dev/null +++ b/site/contexts/UserContext.test.tsx @@ -0,0 +1,100 @@ +import singletonRouter from "next/router" +import mockRouter from "next-router-mock" +import React from "react" +import { SWRConfig } from "swr" +import { render, screen, waitFor } from "@testing-library/react" + +import { User, UserProvider, useUser } from "./UserContext" + +namespace Helpers { + // Helper component that renders out the state of the `useUser` hook. + // It just renders simple text in the 'error', 'me', and 'loading' states, + // so that the test can get a peak at the state of the hook. + const TestComponent: React.FC<{ redirectOnFailure: boolean }> = ({ redirectOnFailure }) => { + const { me, error } = useUser(redirectOnFailure) + + if (error) { + return
{`Error: ${error.toString()}`}
+ } + if (me) { + return
{`Me: ${me.toString()}`}
+ } + + return
Loading
+ } + + // Helper to render a userContext, and all the scaffolding needed + // (an SWRConfig as well as a UserPRovider) + export const renderUserContext = ( + simulatedRequest: () => Promise, + redirectOnFailure: boolean, + ): React.ReactElement => { + return ( + // Set up an SWRConfig that works for testing - we'll simulate a request, + // and set up the cache to reset every test. + new Map(), + }} + > + + + + + ) + } + + export const mockUser: User = { + id: "test-user-id", + username: "TestUser", + email: "test@coder.com", + created_at: "", + } +} + +describe("UserContext", () => { + const failingRequest = () => Promise.reject("Failed to load user") + const successfulRequest = () => Promise.resolve(Helpers.mockUser) + + // Reset the router to '/' before every test + beforeEach(() => { + mockRouter.setCurrentUrl("/") + }) + + it("shouldn't redirect if user fails to load and redirectOnFailure is false", async () => { + // When + render(Helpers.renderUserContext(failingRequest, false)) + + // Then + // Verify we get an error message + await waitFor(() => { + expect(screen.queryByText("Error:", { exact: false })).toBeDefined() + }) + // ...and the route should be unchanged + expect(singletonRouter).toMatchObject({ asPath: "/" }) + }) + + it("should redirect if user fails to load and redirectOnFailure is true", async () => { + // When + render(Helpers.renderUserContext(failingRequest, true)) + + // Then + // Verify we route to the login page + await waitFor(() => expect(singletonRouter).toMatchObject({ asPath: "/login?redirect=%2F" })) + }) + + it("should not redirect if user loads and redirectOnFailure is true", async () => { + // When + render(Helpers.renderUserContext(successfulRequest, true)) + + // Then + // Verify the user is rendered + await waitFor(() => { + expect(screen.queryByText("Me:", { exact: false })).toBeDefined() + }) + // ...and the route should be unchanged + expect(singletonRouter).toMatchObject({ asPath: "/" }) + }) +}) diff --git a/site/contexts/UserContext.tsx b/site/contexts/UserContext.tsx new file mode 100644 index 0000000000..544d5dd835 --- /dev/null +++ b/site/contexts/UserContext.tsx @@ -0,0 +1,51 @@ +import { useRouter } from "next/router" +import React, { useContext, useEffect } from "react" +import useSWR from "swr" + +export interface User { + readonly id: string + readonly username: string + readonly email: string + readonly created_at: string +} + +export interface UserContext { + readonly error?: Error + readonly me?: User +} + +const UserContext = React.createContext({}) + +export const useUser = (redirectOnError = false): UserContext => { + const ctx = useContext(UserContext) + const router = useRouter() + + const requestError = ctx.error + useEffect(() => { + if (redirectOnError && requestError) { + router.push({ + pathname: "/login", + query: { + redirect: router.asPath, + }, + }) + } + }, [redirectOnError, requestError]) + + return ctx +} + +export const UserProvider: React.FC = (props) => { + const { data, error } = useSWR("/api/v2/user") + + return ( + + {props.children} + + ) +} diff --git a/site/pages/_app.tsx b/site/pages/_app.tsx index fea611109e..6c69513919 100644 --- a/site/pages/_app.tsx +++ b/site/pages/_app.tsx @@ -1,43 +1,10 @@ import React from "react" import CssBaseline from "@material-ui/core/CssBaseline" -import { makeStyles } from "@material-ui/core/styles" import ThemeProvider from "@material-ui/styles/ThemeProvider" - -import { dark } from "../theme" +import { SWRConfig } from "swr" import { AppProps } from "next/app" -import { Navbar } from "../components/Navbar" -import { Footer } from "../components/Page" - -/** - * `Contents` is the wrapper around the core app UI, - * containing common UI elements like the footer and navbar. - * - * This can't be inlined in `MyApp` because it requires styling, - * and `useStyles` needs to be inside a `` - */ -const Contents: React.FC = ({ Component, pageProps }) => { - const styles = useStyles() - - const header = ( -
- -
- ) - - const footer = ( -
-
-
- ) - - return ( -
- {header} - - {footer} -
- ) -} +import { UserProvider } from "../contexts/UserContext" +import { light } from "../theme" /** * ClientRender is a component that only allows its children to be rendered @@ -52,32 +19,30 @@ const ClientRender: React.FC = ({ children }) => ( * is the root rendering logic of the application - setting up our router * and any contexts / global state management. */ -const MyApp: React.FC = (appProps) => { +const MyApp: React.FC = ({ Component, pageProps }) => { return ( - - - - + { + const res = await fetch(url) + if (!res.ok) { + const err = new Error((await res.json()).error?.message || res.statusText) + throw err + } + return res.json() + }, + }} + > + + + + + + + ) } -const useStyles = makeStyles(() => ({ - root: { - display: "flex", - flexDirection: "column", - }, - header: { - flex: 0, - }, - body: { - height: "100%", - flex: 1, - }, - footer: { - flex: 0, - }, -})) - export default MyApp diff --git a/site/pages/index.tsx b/site/pages/index.tsx index 57767c414d..5780010f73 100644 --- a/site/pages/index.tsx +++ b/site/pages/index.tsx @@ -5,9 +5,18 @@ import Paper from "@material-ui/core/Paper" import AddWorkspaceIcon from "@material-ui/icons/AddToQueue" import { EmptyState, SplitButton } from "../components" +import { Navbar } from "../components/Navbar" +import { Footer } from "../components/Page" +import { useUser } from "../contexts/UserContext" +import { FullScreenLoader } from "../components/Loader/FullScreenLoader" const WorkspacesPage: React.FC = () => { const styles = useStyles() + const { me } = useUser(true) + + if (!me) { + return + } const createWorkspace = () => { alert("create") @@ -19,7 +28,8 @@ const WorkspacesPage: React.FC = () => { } return ( - <> +
+
color="primary" @@ -44,11 +54,16 @@ const WorkspacesPage: React.FC = () => { - +
+
) } const useStyles = makeStyles((theme) => ({ + root: { + display: "flex", + flexDirection: "column", + }, header: { display: "flex", flexDirection: "row-reverse", diff --git a/site/pages/login.tsx b/site/pages/login.tsx new file mode 100644 index 0000000000..c8103fe811 --- /dev/null +++ b/site/pages/login.tsx @@ -0,0 +1,30 @@ +import { makeStyles } from "@material-ui/core/styles" +import React from "react" +import { SignInForm } from "./../components/SignIn" + +export const useStyles = makeStyles((theme) => ({ + root: { + height: "100vh", + display: "flex", + justifyContent: "center", + alignItems: "center", + }, + container: { + marginTop: theme.spacing(-8), + minWidth: "320px", + maxWidth: "320px", + }, +})) + +export const SignInPage: React.FC = () => { + const styles = useStyles() + return ( +
+
+ +
+
+ ) +} + +export default SignInPage diff --git a/yarn.lock b/yarn.lock index 7026d23bde..1657a36418 100644 --- a/yarn.lock +++ b/yarn.lock @@ -277,7 +277,7 @@ dependencies: regenerator-runtime "^0.13.4" -"@babel/runtime@^7.10.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.16.3", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.7": +"@babel/runtime@^7.10.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.16.3", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.7": version "7.16.7" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.7.tgz#03ff99f64106588c9c403c6ecb8c3bafbbdff1fa" integrity sha512-9E9FJowqAsytyOY6LG+1KuueckRL+aQW+mKvXRXnuFGyRAyepJPmEo9vgMfXUA6O9u3IeEdv9MAkppFcaQwogQ== @@ -970,6 +970,11 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= +"@types/lodash@^4.14.175": + version "4.14.178" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.178.tgz#341f6d2247db528d4a13ddbb374bcdc80406f4f8" + integrity sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw== + "@types/mime@^1": version "1.3.2" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" @@ -2135,6 +2140,11 @@ deep-is@^0.1.3, deep-is@~0.1.3: resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== +deepmerge@^2.1.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-2.2.1.tgz#5d3ff22a01c00f645405a2fbc17d0778a1801170" + integrity sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA== + deepmerge@^4.2.2: version "4.2.2" resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" @@ -2877,6 +2887,19 @@ form-data@^3.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" +formik@2.2.9: + version "2.2.9" + resolved "https://registry.yarnpkg.com/formik/-/formik-2.2.9.tgz#8594ba9c5e2e5cf1f42c5704128e119fc46232d0" + integrity sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA== + dependencies: + deepmerge "^2.1.1" + hoist-non-react-statics "^3.3.0" + lodash "^4.17.21" + lodash-es "^4.17.21" + react-fast-compare "^2.0.1" + tiny-warning "^1.0.2" + tslib "^1.10.0" + forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" @@ -3069,7 +3092,7 @@ hmac-drbg@^1.0.1: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -hoist-non-react-statics@^3.3.2: +hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -4144,6 +4167,11 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" +lodash-es@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" + integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== + lodash.memoize@4.1.2, lodash.memoize@4.x: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" @@ -4164,7 +4192,7 @@ lodash.truncate@^4.4.2: resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193" integrity sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM= -lodash@^4.7.0: +lodash@^4.17.21, lodash@^4.7.0: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -4316,6 +4344,11 @@ ms@2.1.3, ms@^2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +nanoclone@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/nanoclone/-/nanoclone-0.2.1.tgz#dd4090f8f1a110d26bb32c49ed2f5b9235209ed4" + integrity sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA== + nanoid@^3.1.23: version "3.1.30" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.30.tgz#63f93cc548d2a113dc5dfbc63bfa09e2b9b64362" @@ -4331,6 +4364,11 @@ negotiator@0.6.2: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw== +next-router-mock@^0.6.5: + version "0.6.5" + resolved "https://registry.yarnpkg.com/next-router-mock/-/next-router-mock-0.6.5.tgz#46258e1921587a892e29a3052cb0daa2cc503185" + integrity sha512-gh6phWv4YUhFON0rWGmc02ni91m68ICG1HTj2N9bi2Y0MIlp5Z12QITXF4lNtV33wuMeUzrs/Ik6XyNOZ8rmNQ== + next@12.0.7: version "12.0.7" resolved "https://registry.yarnpkg.com/next/-/next-12.0.7.tgz#33ebf229b81b06e583ab5ae7613cffe1ca2103fc" @@ -4798,6 +4836,11 @@ prop-types@^15.6.2, prop-types@^15.7.2: object-assign "^4.1.1" react-is "^16.13.1" +property-expr@^2.0.4: + version "2.0.5" + resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.5.tgz#278bdb15308ae16af3e3b9640024524f4dc02cb4" + integrity sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA== + proxy-addr@~2.0.7: version "2.0.7" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" @@ -4899,6 +4942,11 @@ react-dom@17.0.2: object-assign "^4.1.1" scheduler "^0.20.2" +react-fast-compare@^2.0.1: + version "2.0.4" + resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" + integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw== + react-is@17.0.2, "react-is@^16.8.0 || ^17.0.0", react-is@^17.0.1: version "17.0.2" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" @@ -5426,6 +5474,11 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +swr@1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/swr/-/swr-1.1.2.tgz#9f3de2541931fccf03c48f322f1fc935a7551612" + integrity sha512-UsM0eo5T+kRPyWFZtWRx2XR5qzohs/LS4lDC0GCyLpCYFmsfTk28UCVDbOE9+KtoXY4FnwHYiF+ZYEU3hnJ1lQ== + symbol-tree@^3.2.4: version "3.2.4" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" @@ -5518,6 +5571,11 @@ toidentifier@1.0.1: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== +toposort@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330" + integrity sha1-riF2gXXRVZ1IvvNUILL0li8JwzA= + tough-cookie@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.0.0.tgz#d822234eeca882f991f0f908824ad2622ddbece4" @@ -5593,7 +5651,7 @@ tsconfig-paths@^3.12.0, tsconfig-paths@^3.9.0: minimist "^1.2.0" strip-bom "^3.0.0" -tslib@^1.8.1: +tslib@^1.10.0, tslib@^1.8.1: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== @@ -5939,3 +5997,16 @@ yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +yup@0.32.11: + version "0.32.11" + resolved "https://registry.yarnpkg.com/yup/-/yup-0.32.11.tgz#d67fb83eefa4698607982e63f7ca4c5ed3cf18c5" + integrity sha512-Z2Fe1bn+eLstG8DRR6FTavGD+MeAwyfmouhHsIUgaADz8jvFKbO/fXc2trJKZg+5EBjh4gGm3iU/t3onKlXHIg== + dependencies: + "@babel/runtime" "^7.15.4" + "@types/lodash" "^4.14.175" + lodash "^4.17.21" + lodash-es "^4.17.21" + nanoclone "^0.2.1" + property-expr "^2.0.4" + toposort "^2.0.2"