Add login form extension point (#2088)

One of our users would like to create a new plugin that provides alternative methods of authentication. For that purpose, we need an additional extension point that can either replace the login form entirely, or extend it by adding login buttons. By default, the extension point simply renders the original login form.
This commit is contained in:
Konstantin Schaper
2022-07-14 14:06:51 +02:00
committed by GitHub
parent f647e06d3c
commit 8c41fab30d
8 changed files with 659 additions and 219 deletions

View File

@@ -44,7 +44,7 @@ import {
RepositoryTypeCollection, RepositoryTypeCollection,
Tag, Tag,
User, User,
ContentType ContentType,
} from "@scm-manager/ui-types"; } from "@scm-manager/ui-types";
import { ExtensionPointDefinition } from "./binder"; import { ExtensionPointDefinition } from "./binder";
import { RenderableExtensionPointDefinition, SimpleRenderableDynamicExtensionPointDefinition } from "./ExtensionPoint"; import { RenderableExtensionPointDefinition, SimpleRenderableDynamicExtensionPointDefinition } from "./ExtensionPoint";
@@ -650,3 +650,5 @@ export type FileViewActionBarOverflowMenu = ExtensionPointDefinition<
ActionMenuProps | ModalMenuProps | LinkMenuProps, ActionMenuProps | ModalMenuProps | LinkMenuProps,
ContentActionExtensionProps ContentActionExtensionProps
>; >;
export type LoginForm = RenderableExtensionPointDefinition<"login.form">;

View File

