refactor(site): refactor login screen (#10768)

This commit is contained in:
Bruno Quaresma
2023-11-20 11:19:50 -03:00
committed by GitHub
parent 2895c108c2
commit fbec79f35d
11 changed files with 83 additions and 153 deletions

View File

@ -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"],

View File

@ -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" {

View File

@ -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);
});
}); });

View File

@ -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",

View File

@ -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>
); );

View File

@ -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>
); );

View File

@ -37,9 +37,6 @@ export const WithError: Story = {
}, },
], ],
}), }),
initialTouched: {
password: true,
},
}, },
}; };

View File

@ -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>
); );

View File

@ -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;
}

View File

@ -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;

View File

@ -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: {