Skip to content

Commit

Permalink
Clarity Login form (#1842)
Browse files Browse the repository at this point in the history
  • Loading branch information
Andres Martinez Gotor committed Jul 6, 2020
1 parent 7adf777 commit 34149b5
Show file tree
Hide file tree
Showing 11 changed files with 576 additions and 7 deletions.
2 changes: 1 addition & 1 deletion dashboard/src/components/LoginForm/LoginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Redirect } from "react-router";
import LoadingWrapper from "../../components/LoadingWrapper";
import "./LoginForm.css";

interface ILoginFormProps {
export interface ILoginFormProps {
authenticated: boolean;
authenticating: boolean;
authenticationError: string | undefined;
Expand Down
34 changes: 34 additions & 0 deletions dashboard/src/components/LoginForm/LoginForm.v2.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
.login {
&-wrapper {
.login {
min-height: calc(100vh - 3rem);
.login-group {
padding: 0 0 0 0;
}
.error.active {
font-size: small;
}
}
}
&-submit-button {
display: grid;
padding: 1rem 0 0 0;
}
&-moreinfo {
display: grid;
justify-content: end;
margin-top: 0.5rem;
svg {
margin-top: -0.1rem;
}
}
}

.title h3 span {
font-size: xx-large;
display: block;
}

#login-submit-button {
display: contents;
}
159 changes: 159 additions & 0 deletions dashboard/src/components/LoginForm/LoginForm.v2.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { mount, shallow } from "enzyme";
import { Location } from "history";
import context from "jest-plugin-context";
import * as React from "react";
import { act } from "react-dom/test-utils";
import { Redirect } from "react-router";
import itBehavesLike from "../../shared/specs";
import LoginForm from "./LoginForm.v2";
import OAuthLogin from "./OauthLogin";
import TokenLogin from "./TokenLogin";

const emptyLocation: Location = {
hash: "",
pathname: "",
search: "",
state: "",
};

const defaultProps = {
authenticate: jest.fn(),
authenticated: false,
authenticating: false,
authenticationError: undefined,
location: emptyLocation,
checkCookieAuthentication: jest.fn(),
oauthLoginURI: "",
appVersion: "devel",
};

const authenticationError = "it's a trap";

describe("componentDidMount", () => {
it("calls checkCookieAuthentication when oauthLoginURI provided", () => {
const props = {
...defaultProps,
oauthLoginURI: "/sign/in",
};
const checkCookieAuthentication = jest.fn();
mount(<LoginForm {...props} checkCookieAuthentication={checkCookieAuthentication} />);
expect(checkCookieAuthentication).toHaveBeenCalled();
});

it("does not call checkCookieAuthentication when oauthLoginURI not provided", () => {
const checkCookieAuthentication = jest.fn();
mount(<LoginForm {...defaultProps} checkCookieAuthentication={checkCookieAuthentication} />);
expect(checkCookieAuthentication).not.toHaveBeenCalled();
});
});

context("while authenticating", () => {
itBehavesLike("aLoadingComponent", {
component: LoginForm,
props: { ...defaultProps, authenticating: true },
});
});

describe("token login form", () => {
it("renders a token login form", () => {
const wrapper = shallow(<LoginForm {...defaultProps} />);
expect(wrapper.find(TokenLogin)).toExist();
expect(wrapper.find(OAuthLogin)).not.toExist();
expect(wrapper).toMatchSnapshot();
});

it("renders a link to the access control documentation", () => {
const wrapper = shallow(<LoginForm {...defaultProps} />);
expect(wrapper.find("a").props()).toMatchObject({
href: "https://github.com/kubeapps/kubeapps/blob/devel/docs/user/access-control.md",
target: "_blank",
});
});

it("updates the token in the state when the input is changed", () => {
const wrapper = mount(<LoginForm {...defaultProps} />);
let input = wrapper.find("input#token");
act(() => {
input.simulate("change", {
target: { value: "f00b4r" },
current: { value: "ff00b4r" },
});
});
wrapper.update();
input = wrapper.find("input#token");
expect(input.prop("value")).toBe("f00b4r");
});

describe("redirect if authenticated", () => {
it("redirects to / if no current location", () => {
const wrapper = shallow(<LoginForm {...defaultProps} authenticated={true} />);
const redirect = wrapper.find(Redirect);
expect(redirect.props()).toEqual({ to: { pathname: "/" } });
});

it("redirects to previous location", () => {
const location = Object.assign({}, emptyLocation);
location.state = { from: "/test" };
const wrapper = shallow(
<LoginForm {...defaultProps} authenticated={true} location={location} />,
);
const redirect = wrapper.find(Redirect);
expect(redirect.props()).toEqual({ to: "/test" });
});
});

it("calls the authenticate handler when the form is submitted", () => {
const authenticate = jest.fn();
const wrapper = mount(<LoginForm {...defaultProps} authenticate={authenticate} />);
act(() => {
wrapper.find("input#token").simulate("change", { target: { value: "f00b4r" } });
});
act(() => {
wrapper.find("form").simulate("submit", { preventDefault: jest.fn() });
});
expect(authenticate).toBeCalledWith("f00b4r");
});

it("displays an error if the authentication error is passed", () => {
const wrapper = mount(
<LoginForm {...defaultProps} authenticationError={authenticationError} />,
);

expect(wrapper.find(".error").exists()).toBe(true);
expect(wrapper).toMatchSnapshot();
});

it("does not display the oauth login if oauthLoginURI provided", () => {
const props = {
...defaultProps,
oauthLoginURI: "",
};

const wrapper = shallow(<LoginForm {...props} />);

expect(wrapper.find("a.button").exists()).toBe(false);
});
});

describe("oauth login form", () => {
const props = {
...defaultProps,
oauthLoginURI: "/sign/in",
};
it("does not display the token login if oauthLoginURI provided", () => {
const wrapper = mount(<LoginForm {...props} />);

expect(wrapper.find("input#token").exists()).toBe(false);
});

it("displays the oauth login if oauthLoginURI provided", () => {
const wrapper = mount(<LoginForm {...props} />);

expect(wrapper.find("a").findWhere(a => a.prop("href") === props.oauthLoginURI)).toExist();
});

it("renders a login button link", () => {
const wrapper = shallow(<LoginForm {...props} />);
expect(wrapper).toMatchSnapshot();
});
});
82 changes: 82 additions & 0 deletions dashboard/src/components/LoginForm/LoginForm.v2.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { ClarityIcons, infoCircleIcon } from "@clr/core/icon-shapes";
import { Location } from "history";
import * as React from "react";
import { useEffect, useState } from "react";
import { Redirect } from "react-router";
import { CdsIcon } from "../Clarity/clarity";

import LoadingWrapper from "../../components/LoadingWrapper";
import "./LoginForm.v2.css";
import OAuthLogin from "./OauthLogin";
import TokenLogin from "./TokenLogin";

ClarityIcons.addIcons(infoCircleIcon);

export interface ILoginFormProps {
authenticated: boolean;
authenticating: boolean;
authenticationError: string | undefined;
oauthLoginURI: string;
authenticate: (token: string) => any;
checkCookieAuthentication: () => void;
appVersion: string;
location: Location;
}

function LoginForm(props: ILoginFormProps) {
const [token, setToken] = useState("");
const { oauthLoginURI, checkCookieAuthentication } = props;
useEffect(() => {
if (oauthLoginURI) {
checkCookieAuthentication();
}
}, [oauthLoginURI, checkCookieAuthentication]);

if (props.authenticating) {
return <LoadingWrapper />;
}
if (props.authenticated) {
const { from } = (props.location.state as any) || { from: { pathname: "/" } };
return <Redirect to={from} />;
}

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
return token && (await props.authenticate(token));
};

const handleTokenChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setToken(e.target.value);
};