@@ -25,7 +25,7 @@ import * as React from "react";
import { WithTranslation, withTranslation } from "react-i18next"; import { WithTranslation, withTranslation } from "react-i18next";
import classNames from "classnames"; import classNames from "classnames";
import styled from "styled-components"; import styled from "styled-components";
import { InfoItem } from "@scm-manager/ui-types"; import { InfoItem, Link } from "@scm-manager/ui-types";
import { devices, Icon } from "@scm-manager/ui-components"; import { devices, Icon } from "@scm-manager/ui-components";
type Props = WithTranslation & { type Props = WithTranslation & {
@@ -70,7 +70,7 @@ class InfoBox extends React.Component<Props> {
const { item, type, t } = this.props; const { item, type, t } = this.props;
const icon = type === "plugin" ? "puzzle-piece" : "star"; const icon = type === "plugin" ? "puzzle-piece" : "star";
return ( return (
<a className="is-block mb-5" href={item._links.self.href}> <a className="is-block mb-5" href={(item?._links?.self as Link)?.href}>
<InfoBoxWrapper className="box media"> <InfoBoxWrapper className="box media">
<figure className="media-left"> <figure className="media-left">
<FixedSizedIconWrapper <FixedSizedIconWrapper

View File

@@ -23,8 +23,7 @@
*/ */
import React, { FormEvent } from "react"; import React, { FormEvent } from "react";
import { WithTranslation, withTranslation } from "react-i18next"; import { WithTranslation, withTranslation } from "react-i18next";
import styled from "styled-components"; import { ErrorNotification, InputField, SubmitButton, UnauthorizedError } from "@scm-manager/ui-components";
import { ErrorNotification, Image, InputField, SubmitButton, UnauthorizedError } from "@scm-manager/ui-components";
type Props = WithTranslation & { type Props = WithTranslation & {
error?: Error | null; error?: Error | null;
@@ -37,33 +36,12 @@ type State = {
password: string; password: string;
}; };
const TopMarginBox = styled.div`
margin-top: 5rem;
`;
const AvatarWrapper = styled.figure`
display: flex;
justify-content: center;
margin: -70px auto 20px;
width: 128px;
height: 128px;
background: var(--scm-white-color);
border: 1px solid lightgray;
border-radius: 50%;
`;
const AvatarImage = styled(Image)`
width: 75%;
margin-left: 0.25rem;
padding: 5px;
`;
class LoginForm extends React.Component<Props, State> { class LoginForm extends React.Component<Props, State> {
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
this.state = { this.state = {
username: "", username: "",
password: "" password: "",
}; };
} }
@@ -76,13 +54,13 @@ class LoginForm extends React.Component<Props, State> {
handleUsernameChange = (value: string) => { handleUsernameChange = (value: string) => {
this.setState({ this.setState({
username: value username: value,
}); });
}; };
handlePasswordChange = (value: string) => { handlePasswordChange = (value: string) => {
this.setState({ this.setState({
password: value password: value,
}); });
}; };
@@ -92,7 +70,7 @@ class LoginForm extends React.Component<Props, State> {
areCredentialsInvalid() { areCredentialsInvalid() {
const { t, error } = this.props; const { t, error } = this.props;
if (error instanceof UnauthorizedError) { if (error && error instanceof UnauthorizedError) {
return new Error(t("errorNotification.wrongLoginCredentials")); return new Error(t("errorNotification.wrongLoginCredentials"));
} else { } else {
return error; return error;
@@ -102,13 +80,7 @@ class LoginForm extends React.Component<Props, State> {
render() { render() {
const { loading, t } = this.props; const { loading, t } = this.props;
return ( return (
<div className="column is-4 box has-text-centered has-background-secondary-less"> <>
<h3 className="title">{t("login.title")}</h3>
<p className="subtitle">{t("login.subtitle")}</p>
<TopMarginBox className="box">
<AvatarWrapper>
<AvatarImage src="/images/blibSmallLightBackground.svg" alt={t("login.logo-alt")} />
</AvatarWrapper>
<ErrorNotification error={this.areCredentialsInvalid()} /> <ErrorNotification error={this.areCredentialsInvalid()} />
<form onSubmit={this.handleSubmit}> <form onSubmit={this.handleSubmit}>
<InputField <InputField
@@ -125,8 +97,7 @@ class LoginForm extends React.Component<Props, State> {
/> />
<SubmitButton label={t("login.submit")} fullWidth={true} loading={loading} testId="login-button" /> <SubmitButton label={t("login.submit")} fullWidth={true} loading={loading} testId="login-button" />
</form> </form>
</TopMarginBox> </>
</div>
); );
} }
} }

View File

@@ -0,0 +1,78 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import * as React from "react";
import * as TestRenderer from "react-test-renderer";
import { LoginInfo as LoginInfoType } from "@scm-manager/ui-types";
import "@scm-manager/ui-tests";
import LoginInfo from "./LoginInfo";
import { Binder, BinderContext, extensionPoints } from "@scm-manager/ui-extensions";
jest.mock("@scm-manager/ui-api", () => ({
useLoginInfo: jest.fn(() => ({
isLoading: false,
data: {
_links: {},
feature: { title: "Test", summary: "Test" },
plugin: { summary: "Test", title: "Test" },
} as LoginInfoType,
})),
urls: {
getPageFromMatch: jest.fn(() => 1),
withContextPath: jest.fn((it) => it),
},
}));
describe("LoginInfo", () => {
const withBinder = (ui, binder) => <BinderContext.Provider value={binder}>{ui}</BinderContext.Provider>;
it("should render login page", () => {
const loginHandler = jest.fn();
const binder = new Binder("test");
const reactTestRenderer = TestRenderer.create(withBinder(<LoginInfo loginHandler={loginHandler} />, binder));
expect(reactTestRenderer.toJSON()).toMatchSnapshot();
});
it("should render extension", () => {
const loginHandler = jest.fn();
const binder = new Binder("test");
binder.bind<extensionPoints.LoginForm>("login.form", () => <button>Login with OAuth2</button>);
const reactTestRenderer = TestRenderer.create(withBinder(<LoginInfo loginHandler={loginHandler} />, binder));
expect(reactTestRenderer.toJSON()).toMatchSnapshot();
});
it("should render extension with login form", () => {
const loginHandler = jest.fn();
const binder = new Binder("test");
binder.bind<extensionPoints.LoginForm>("login.form", ({ children }) => (
<>
{children}
<button>Login with OAuth2</button>
</>
));
const reactTestRenderer = TestRenderer.create(withBinder(<LoginInfo loginHandler={loginHandler} />, binder));
expect(reactTestRenderer.toJSON()).toMatchSnapshot();
});
});

View File

@@ -21,125 +21,81 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
import React from "react"; import React, { FC } from "react";
import InfoBox from "./InfoBox"; import InfoBox from "./InfoBox";
import { LoginInfo as LoginInfoResponse } from "@scm-manager/ui-types";
import LoginForm from "./LoginForm"; import LoginForm from "./LoginForm";
import { Loading } from "@scm-manager/ui-components"; import { Image, Loading } from "@scm-manager/ui-components";
import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions";
import styled from "styled-components";
import { useLoginInfo } from "@scm-manager/ui-api";
import { useTranslation } from "react-i18next";
const TopMarginBox = styled.div`
margin-top: 5rem;
`;
const AvatarWrapper = styled.figure`
display: flex;
justify-content: center;
margin: -70px auto 20px;
width: 128px;
height: 128px;
background: var(--scm-white-color);
border: 1px solid lightgray;
border-radius: 50%;
`;
const AvatarImage = styled(Image)`
width: 75%;
margin-left: 0.25rem;
padding: 5px;
`;
type Props = { type Props = {
/**
* @deprecated Unused because the component now uses {@link useLoginInfo} internally.
*/
loginInfoLink?: string; loginInfoLink?: string;
loading?: boolean; loading?: boolean;
error?: Error | null; error?: Error | null;
loginHandler: (username: string, password: string) => void; loginHandler: (username: string, password: string) => void;
}; };
type State = { const LoginInfo: FC<Props> = (props) => {
info?: LoginInfoResponse; const { isLoading: isLoadingLoginInfo, data: info } = useLoginInfo();
loading?: boolean; const [t] = useTranslation("commons");
};
// eslint-disable-next-line @typescript-eslint/ban-types if (isLoadingLoginInfo) {
type NoOpErrorBoundaryProps = {};
type NoOpErrorBoundaryState = {
error?: Error;
};
class NoOpErrorBoundary extends React.Component<NoOpErrorBoundaryProps, NoOpErrorBoundaryState> {
constructor(props: NoOpErrorBoundaryProps) {
super(props);
this.state = {};
}
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error) {
// eslint-disable-next-line no-console
if (console && console.error) {
// eslint-disable-next-line no-console
console.error("failed to render login info", error);
}
}
render() {
if (this.state.error) {
return null;
}
return this.props.children;
}
}
class LoginInfo extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
loading: !!props.loginInfoLink
};
}
fetchLoginInfo = (url: string) => {
return fetch(url)
.then(response => response.json())
.then(info => {
this.setState({
info,
loading: false
});
});
};
timeout = (ms: number, promise: Promise<void>) => {
return new Promise<void>((resolve, reject) => {
setTimeout(() => {
reject(new Error("timeout during fetch of login info"));
}, ms);
promise.then(resolve, reject);
});
};
componentDidMount() {
const { loginInfoLink } = this.props;
if (!loginInfoLink) {
return;
}
this.timeout(1000, this.fetchLoginInfo(loginInfoLink)).catch(() => {
this.setState({
loading: false
});
});
}
createInfoPanel = (info: LoginInfoResponse) => (
<NoOpErrorBoundary>
<div className="column is-7 is-offset-1 p-0">
<InfoBox item={info.feature} type="feature" />
<InfoBox item={info.plugin} type="plugin" />
</div>
</NoOpErrorBoundary>
);
render() {
const { info, loading } = this.state;
if (loading) {
return <Loading />; return <Loading />;
} }
let infoPanel; let infoPanel;
if (info) { if (info) {
infoPanel = this.createInfoPanel(info); infoPanel = (
<div className="column is-7 is-offset-1 p-0">
<InfoBox item={info.feature} type="feature" />
<InfoBox item={info.plugin} type="plugin" />
</div>
);
} }
return ( return (
<> <>
<LoginForm {...this.props} /> <div className="column is-4 box has-text-centered has-background-secondary-less">
<h3 className="title">{t("login.title")}</h3>
<p className="subtitle">{t("login.subtitle")}</p>
<TopMarginBox className="box">
<AvatarWrapper>
<AvatarImage src="/images/blibSmallLightBackground.svg" alt={t("login.logo-alt")} />
</AvatarWrapper>
<ExtensionPoint<extensionPoints.LoginForm> name="login.form" props={{}}>
<LoginForm {...props} />
</ExtensionPoint>
</TopMarginBox>
</div>
{infoPanel} {infoPanel}
</> </>
); );
} };
}
export default LoginInfo; export default LoginInfo;

View File

@@ -0,0 +1,483 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`LoginInfo should render extension 1`] = `
Array [
<div
className="column is-4 box has-text-centered has-background-secondary-less"
>
<h3
className="title"
>
login.title
</h3>
<p
className="subtitle"
>
login.subtitle
</p>
<div
className="LoginInfo__TopMarginBox-sc-dmmy9i-0 hXYZcL box"
>
<figure
className="LoginInfo__AvatarWrapper-sc-dmmy9i-1 ikoLxp"
>
<img
alt="login.logo-alt"
className="LoginInfo__AvatarImage-sc-dmmy9i-2 gFcMrK"
src="/images/blibSmallLightBackground.svg"
/>
</figure>
<button>
Login with OAuth2
</button>
</div>
</div>,
<div
className="column is-7 is-offset-1 p-0"
>
<a
className="is-block mb-5"
>
<div
className="InfoBox__InfoBoxWrapper-sc-qh4uzj-2 dgRYgc box media"
>
<figure
className="media-left"
>
<div
className="InfoBox__FixedSizedIconWrapper-sc-qh4uzj-0 bCVWB image box has-text-weight-bold has-text-secondary-least has-background-info is-flex is-flex-direction-column is-justify-content-center is-align-items-center"
>
<i
aria-label=""
className="fas fa-fw fa-star has-text-white mb-2 fa-2x"
onKeyPress={[Function]}
/>
<div
className="is-size-4 has-text-white"
>
login.feature
</div>
<div
className="is-size-4 has-text-white"
>
login.tip
</div>
</div>
</figure>
<div
className="InfoBox__ContentWrapper-sc-qh4uzj-1 eiUgVo media-content content ml-5"
>
<h4
className="has-text-link"
>
Test
</h4>
<p>
Test
</p>
</div>
</div>
</a>
<a
className="is-block mb-5"
>
<div
className="InfoBox__InfoBoxWrapper-sc-qh4uzj-2 dgRYgc box media"
>
<figure
className="media-left"
>
<div
className="InfoBox__FixedSizedIconWrapper-sc-qh4uzj-0 bCVWB image box has-text-weight-bold has-text-secondary-least has-background-info is-flex is-flex-direction-column is-justify-content-center is-align-items-center"
>
<i
aria-label=""
className="fas fa-fw fa-puzzle-piece has-text-white mb-2 fa-2x"
onKeyPress={[Function]}
/>
<div
className="is-size-4 has-text-white"
>
login.plugin
</div>
<div
className="is-size-4 has-text-white"
>
login.tip
</div>
</div>
</figure>
<div
className="InfoBox__ContentWrapper-sc-qh4uzj-1 eiUgVo media-content content ml-5"
>
<h4
className="has-text-link"
>
Test
</h4>
<p>
Test
</p>
</div>
</div>
</a>
</div>,
]
`;
exports[`LoginInfo should render extension with login form 1`] = `
Array [
<div
className="column is-4 box has-text-centered has-background-secondary-less"
>
<h3
className="title"
>
login.title
</h3>
<p
className="subtitle"
>
login.subtitle
</p>
<div
className="LoginInfo__TopMarginBox-sc-dmmy9i-0 hXYZcL box"
>
<figure
className="LoginInfo__AvatarWrapper-sc-dmmy9i-1 ikoLxp"
>
<img
alt="login.logo-alt"
className="LoginInfo__AvatarImage-sc-dmmy9i-2 gFcMrK"
src="/images/blibSmallLightBackground.svg"
/>
</figure>
<form
onSubmit={[Function]}
>
<fieldset
className="field"
>
<div
className="control"
>
<input
aria-describedby="input_5"
aria-labelledby="input_4"
className="input"
data-testid="username-input"
onBlur={[Function]}
onChange={[Function]}
onKeyPress={[Function]}
placeholder="login.username-placeholder"
/>
</div>
</fieldset>
<fieldset
className="field"
>
<div
className="control"
>
<input
aria-describedby="input_7"
aria-labelledby="input_6"
className="input"
data-testid="password-input"
onBlur={[Function]}
onChange={[Function]}
onKeyPress={[Function]}
placeholder="login.password-placeholder"
type="password"
/>
</div>
</fieldset>
<button
className="button is-primary is-fullwidth"
data-testid="login-button"
onClick={[Function]}
type="submit"
>
<span>
login.submit
</span>
</button>
</form>
<button>
Login with OAuth2
</button>
</div>
</div>,
<div
className="column is-7 is-offset-1 p-0"
>
<a
className="is-block mb-5"
>
<div
className="InfoBox__InfoBoxWrapper-sc-qh4uzj-2 dgRYgc box media"
>
<figure
className="media-left"
>
<div
className="InfoBox__FixedSizedIconWrapper-sc-qh4uzj-0 bCVWB image box has-text-weight-bold has-text-secondary-least has-background-info is-flex is-flex-direction-column is-justify-content-center is-align-items-center"
>
<i
aria-label=""
className="fas fa-fw fa-star has-text-white mb-2 fa-2x"
onKeyPress={[Function]}
/>
<div
className="is-size-4 has-text-white"
>
login.feature
</div>
<div
className="is-size-4 has-text-white"
>
login.tip
</div>
</div>
</figure>
<div
className="InfoBox__ContentWrapper-sc-qh4uzj-1 eiUgVo media-content content ml-5"
>
<h4
className="has-text-link"
>
Test
</h4>
<p>
Test
</p>
</div>
</div>
</a>
<a
className="is-block mb-5"
>
<div
className="InfoBox__InfoBoxWrapper-sc-qh4uzj-2 dgRYgc box media"
>
<figure
className="media-left"
>
<div
className="InfoBox__FixedSizedIconWrapper-sc-qh4uzj-0 bCVWB image box has-text-weight-bold has-text-secondary-least has-background-info is-flex is-flex-direction-column is-justify-content-center is-align-items-center"
>
<i
aria-label=""
className="fas fa-fw fa-puzzle-piece has-text-white mb-2 fa-2x"
onKeyPress={[Function]}
/>
<div
className="is-size-4 has-text-white"
>
login.plugin
</div>
<div
className="is-size-4 has-text-white"
>
login.tip
</div>
</div>
</figure>
<div
className="InfoBox__ContentWrapper-sc-qh4uzj-1 eiUgVo media-content content ml-5"
>
<h4
className="has-text-link"
>
Test
</h4>
<p>
Test
</p>
</div>
</div>
</a>
</div>,
]
`;
exports[`LoginInfo should render login page 1`] = `
Array [
<div
className="column is-4 box has-text-centered has-background-secondary-less"
>
<h3
className="title"
>
login.title
</h3>
<p
className="subtitle"
>
login.subtitle
</p>
<div
className="LoginInfo__TopMarginBox-sc-dmmy9i-0 hXYZcL box"
>
<figure
className="LoginInfo__AvatarWrapper-sc-dmmy9i-1 ikoLxp"
>
<img
alt="login.logo-alt"
className="LoginInfo__AvatarImage-sc-dmmy9i-2 gFcMrK"
src="/images/blibSmallLightBackground.svg"
/>
</figure>
<form
onSubmit={[Function]}
>
<fieldset
className="field"
>
<div
className="control"
>
<input
aria-describedby="input_1"
aria-labelledby="input_0"
className="input"
data-testid="username-input"
onBlur={[Function]}
onChange={[Function]}
onKeyPress={[Function]}
placeholder="login.username-placeholder"
/>
</div>
</fieldset>
<fieldset
className="field"
>
<div
className="control"
>
<input
aria-describedby="input_3"
aria-labelledby="input_2"
className="input"
data-testid="password-input"
onBlur={[Function]}
onChange={[Function]}
onKeyPress={[Function]}
placeholder="login.password-placeholder"
type="password"
/>
</div>
</fieldset>
<button
className="button is-primary is-fullwidth"
data-testid="login-button"
onClick={[Function]}
type="submit"
>
<span>
login.submit
</span>
</button>
</form>
</div>
</div>,
<div
className="column is-7 is-offset-1 p-0"
>
<a
className="is-block mb-5"
>
<div
className="InfoBox__InfoBoxWrapper-sc-qh4uzj-2 dgRYgc box media"
>
<figure
className="media-left"
>
<div
className="InfoBox__FixedSizedIconWrapper-sc-qh4uzj-0 bCVWB image box has-text-weight-bold has-text-secondary-least has-background-info is-flex is-flex-direction-column is-justify-content-center is-align-items-center"
>
<i
aria-label=""
className="fas fa-fw fa-star has-text-white mb-2 fa-2x"
onKeyPress={[Function]}
/>
<div
className="is-size-4 has-text-white"
>
login.feature
</div>
<div
className="is-size-4 has-text-white"
>
login.tip
</div>
</div>
</figure>
<div
className="InfoBox__ContentWrapper-sc-qh4uzj-1 eiUgVo media-content content ml-5"
>
<h4
className="has-text-link"
>
Test
</h4>
<p>
Test
</p>
</div>
</div>
</a>
<a
className="is-block mb-5"
>
<div
className="InfoBox__InfoBoxWrapper-sc-qh4uzj-2 dgRYgc box media"
>
<figure
className="media-left"
>
<div
className="InfoBox__FixedSizedIconWrapper-sc-qh4uzj-0 bCVWB image box has-text-weight-bold has-text-secondary-least has-background-info is-flex is-flex-direction-column is-justify-content-center is-align-items-center"
>
<i
aria-label=""
className="fas fa-fw fa-puzzle-piece has-text-white mb-2 fa-2x"
onKeyPress={[Function]}
/>
<div
className="is-size-4 has-text-white"
>
login.plugin
</div>
<div
className="is-size-4 has-text-white"
>
login.tip
</div>
</div>
</figure>
<div
className="InfoBox__ContentWrapper-sc-qh4uzj-1 eiUgVo media-content content ml-5"
>
<h4
className="has-text-link"
>
Test
</h4>
<p>
Test
</p>
</div>
</div>
</a>
</div>,
]
`;

View File

@@ -1,47 +0,0 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import users from "../public/locales/en/users.json";
import commons from "../public/locales/en/commons.json";
i18n.use(initReactI18next).init({
lng: "en",
fallbackLng: "en",
// have a common namespace used around the full app
ns: ["translationsNS"],
defaultNS: "translationsNS",
debug: true,
interpolation: {
escapeValue: false, // not needed for react!!
},
resources: { en: { users, commons } },
});
export default i18n;

View File

@@ -26,11 +26,8 @@ import * as React from "react";
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import UserForm from "./UserForm"; import UserForm from "./UserForm";
import { I18nextProvider } from "react-i18next";
import i18nTest from "../../i18n.mock";
import { User } from "@scm-manager/ui-types"; import { User } from "@scm-manager/ui-types";
import "@scm-manager/ui-tests";
const renderWithI18n = (component) => render(<I18nextProvider i18n={i18nTest}>{component}</I18nextProvider>);
describe("for user creation", () => { describe("for user creation", () => {
const fillForm = (userId: string, displayName: string, password: string, confirmation: string) => { const fillForm = (userId: string, displayName: string, password: string, confirmation: string) => {
@@ -54,7 +51,7 @@ describe("for user creation", () => {
it("should allow to create user", () => { it("should allow to create user", () => {
const mockSubmitForm = jest.fn(); const mockSubmitForm = jest.fn();
renderWithI18n(<UserForm submitForm={mockSubmitForm} />); render(<UserForm submitForm={mockSubmitForm} />);
fillForm("trillian", "Tricia McMillan", "password", "password"); fillForm("trillian", "Tricia McMillan", "password", "password");
@@ -66,7 +63,7 @@ describe("for user creation", () => {
it("should prevent to submit empty form", () => { it("should prevent to submit empty form", () => {
const mockSubmitForm = jest.fn(); const mockSubmitForm = jest.fn();
renderWithI18n(<UserForm submitForm={mockSubmitForm} />); render(<UserForm submitForm={mockSubmitForm} />);
fireEvent.click(screen.getByTestId("submit-button")); fireEvent.click(screen.getByTestId("submit-button"));
@@ -76,7 +73,7 @@ describe("for user creation", () => {
it("should prevent to submit form without user id", () => { it("should prevent to submit form without user id", () => {
const mockSubmitForm = jest.fn(); const mockSubmitForm = jest.fn();
renderWithI18n(<UserForm submitForm={mockSubmitForm} />); render(<UserForm submitForm={mockSubmitForm} />);
fillForm("", "Arthur Dent", "password", "password"); fillForm("", "Arthur Dent", "password", "password");
@@ -88,7 +85,7 @@ describe("for user creation", () => {
it("should prevent to submit form without display name", () => { it("should prevent to submit form without display name", () => {
const mockSubmitForm = jest.fn(); const mockSubmitForm = jest.fn();
renderWithI18n(<UserForm submitForm={mockSubmitForm} />); render(<UserForm submitForm={mockSubmitForm} />);
fillForm("trillian", "", "password", "password"); fillForm("trillian", "", "password", "password");
@@ -100,7 +97,7 @@ describe("for user creation", () => {
it("should prevent to submit form without password", () => { it("should prevent to submit form without password", () => {
const mockSubmitForm = jest.fn(); const mockSubmitForm = jest.fn();
renderWithI18n(<UserForm submitForm={mockSubmitForm} />); render(<UserForm submitForm={mockSubmitForm} />);
fillForm("trillian", "Tricia McMillan", "", ""); fillForm("trillian", "Tricia McMillan", "", "");
@@ -112,7 +109,7 @@ describe("for user creation", () => {
it("should prevent to submit form with wrong password confirmation", () => { it("should prevent to submit form with wrong password confirmation", () => {
const mockSubmitForm = jest.fn(); const mockSubmitForm = jest.fn();
renderWithI18n(<UserForm submitForm={mockSubmitForm} />); render(<UserForm submitForm={mockSubmitForm} />);
fillForm("trillian", "Tricia McMillan", "password", "different"); fillForm("trillian", "Tricia McMillan", "password", "different");
@@ -136,7 +133,7 @@ describe("for user edit", () => {
it("should allow to edit user with changed display name", () => { it("should allow to edit user with changed display name", () => {
const mockSubmitForm = jest.fn(); const mockSubmitForm = jest.fn();
renderWithI18n(<UserForm user={user} submitForm={mockSubmitForm} />); render(<UserForm user={user} submitForm={mockSubmitForm} />);
fireEvent.change(screen.getByTestId("input-displayname"), { fireEvent.change(screen.getByTestId("input-displayname"), {
target: { value: "Just Tricia" }, target: { value: "Just Tricia" },
@@ -150,7 +147,7 @@ describe("for user edit", () => {
it("should allow to edit user with changed email", () => { it("should allow to edit user with changed email", () => {
const mockSubmitForm = jest.fn(); const mockSubmitForm = jest.fn();
renderWithI18n(<UserForm user={user} submitForm={mockSubmitForm} />); render(<UserForm user={user} submitForm={mockSubmitForm} />);
fireEvent.change(screen.getByTestId("input-mail"), { fireEvent.change(screen.getByTestId("input-mail"), {
target: { value: "tricia@hg2g.com" }, target: { value: "tricia@hg2g.com" },
@@ -164,7 +161,7 @@ describe("for user edit", () => {
it("should allow to edit user with changed active flag", () => { it("should allow to edit user with changed active flag", () => {
const mockSubmitForm = jest.fn(); const mockSubmitForm = jest.fn();
renderWithI18n(<UserForm user={user} submitForm={mockSubmitForm} />); render(<UserForm user={user} submitForm={mockSubmitForm} />);
fireEvent.click(screen.getByTestId("checkbox-active")); fireEvent.click(screen.getByTestId("checkbox-active"));
@@ -176,7 +173,7 @@ describe("for user edit", () => {
it("should prevent to submit unchanged user", () => { it("should prevent to submit unchanged user", () => {
const mockSubmitForm = jest.fn(); const mockSubmitForm = jest.fn();
renderWithI18n(<UserForm user={user} submitForm={mockSubmitForm} />); render(<UserForm user={user} submitForm={mockSubmitForm} />);
fireEvent.click(screen.getByTestId("submit-button")); fireEvent.click(screen.getByTestId("submit-button"));
@@ -186,7 +183,7 @@ describe("for user edit", () => {
it("should prevent to edit user with incorrect email", () => { it("should prevent to edit user with incorrect email", () => {
const mockSubmitForm = jest.fn(); const mockSubmitForm = jest.fn();
renderWithI18n(<UserForm user={user} submitForm={mockSubmitForm} />); render(<UserForm user={user} submitForm={mockSubmitForm} />);
fireEvent.change(screen.getByTestId("input-mail"), { fireEvent.change(screen.getByTestId("input-mail"), {
target: { value: "do_not_reply" }, target: { value: "do_not_reply" },
@@ -200,7 +197,7 @@ describe("for user edit", () => {
it("should prevent to edit user with empty display name", () => { it("should prevent to edit user with empty display name", () => {
const mockSubmitForm = jest.fn(); const mockSubmitForm = jest.fn();
renderWithI18n(<UserForm user={user} submitForm={mockSubmitForm} />); render(<UserForm user={user} submitForm={mockSubmitForm} />);
fireEvent.change(screen.getByTestId("input-displayname"), { fireEvent.change(screen.getByTestId("input-displayname"), {
target: { value: "" }, target: { value: "" },