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