return (
<div className="login-wrapper">
<form className="login clr-form" onSubmit={handleSubmit}>
{props.oauthLoginURI ? (
<OAuthLogin
authenticationError={props.authenticationError}
oauthLoginURI={props.oauthLoginURI}
/>
) : (
<TokenLogin
authenticationError={props.authenticationError}
token={token}
handleTokenChange={handleTokenChange}
/>
)}
<div className="login-moreinfo">
<a
href={`https://github.com/kubeapps/kubeapps/blob/${props.appVersion}/docs/user/access-control.md`}
target="_blank"
rel="noopener noreferrer"
>
<CdsIcon shape="info-circle" />
More Info
</a>
</div>
</form>
</div>
);
}

export default LoginForm;
35 changes: 35 additions & 0 deletions dashboard/src/components/LoginForm/OauthLogin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import * as React from "react";
import { CdsButton } from "../Clarity/clarity";

interface ILoginFormProps {
authenticationError: string | undefined;
oauthLoginURI: string;
}

function OAuthLogin(props: ILoginFormProps) {
return (
<section className="title" aria-labelledby="login-title" aria-describedby="login-desc">
<h3 id="login-title" className="welcome">
Welcome to <span>Kubeapps</span>
</h3>
<p id="login-desc" className="hint">
Your cluster operator has enabled login via an authentication provider.
</p>
<div className="login-group">
{props.authenticationError && (
<div className="error active">
There was an error connecting to the Kubernetes API. Please check that your token is
valid.
</div>
)}
<a href={props.oauthLoginURI} className="login-submit-button">
<CdsButton id="login-submit-button" status="primary">
Login via OIDC Provider
</CdsButton>
</a>
</div>
</section>
);
}

export default OAuthLogin;
54 changes: 54 additions & 0 deletions dashboard/src/components/LoginForm/TokenLogin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import * as React from "react";
import { CdsButton } from "../Clarity/clarity";

interface ILoginFormProps {
token: string;
authenticationError: string | undefined;
handleTokenChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}

function TokenLogin(props: ILoginFormProps) {
return (
<section className="title" aria-labelledby="login-title" aria-describedby="login-desc">
<h3 id="login-title" className="welcome">
Welcome to <span>Kubeapps</span>
</h3>
<p id="login-desc" className="hint">
Your cluster operator should provide you with a Kubernetes API token.
</p>
<div className="login-group">
<div className="clr-form-control">
<label htmlFor="token" className="clr-control-label">
Token
</label>
<div className="clr-control-container">
<div className="clr-input-wrapper">
<input
type="password"
id="token"
placeholder="Paste token here"
className="clr-input"
required={true}
onChange={props.handleTokenChange}
value={props.token}
/>
</div>
</div>
</div>
{props.authenticationError && (
<div className="error active ">
There was an error connecting to the Kubernetes API. Please check that your token is
valid.
</div>
)}
<div className="login-submit-button">
<CdsButton id="login-submit-button" status="primary">
Submit
</CdsButton>
</div>
</div>
</section>
);
}

export default TokenLogin;

0 comments on commit 34149b5

Please sign in to comment.