mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00:00
feat: Initial Login flow (#42)
This just implements a basic sign-in flow, using the new endpoints in #29 :  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.
This commit is contained in:
7
_jest/setupTests.ts
Normal file
7
_jest/setupTests.ts
Normal file
@ -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"))
|
@ -5,6 +5,7 @@ module.exports = {
|
||||
displayName: "test",
|
||||
preset: "ts-jest",
|
||||
roots: ["<rootDir>/site"],
|
||||
setupFilesAfterEnv: ["<rootDir>/_jest/setupTests.ts"],
|
||||
transform: {
|
||||
"^.+\\.tsx?$": "ts-jest",
|
||||
},
|
||||
@ -26,8 +27,10 @@ module.exports = {
|
||||
"<rootDir>/site/**/*.tsx",
|
||||
"!<rootDir>/site/**/*.stories.tsx",
|
||||
"!<rootDir>/site/.next/**/*.*",
|
||||
"!<rootDir>/site/api.ts",
|
||||
"!<rootDir>/site/dev.ts",
|
||||
"!<rootDir>/site/next-env.d.ts",
|
||||
"!<rootDir>/site/next.config.js",
|
||||
"!<rootDir>/site/out/**/*.*",
|
||||
],
|
||||
}
|
||||
|
14
package.json
14
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"
|
||||
]
|
||||
}
|
||||
|
23
site/api.ts
Normal file
23
site/api.ts
Normal file
@ -0,0 +1,23 @@
|
||||
interface LoginResponse {
|
||||
session_token: string
|
||||
}
|
||||
|
||||
export const login = async (email: string, password: string): Promise<LoginResponse> => {
|
||||
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
|
||||
}
|
23
site/components/Button/LoadingButton.test.tsx
Normal file
23
site/components/Button/LoadingButton.test.tsx
Normal file
@ -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(<LoadingButton>Sign In</LoadingButton>)
|
||||
|
||||
// Then
|
||||
const element = await screen.findByText("Sign In")
|
||||
expect(element).toBeDefined()
|
||||
})
|
||||
|
||||
it("shows spinner if loading is set to true", async () => {
|
||||
// When
|
||||
render(<LoadingButton loading>Sign in</LoadingButton>)
|
||||
|
||||
// Then
|
||||
const spinnerElement = await screen.findByRole("progressbar")
|
||||
expect(spinnerElement).toBeDefined()
|
||||
})
|
||||
})
|
45
site/components/Button/LoadingButton.tsx
Normal file
45
site/components/Button/LoadingButton.tsx
Normal file
@ -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<LoadingButtonProps> = ({ loading = false, children, ...rest }) => {
|
||||
const styles = useStyles()
|
||||
const hidden = loading ? { opacity: 0 } : undefined
|
||||
|
||||
return (
|
||||
<Button {...rest} disabled={rest.disabled || loading}>
|
||||
<span style={hidden}>{children}</span>
|
||||
{loading && (
|
||||
<div className={styles.loader}>
|
||||
<CircularProgress size={18} className={styles.spinner} />
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}))
|
@ -1 +1,2 @@
|
||||
export { SplitButton } from "./SplitButton"
|
||||
export * from "./SplitButton"
|
||||
export * from "./LoadingButton"
|
||||
|
77
site/components/Form/FormTextField.test.tsx
Normal file
77
site/components/Form/FormTextField.test.tsx
Normal file
@ -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<FormValues>()
|
||||
|
||||
export const Component: React.FC<Omit<FormTextFieldProps<FormValues>, "form" | "formFieldName">> = (props) => {
|
||||
const form = useFormik<FormValues>({
|
||||
initialValues: {
|
||||
name: "",
|
||||
},
|
||||
onSubmit: (values, helpers) => {
|
||||
return helpers.setSubmitting(false)
|
||||
},
|
||||
validationSchema: yup.object({
|
||||
name: yup.string().required(requiredValidationMsg),
|
||||
}),
|
||||
})
|
||||
|
||||
return <FormTextField {...props} form={form} formFieldName="name" />
|
||||
}
|
||||
}
|
||||
|
||||
describe("FormTextField", () => {
|
||||
describe("helperText", () => {
|
||||
it("uses helperText prop when there are no errors", () => {
|
||||
// Given
|
||||
const props = {
|
||||
helperText: "testing",
|
||||
}
|
||||
|
||||
// When
|
||||
const { queryByText } = render(<Helpers.Component {...props} />)
|
||||
|
||||
// Then
|
||||
expect(queryByText(props.helperText)).toBeDefined()
|
||||
})
|
||||
|
||||
it("uses validation message when there are errors", () => {
|
||||
// Given
|
||||
const props = {}
|
||||
|
||||
// When
|
||||
const { container } = render(<Helpers.Component {...props} />)
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
170
site/components/Form/FormTextField.tsx
Normal file
170
site/components/Form/FormTextField.tsx
Normal file
@ -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<T> {
|
||||
/**
|
||||
* form is a reference to a form or subform and is used to compute common
|
||||
* states such as error and helper text
|
||||
*/
|
||||
form: FormikContextType<T>
|
||||
/**
|
||||
* 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<T>
|
||||
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<T> {
|
||||
/**
|
||||
* eventTransform is an optional transformer on the event data before it is
|
||||
* processed by formik.
|
||||
*
|
||||
* @example
|
||||
* <FormTextField
|
||||
* eventTransformer={(str) => {
|
||||
* 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<FormValues>()
|
||||
*
|
||||
* const MyComponent: React.FC = () => {
|
||||
* const form = useFormik<FormValues>()
|
||||
*
|
||||
* return (
|
||||
* <FormTextField
|
||||
* form={form}
|
||||
* formFieldName="username"
|
||||
* fullWidth
|
||||
* helperText="A unique name"
|
||||
* label="Username"
|
||||
* placeholder="Lorem Ipsum"
|
||||
* required
|
||||
* />
|
||||
* )
|
||||
* }
|
||||
*/
|
||||
export const formTextFieldFactory = <T,>(): React.FC<FormTextFieldProps<T>> => {
|
||||
const component: React.FC<FormTextFieldProps<T>> = ({
|
||||
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 (
|
||||
<Component
|
||||
{...rest}
|
||||
variant={variant}
|
||||
disabled={disabled || form.isSubmitting}
|
||||
error={isError}
|
||||
helperText={isError ? form.errors[formFieldName] : helperText}
|
||||
id={fieldId}
|
||||
InputProps={isPassword ? undefined : InputProps}
|
||||
name={fieldId}
|
||||
onBlur={form.handleBlur}
|
||||
onChange={(e) => {
|
||||
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}
|
||||
</Component>
|
||||
)
|
||||
}
|
||||
|
||||
// Required when using an anonymous factory function
|
||||
component.displayName = "FormTextField"
|
||||
return component
|
||||
}
|
14
site/components/Form/PasswordField.test.tsx
Normal file
14
site/components/Form/PasswordField.test.tsx
Normal file
@ -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(<PasswordField helperText="Enter password" />)
|
||||
|
||||
// Then
|
||||
const element = await screen.findByText("Enter password")
|
||||
expect(element).toBeDefined()
|
||||
})
|
||||
})
|
40
site/components/Form/PasswordField.tsx
Normal file
40
site/components/Form/PasswordField.tsx
Normal file
@ -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<TextFieldProps, "InputProps" | "type">
|
||||
|
||||
export const PasswordField: React.FC<PasswordFieldProps> = ({ variant = "outlined", ...rest }) => {
|
||||
const styles = useStyles()
|
||||
const [showPassword, setShowPassword] = useState<boolean>(false)
|
||||
|
||||
const handleVisibilityChange = useCallback(() => setShowPassword((showPassword) => !showPassword), [])
|
||||
const VisibilityIcon = showPassword ? VisibilityOffOutlined : VisibilityOutlined
|
||||
|
||||
return (
|
||||
<TextField
|
||||
{...rest}
|
||||
type={showPassword ? "text" : "password"}
|
||||
variant={variant}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton aria-label="toggle password visibility" onClick={handleVisibilityChange} size="small">
|
||||
<VisibilityIcon className={styles.visibilityIcon} />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
visibilityIcon: {
|
||||
fontSize: 20,
|
||||
},
|
||||
})
|
1
site/components/Form/index.tsx
Normal file
1
site/components/Form/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export * from "./FormTextField"
|
17
site/components/Icons/CoderIcon.tsx
Normal file
17
site/components/Icons/CoderIcon.tsx
Normal file
@ -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 => (
|
||||
<SvgIcon {...props} viewBox="0 0 68 49">
|
||||
<path d="M66.3575 21.3584C65.0024 21.3584 64.099 20.5638 64.099 18.9328V9.5647C64.099 3.58419 61.6353 0.280273 55.2705 0.280273H52.314V6.59536H53.2174C55.7222 6.59536 56.913 7.97547 56.913 10.443V18.7237C56.913 22.3203 57.9807 23.7841 60.3212 24.5369C57.9807 25.2479 56.913 26.7534 56.913 30.3501C56.913 32.3994 56.913 34.4486 56.913 36.4979C56.913 38.2126 56.913 39.8855 56.4613 41.6002C56.0097 43.1894 55.2705 44.695 54.244 45.9914C53.6691 46.7442 53.0121 47.3716 52.2729 47.9571V48.7935H55.2295C61.5942 48.7935 64.058 45.4896 64.058 39.5091V30.141C64.058 28.4681 64.9203 27.7153 66.3164 27.7153H68V21.4003H66.3575V21.3584Z" />
|
||||
<path d="M46.2367 9.81532H37.1208C36.9155 9.81532 36.7512 9.64804 36.7512 9.43893V8.72796C36.7512 8.51885 36.9155 8.35156 37.1208 8.35156H46.2778C46.4831 8.35156 46.6473 8.51885 46.6473 8.72796V9.43893C46.6473 9.64804 46.442 9.81532 46.2367 9.81532Z" />
|
||||
<path d="M47.7971 18.8485H41.145C40.9396 18.8485 40.7754 18.6812 40.7754 18.4721V17.7612C40.7754 17.5521 40.9396 17.3848 41.145 17.3848H47.7971C48.0024 17.3848 48.1667 17.5521 48.1667 17.7612V18.4721C48.1667 18.6394 48.0024 18.8485 47.7971 18.8485Z" />
|
||||
<path d="M50.4251 14.3319H37.1208C36.9155 14.3319 36.7512 14.1646 36.7512 13.9555V13.2446C36.7512 13.0355 36.9155 12.8682 37.1208 12.8682H50.384C50.5894 12.8682 50.7536 13.0355 50.7536 13.2446V13.9555C50.7536 14.1228 50.6304 14.3319 50.4251 14.3319Z" />
|
||||
<path d="M26.5677 11.8649C27.4711 11.8649 28.3744 11.9485 29.2368 12.1577V10.443C29.2368 8.0173 30.4686 6.59536 32.9324 6.59536H33.8358V0.280273H30.8793C24.5145 0.280273 22.0508 3.58419 22.0508 9.5647V12.6595C23.488 12.1577 25.0073 11.8649 26.5677 11.8649Z" />
|
||||
<path d="M53.2174 34.6165C52.5603 29.3051 48.5362 24.872 43.3623 23.8683C41.9251 23.5755 40.4879 23.5337 39.0918 23.7847C39.0507 23.7847 39.0507 23.7428 39.0096 23.7428C36.7512 18.9333 31.9058 15.7549 26.6497 15.7549C21.3937 15.7549 16.5894 18.8497 14.2898 23.6592C14.2488 23.6592 14.2488 23.701 14.2077 23.701C12.7295 23.5337 11.2512 23.6174 9.77294 23.9938C4.68116 25.2484 0.821255 29.5979 0.123188 34.8674C0.0410628 35.4111 0 35.9548 0 36.4566C0 38.0459 1.06763 39.5096 2.62802 39.7187C4.55797 40.0115 6.24154 38.5059 6.20048 36.5821C6.20048 36.2894 6.20048 35.9548 6.24154 35.662C6.57004 32.9854 8.58212 30.7271 11.2101 30.0997C12.0314 29.8906 12.8526 29.8488 13.6328 29.9743C16.1377 30.3088 18.6014 29.0124 19.6691 26.754C20.4493 25.0811 21.6811 23.6174 23.3237 22.8228C25.1304 21.9445 27.1836 21.819 29.0724 22.4882C31.0435 23.1992 32.5217 24.7047 33.4251 26.5867C34.3695 28.4269 34.8212 29.7233 36.8333 29.9743C37.6546 30.0997 39.9541 30.0579 40.8164 30.0161C42.5 30.0161 44.1835 30.6016 45.3744 31.8144C46.1546 32.6509 46.7294 33.6964 46.9758 34.8674C47.3454 36.7494 46.8937 38.6314 45.785 40.0533C45.0048 41.057 43.9372 41.8098 42.7463 42.1444C42.1715 42.3117 41.5966 42.3535 41.0217 42.3535C40.6932 42.3535 40.2415 42.3535 39.7077 42.3535C38.0652 42.3535 34.5749 42.3535 31.9468 42.3535C30.1401 42.3535 28.7029 40.8897 28.7029 39.0496V32.86V26.7958C28.7029 26.294 28.2922 25.8757 27.7995 25.8757H26.5265C24.0217 25.9176 22.0096 28.7614 22.0096 31.7726C22.0096 34.7838 22.0096 42.7717 22.0096 42.7717C22.0096 46.0338 24.5966 48.6686 27.7995 48.6686C27.7995 48.6686 42.0483 48.6268 42.2536 48.6268C45.5386 48.2922 48.5773 46.5775 50.6304 43.9427C52.6835 41.3916 53.628 38.0459 53.2174 34.6165Z" />
|
||||
</SvgIcon>
|
||||
)
|
@ -1,2 +1,3 @@
|
||||
export { CoderIcon } from "./CoderIcon"
|
||||
export { Logo } from "./Logo"
|
||||
export { WorkspacesIcon } from "./WorkspacesIcon"
|
||||
|
14
site/components/Loader/FullScreenLoader.test.tsx
Normal file
14
site/components/Loader/FullScreenLoader.test.tsx
Normal file
@ -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(<FullScreenLoader />)
|
||||
|
||||
// Then
|
||||
const element = await screen.findByRole("progressbar")
|
||||
expect(element).toBeDefined()
|
||||
})
|
||||
})
|
26
site/components/Loader/FullScreenLoader.tsx
Normal file
26
site/components/Loader/FullScreenLoader.tsx
Normal file
@ -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 (
|
||||
<div className={styles.root}>
|
||||
<CircularProgress />
|
||||
</div>
|
||||
)
|
||||
}
|
@ -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<NavbarProps> = () => {
|
||||
const styles = useStyles()
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
@ -35,6 +39,7 @@ const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
flex: "0",
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
|
@ -26,6 +26,7 @@ const useFooterStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
textAlign: "center",
|
||||
marginBottom: theme.spacing(5),
|
||||
flex: "0",
|
||||
},
|
||||
copyRight: {
|
||||
backgroundColor: theme.palette.background.default,
|
||||
|
61
site/components/SignIn/SignInForm.test.tsx
Normal file
61
site/components/SignIn/SignInForm.test.tsx
Normal file
@ -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(<SignInForm />)
|
||||
|
||||
// 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(<SignInForm loginHandler={loginHandler} />)
|
||||
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(<SignInForm loginHandler={loginHandler} />)
|
||||
// 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: "/" }))
|
||||
})
|
||||
})
|
146
site/components/SignIn/SignInForm.tsx
Normal file
146
site/components/SignIn/SignInForm.tsx
Normal file
@ -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<BuiltInAuthFormValues>()
|
||||
|
||||
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<void>
|
||||
}
|
||||
|
||||
export const SignInForm: React.FC<SignInProps> = ({
|
||||
loginHandler = (email: string, password: string) => API.login(email, password),
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const styles = useStyles()
|
||||
const { mutate } = useSWRConfig()
|
||||
|
||||
const form: FormikContextType<BuiltInAuthFormValues> = useFormik<BuiltInAuthFormValues>({
|
||||
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 (
|
||||
<>
|
||||
<Welcome />
|
||||
<form onSubmit={form.handleSubmit}>
|
||||
<div>
|
||||
<FormTextField
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
eventTransform={(email: string) => email.trim()}
|
||||
form={form}
|
||||
formFieldName="email"
|
||||
fullWidth
|
||||
inputProps={{
|
||||
id: "signin-form-inpt-email",
|
||||
}}
|
||||
margin="none"
|
||||
placeholder="Email"
|
||||
variant="outlined"
|
||||
/>
|
||||
<FormTextField
|
||||
autoComplete="current-password"
|
||||
form={form}
|
||||
formFieldName="password"
|
||||
fullWidth
|
||||
inputProps={{
|
||||
id: "signin-form-inpt-password",
|
||||
}}
|
||||
isPassword
|
||||
margin="none"
|
||||
placeholder="Password"
|
||||
variant="outlined"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.submitBtn}>
|
||||
<LoadingButton
|
||||
color="primary"
|
||||
loading={form.isSubmitting}
|
||||
fullWidth
|
||||
id="signin-form-submit"
|
||||
type="submit"
|
||||
variant="contained"
|
||||
>
|
||||
Sign In
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
}
|
42
site/components/SignIn/Welcome.tsx
Normal file
42
site/components/SignIn/Welcome.tsx
Normal file
@ -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 (
|
||||
<div>
|
||||
<div className={styles.logoBox}>
|
||||
<CoderIcon className={styles.logo} />
|
||||
</div>
|
||||
<Typography className={styles.title} variant="h1">
|
||||
<>
|
||||
Welcome to
|
||||
<br />
|
||||
Coder
|
||||
</>
|
||||
</Typography>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
}))
|
1
site/components/SignIn/index.tsx
Normal file
1
site/components/SignIn/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export * from "./SignInForm"
|
100
site/contexts/UserContext.test.tsx
Normal file
100
site/contexts/UserContext.test.tsx
Normal file
@ -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 <div>{`Error: ${error.toString()}`}</div>
|
||||
}
|
||||
if (me) {
|
||||
return <div>{`Me: ${me.toString()}`}</div>
|
||||
}
|
||||
|
||||
return <div>Loading</div>
|
||||
}
|
||||
|
||||
// Helper to render a userContext, and all the scaffolding needed
|
||||
// (an SWRConfig as well as a UserPRovider)
|
||||
export const renderUserContext = (
|
||||
simulatedRequest: () => Promise<User>,
|
||||
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.
|
||||
<SWRConfig
|
||||
value={{
|
||||
fetcher: simulatedRequest,
|
||||
// Reset cache for every test. Without this, requests will be cached between test cases.
|
||||
provider: () => new Map(),
|
||||
}}
|
||||
>
|
||||
<UserProvider>
|
||||
<TestComponent redirectOnFailure={redirectOnFailure} />
|
||||
</UserProvider>
|
||||
</SWRConfig>
|
||||
)
|
||||
}
|
||||
|
||||
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: "/" })
|
||||
})
|
||||
})
|
51
site/contexts/UserContext.tsx
Normal file
51
site/contexts/UserContext.tsx
Normal file
@ -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<UserContext>({})
|
||||
|
||||
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 (
|
||||
<UserContext.Provider
|
||||
value={{
|
||||
error: error,
|
||||
me: data,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</UserContext.Provider>
|
||||
)
|
||||
}
|
@ -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 `<ThemeProvider />`
|
||||
*/
|
||||
const Contents: React.FC<AppProps> = ({ Component, pageProps }) => {
|
||||
const styles = useStyles()
|
||||
|
||||
const header = (
|
||||
<div className={styles.header}>
|
||||
<Navbar />
|
||||
</div>
|
||||
)
|
||||
|
||||
const footer = (
|
||||
<div className={styles.footer}>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{header}
|
||||
<Component {...pageProps} />
|
||||
{footer}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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 }) => (
|
||||
* <App /> is the root rendering logic of the application - setting up our router
|
||||
* and any contexts / global state management.
|
||||
*/
|
||||
const MyApp: React.FC<AppProps> = (appProps) => {
|
||||
const MyApp: React.FC<AppProps> = ({ Component, pageProps }) => {
|
||||
return (
|
||||
<ClientRender>
|
||||
<ThemeProvider theme={dark}>
|
||||
<CssBaseline />
|
||||
<Contents {...appProps} />
|
||||
</ThemeProvider>
|
||||
<SWRConfig
|
||||
value={{
|
||||
fetcher: async (url: string) => {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) {
|
||||
const err = new Error((await res.json()).error?.message || res.statusText)
|
||||
throw err
|
||||
}
|
||||
return res.json()
|
||||
},
|
||||
}}
|
||||
>
|
||||
<UserProvider>
|
||||
<ThemeProvider theme={light}>
|
||||
<CssBaseline />
|
||||
<Component {...pageProps} />
|
||||
</ThemeProvider>
|
||||
</UserProvider>
|
||||
</SWRConfig>
|
||||
</ClientRender>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
root: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
header: {
|
||||
flex: 0,
|
||||
},
|
||||
body: {
|
||||
height: "100%",
|
||||
flex: 1,
|
||||
},
|
||||
footer: {
|
||||
flex: 0,
|
||||
},
|
||||
}))
|
||||
|
||||
export default MyApp
|
||||
|
@ -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 <FullScreenLoader />
|
||||
}
|
||||
|
||||
const createWorkspace = () => {
|
||||
alert("create")
|
||||
@ -19,7 +28,8 @@ const WorkspacesPage: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.root}>
|
||||
<Navbar user={me} />
|
||||
<div className={styles.header}>
|
||||
<SplitButton<string>
|
||||
color="primary"
|
||||
@ -44,11 +54,16 @@ const WorkspacesPage: React.FC = () => {
|
||||
<EmptyState message="No workspaces available." button={button} />
|
||||
</Box>
|
||||
</Paper>
|
||||
</>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
header: {
|
||||
display: "flex",
|
||||
flexDirection: "row-reverse",
|
||||
|
30
site/pages/login.tsx
Normal file
30
site/pages/login.tsx
Normal file
@ -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 (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.container}>
|
||||
<SignInForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SignInPage
|
79
yarn.lock
79
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"
|
||||
|
Reference in New Issue
Block a user