mirror of
https://github.com/coder/coder.git
synced 2025-07-09 11:45:56 +00:00
refactor(site): refactor login screen (#10768)
This commit is contained in:
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -170,6 +170,7 @@
|
||||
"wsconncache",
|
||||
"wsjson",
|
||||
"xerrors",
|
||||
"xlarge",
|
||||
"yamux"
|
||||
],
|
||||
"cSpell.ignorePaths": ["site/package.json", ".vscode/settings.json"],
|
||||
|
4
site/src/@types/mui.d.ts
vendored
4
site/src/@types/mui.d.ts
vendored
@ -31,6 +31,10 @@ declare module "@mui/material/Button" {
|
||||
interface ButtonPropsColorOverrides {
|
||||
neutral: true;
|
||||
}
|
||||
|
||||
interface ButtonPropsSizeOverrides {
|
||||
xlarge: true;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "@mui/system" {
|
||||
|
@ -10,7 +10,6 @@ import {
|
||||
} from "testHelpers/renderHelpers";
|
||||
import { server } from "testHelpers/server";
|
||||
import { LoginPage } from "./LoginPage";
|
||||
import * as TypesGen from "api/typesGenerated";
|
||||
|
||||
describe("LoginPage", () => {
|
||||
beforeEach(() => {
|
||||
@ -76,32 +75,4 @@ describe("LoginPage", () => {
|
||||
// Then
|
||||
await screen.findByText("Setup");
|
||||
});
|
||||
|
||||
it("hides password authentication if OIDC/GitHub is enabled and displays on click", async () => {
|
||||
const authMethods: TypesGen.AuthMethods = {
|
||||
password: { enabled: true },
|
||||
github: { enabled: true },
|
||||
oidc: { enabled: true, signInText: "", iconUrl: "" },
|
||||
};
|
||||
|
||||
// Given
|
||||
server.use(
|
||||
rest.get("/api/v2/users/authmethods", async (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json(authMethods));
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
render(<LoginPage />);
|
||||
|
||||
// Then
|
||||
expect(screen.queryByText(Language.passwordSignIn)).not.toBeInTheDocument();
|
||||
await screen.findByText(Language.githubSignIn);
|
||||
|
||||
const showPasswordAuthLink = screen.getByText("Email and password");
|
||||
await userEvent.click(showPasswordAuthLink);
|
||||
|
||||
await screen.findByText(Language.passwordSignIn);
|
||||
await screen.findByText(Language.githubSignIn);
|
||||
});
|
||||
});
|
||||
|
@ -75,7 +75,7 @@ const styles = {
|
||||
|
||||
container: {
|
||||
width: "100%",
|
||||
maxWidth: 385,
|
||||
maxWidth: 320,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
|
@ -1,4 +1,3 @@
|
||||
import Link from "@mui/material/Link";
|
||||
import Button from "@mui/material/Button";
|
||||
import GitHubIcon from "@mui/icons-material/GitHub";
|
||||
import KeyIcon from "@mui/icons-material/VpnKey";
|
||||
@ -26,49 +25,47 @@ export const OAuthSignInForm: FC<OAuthSignInFormProps> = ({
|
||||
return (
|
||||
<Box display="grid" gap="16px">
|
||||
{authMethods?.github.enabled && (
|
||||
<Link
|
||||
<Button
|
||||
component="a"
|
||||
href={`/api/v2/users/oauth2/github/callback?redirect=${encodeURIComponent(
|
||||
redirectTo,
|
||||
)}`}
|
||||
variant="contained"
|
||||
startIcon={<GitHubIcon css={iconStyles} />}
|
||||
disabled={isSigningIn}
|
||||
fullWidth
|
||||
type="submit"
|
||||
size="xlarge"
|
||||
>
|
||||
<Button
|
||||
startIcon={<GitHubIcon css={iconStyles} />}
|
||||
disabled={isSigningIn}
|
||||
fullWidth
|
||||
type="submit"
|
||||
size="large"
|
||||
>
|
||||
{Language.githubSignIn}
|
||||
</Button>
|
||||
</Link>
|
||||
{Language.githubSignIn}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{authMethods?.oidc.enabled && (
|
||||
<Link
|
||||
<Button
|
||||
component="a"
|
||||
href={`/api/v2/users/oidc/callback?redirect=${encodeURIComponent(
|
||||
redirectTo,
|
||||
)}`}
|
||||
variant="contained"
|
||||
size="xlarge"
|
||||
startIcon={
|
||||
authMethods.oidc.iconUrl ? (
|
||||
<img
|
||||
alt="Open ID Connect icon"
|
||||
src={authMethods.oidc.iconUrl}
|
||||
css={iconStyles}
|
||||
/>
|
||||
) : (
|
||||
<KeyIcon css={iconStyles} />
|
||||
)
|
||||
}
|
||||
disabled={isSigningIn}
|
||||
fullWidth
|
||||
type="submit"
|
||||
>
|
||||
<Button
|
||||
size="large"
|
||||
startIcon={
|
||||
authMethods.oidc.iconUrl ? (
|
||||
<img
|
||||
alt="Open ID Connect icon"
|
||||
src={authMethods.oidc.iconUrl}
|
||||
css={iconStyles}
|
||||
/>
|
||||
) : (
|
||||
<KeyIcon css={iconStyles} />
|
||||
)
|
||||
}
|
||||
disabled={isSigningIn}
|
||||
fullWidth
|
||||
type="submit"
|
||||
>
|
||||
{authMethods.oidc.signInText || Language.oidcSignIn}
|
||||
</Button>
|
||||
</Link>
|
||||
{authMethods.oidc.signInText || Language.oidcSignIn}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
@ -2,22 +2,21 @@ import { Stack } from "components/Stack/Stack";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { getFormHelpers, onChangeTrimmed } from "utils/formUtils";
|
||||
import { Language } from "./SignInForm";
|
||||
import { FormikContextType, FormikTouched, useFormik } from "formik";
|
||||
import { useFormik } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import { FC } from "react";
|
||||
import { BuiltInAuthFormValues } from "./SignInForm.types";
|
||||
import LoadingButton from "@mui/lab/LoadingButton";
|
||||
|
||||
type PasswordSignInFormProps = {
|
||||
onSubmit: (credentials: { email: string; password: string }) => void;
|
||||
initialTouched?: FormikTouched<BuiltInAuthFormValues>;
|
||||
isSigningIn: boolean;
|
||||
autoFocus: boolean;
|
||||
};
|
||||
|
||||
export const PasswordSignInForm: FC<PasswordSignInFormProps> = ({
|
||||
onSubmit,
|
||||
initialTouched,
|
||||
isSigningIn,
|
||||
autoFocus,
|
||||
}) => {
|
||||
const validationSchema = Yup.object({
|
||||
email: Yup.string()
|
||||
@ -27,17 +26,16 @@ export const PasswordSignInForm: FC<PasswordSignInFormProps> = ({
|
||||
password: Yup.string(),
|
||||
});
|
||||
|
||||
const form: FormikContextType<BuiltInAuthFormValues> =
|
||||
useFormik<BuiltInAuthFormValues>({
|
||||
initialValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit,
|
||||
initialTouched,
|
||||
});
|
||||
const getFieldHelpers = getFormHelpers<BuiltInAuthFormValues>(form);
|
||||
const form = useFormik({
|
||||
initialValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit,
|
||||
validateOnBlur: false,
|
||||
});
|
||||
const getFieldHelpers = getFormHelpers(form);
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit}>
|
||||
@ -45,7 +43,7 @@ export const PasswordSignInForm: FC<PasswordSignInFormProps> = ({
|
||||
<TextField
|
||||
{...getFieldHelpers("email")}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
autoFocus
|
||||
autoFocus={autoFocus}
|
||||
autoComplete="email"
|
||||
fullWidth
|
||||
label={Language.emailLabel}
|
||||
@ -59,16 +57,14 @@ export const PasswordSignInForm: FC<PasswordSignInFormProps> = ({
|
||||
label={Language.passwordLabel}
|
||||
type="password"
|
||||
/>
|
||||
<div>
|
||||
<LoadingButton
|
||||
size="large"
|
||||
loading={isSigningIn}
|
||||
fullWidth
|
||||
type="submit"
|
||||
>
|
||||
{Language.passwordSignIn}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
<LoadingButton
|
||||
size="xlarge"
|
||||
loading={isSigningIn}
|
||||
fullWidth
|
||||
type="submit"
|
||||
>
|
||||
{Language.passwordSignIn}
|
||||
</LoadingButton>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
|
@ -37,9 +37,6 @@ export const WithError: Story = {
|
||||
},
|
||||
],
|
||||
}),
|
||||
initialTouched: {
|
||||
password: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -1,15 +1,10 @@
|
||||
import { type Interpolation, type Theme } from "@emotion/react";
|
||||
import { type FormikTouched } from "formik";
|
||||
import { type FC, useState } from "react";
|
||||
import { type FC } from "react";
|
||||
import type { AuthMethods } from "api/typesGenerated";
|
||||
import { PasswordSignInForm } from "./PasswordSignInForm";
|
||||
import { OAuthSignInForm } from "./OAuthSignInForm";
|
||||
import { type BuiltInAuthFormValues } from "./SignInForm.types";
|
||||
import Button from "@mui/material/Button";
|
||||
import EmailIcon from "@mui/icons-material/EmailOutlined";
|
||||
import { Alert } from "components/Alert/Alert";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { getApplicationName } from "utils/appearance";
|
||||
|
||||
export const Language = {
|
||||
emailLabel: "Email",
|
||||
@ -71,8 +66,6 @@ export interface SignInFormProps {
|
||||
info?: string;
|
||||
authMethods?: AuthMethods;
|
||||
onSubmit: (credentials: { email: string; password: string }) => void;
|
||||
// initialTouched is only used for testing the error state of the form.
|
||||
initialTouched?: FormikTouched<BuiltInAuthFormValues>;
|
||||
}
|
||||
|
||||
export const SignInForm: FC<React.PropsWithChildren<SignInFormProps>> = ({
|
||||
@ -82,21 +75,15 @@ export const SignInForm: FC<React.PropsWithChildren<SignInFormProps>> = ({
|
||||
error,
|
||||
info,
|
||||
onSubmit,
|
||||
initialTouched,
|
||||
}) => {
|
||||
const oAuthEnabled = Boolean(
|
||||
authMethods?.github.enabled || authMethods?.oidc.enabled,
|
||||
);
|
||||
const passwordEnabled = authMethods?.password.enabled ?? true;
|
||||
// Hide password auth by default if any OAuth method is enabled
|
||||
const [showPasswordAuth, setShowPasswordAuth] = useState(!oAuthEnabled);
|
||||
const applicationName = getApplicationName();
|
||||
|
||||
return (
|
||||
<div css={styles.root}>
|
||||
<h1 css={styles.title}>
|
||||
Sign in to <strong>{applicationName}</strong>
|
||||
</h1>
|
||||
<h1 css={styles.title}>Sign in</h1>
|
||||
|
||||
{Boolean(error) && (
|
||||
<div css={styles.alert}>
|
||||
@ -110,22 +97,6 @@ export const SignInForm: FC<React.PropsWithChildren<SignInFormProps>> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{passwordEnabled && showPasswordAuth && (
|
||||
<PasswordSignInForm
|
||||
onSubmit={onSubmit}
|
||||
initialTouched={initialTouched}
|
||||
isSigningIn={isSigningIn}
|
||||
/>
|
||||
)}
|
||||
|
||||
{passwordEnabled && showPasswordAuth && oAuthEnabled && (
|
||||
<div css={styles.divider}>
|
||||
<div css={styles.dividerLine} />
|
||||
<div css={styles.dividerLabel}>Or</div>
|
||||
<div css={styles.dividerLine} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{oAuthEnabled && (
|
||||
<OAuthSignInForm
|
||||
isSigningIn={isSigningIn}
|
||||
@ -134,27 +105,24 @@ export const SignInForm: FC<React.PropsWithChildren<SignInFormProps>> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{!passwordEnabled && !oAuthEnabled && (
|
||||
<Alert severity="error">No authentication methods configured!</Alert>
|
||||
{passwordEnabled && oAuthEnabled && (
|
||||
<div css={styles.divider}>
|
||||
<div css={styles.dividerLine} />
|
||||
<div css={styles.dividerLabel}>Or</div>
|
||||
<div css={styles.dividerLine} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{passwordEnabled && !showPasswordAuth && (
|
||||
<>
|
||||
<div css={styles.divider}>
|
||||
<div css={styles.dividerLine} />
|
||||
<div css={styles.dividerLabel}>Or</div>
|
||||
<div css={styles.dividerLine} />
|
||||
</div>
|
||||
{passwordEnabled && (
|
||||
<PasswordSignInForm
|
||||
onSubmit={onSubmit}
|
||||
autoFocus={!oAuthEnabled}
|
||||
isSigningIn={isSigningIn}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
size="large"
|
||||
onClick={() => setShowPasswordAuth(true)}
|
||||
startIcon={<EmailIcon css={styles.icon} />}
|
||||
>
|
||||
Email and password
|
||||
</Button>
|
||||
</>
|
||||
{!passwordEnabled && !oAuthEnabled && (
|
||||
<Alert severity="error">No authentication methods configured!</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
@ -1,9 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export interface BuiltInAuthFormValues {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
@ -9,6 +9,7 @@ export const sidePadding = 24;
|
||||
export const dashboardContentBottomPadding = 8 * 6;
|
||||
|
||||
// MUI does not have aligned heights for buttons and inputs so we have to "hack" it a little bit
|
||||
export const BUTTON_XL_HEIGHT = 44;
|
||||
export const BUTTON_LG_HEIGHT = 40;
|
||||
export const BUTTON_MD_HEIGHT = 36;
|
||||
export const BUTTON_SM_HEIGHT = 32;
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
BUTTON_LG_HEIGHT,
|
||||
BUTTON_MD_HEIGHT,
|
||||
BUTTON_SM_HEIGHT,
|
||||
BUTTON_XL_HEIGHT,
|
||||
} from "./constants";
|
||||
// eslint-disable-next-line no-restricted-imports -- We need MUI here
|
||||
import { alertClasses } from "@mui/material/Alert";
|
||||
@ -172,6 +173,9 @@ dark = createTheme(dark, {
|
||||
sizeLarge: {
|
||||
height: BUTTON_LG_HEIGHT,
|
||||
},
|
||||
sizeXlarge: {
|
||||
height: BUTTON_XL_HEIGHT,
|
||||
},
|
||||
outlined: {
|
||||
":hover": {
|
||||
border: `1px solid ${colors.gray[11]}`,
|
||||
@ -190,10 +194,10 @@ dark = createTheme(dark, {
|
||||
},
|
||||
},
|
||||
containedNeutral: {
|
||||
borderColor: colors.gray[12],
|
||||
backgroundColor: colors.gray[13],
|
||||
backgroundColor: colors.gray[14],
|
||||
|
||||
"&:hover": {
|
||||
backgroundColor: colors.gray[12],
|
||||
backgroundColor: colors.gray[13],
|
||||
},
|
||||
},
|
||||
iconSizeMedium: {
|
||||
|
Reference in New Issue
Block a user