[next] Prevent accidental lock out from admin or stream (#2084)

* feat: prevent auth lockout by accident

* test: add integration tests

* test: update snapshots
This commit is contained in:
Kiwi
2018-11-20 17:41:35 +01:00
committed by GitHub
parent ecdb4e1307
commit 9af523318c
15 changed files with 235 additions and 55 deletions
@@ -16,8 +16,9 @@ const ConfigBox: StatelessComponent<Props> = ({
title,
topRight,
children,
...rest
}) => (
<div className={styles.root} id={id}>
<div {...rest} className={styles.root} id={id}>
<Flex className={styles.title} justifyContent="space-between">
<div>{title}</div>
<div>{topRight}</div>
@@ -3,7 +3,7 @@ import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import { Form, FormSpy } from "react-final-form";
import { Button, HorizontalGutter } from "talk-ui/components";
import { Button, CallOut, HorizontalGutter } from "talk-ui/components";
import Layout from "./Layout";
import Main from "./Main";
import { Link, Navigation } from "./Navigation";
@@ -19,9 +19,9 @@ const Configure: StatelessComponent<Props> = ({
onChange,
children,
}) => (
<div id="configure-container">
<div data-test="configure-container">
<Form onSubmit={onSave}>
{({ handleSubmit, submitting, pristine, form }) => (
{({ handleSubmit, submitting, pristine, form, submitError }) => (
<form autoComplete="off" onSubmit={handleSubmit} id="configure-form">
<FormSpy onChange={onChange} />
<Layout>
@@ -34,17 +34,28 @@ const Configure: StatelessComponent<Props> = ({
<Link to="/admin/configure/misc">Misc</Link>
</Navigation>
</HorizontalGutter>
<Localized id="configure-sideBar-saveChanges">
<Button
id="configure-sideBar-saveChanges"
color="success"
variant="filled"
type="submit"
disabled={submitting || pristine}
>
Save Changes
</Button>
</Localized>
<HorizontalGutter size="double">
<Localized id="configure-sideBar-saveChanges">
<Button
data-test="configure-sideBar-saveChanges"
color="success"
variant="filled"
type="submit"
disabled={submitting || pristine}
>
Save Changes
</Button>
</Localized>
{submitError && (
<CallOut
color="error"
fullWidth
data-test="configure-auth-submitError"
>
{submitError}
</CallOut>
)}
</HorizontalGutter>
</SideBar>
<Main>
{React.cloneElement(React.Children.only(children), {
@@ -2,7 +2,7 @@
exports[`renders correctly 1`] = `
<div
id="configure-container"
data-test="configure-container"
>
<ReactFinalForm
onSubmit={[Function]}
@@ -34,6 +34,7 @@ class ConfigureContainer extends React.Component<Props> {
super(props);
this.dirty = false;
const warningMessage = getMessage(
props.localeBundles,
"configure-unsavedInputWarning",
@@ -53,12 +54,21 @@ class ConfigureContainer extends React.Component<Props> {
data: UpdateSettingsInput["settings"],
form: FormApi
) => {
let cancelled = false;
let formErrors: Record<string, React.ReactNode> = {};
const cancel = (errors: Record<string, React.ReactNode>) => {
cancelled = true;
formErrors = { ...errors, ...formErrors };
};
try {
// Call submit hooks, that can manipulate what
// we send as the mutation.
let nextData = data;
for (const hook of this.submitHooks) {
const result = await hook(nextData);
const result = await hook(nextData, cancel);
if (cancelled) {
return formErrors;
}
if (result) {
nextData = result;
}
@@ -22,10 +22,12 @@ const ConfigBoxWithToggleField: StatelessComponent<Props> = ({
title,
disabled,
children,
...rest
}) => (
<Field name={name} type="checkbox" parse={bool}>
{({ input }) => (
<ConfigBox
{...rest}
id={id}
title={title}
topRight={
@@ -38,7 +38,7 @@ const FacebookConfig: StatelessComponent<Props> = ({
callbackURL,
}) => (
<ConfigBoxWithToggleField
id="configure-auth-facebook-container"
data-test="configure-auth-facebook-container"
title={
<Localized id="configure-auth-facebook-loginWith">
<span>Login with Facebook</span>
@@ -62,7 +62,7 @@ const OIDCConfig: StatelessComponent<Props> = ({
};
return (
<ConfigBoxWithToggleField
id={`configure-auth-oidc-container-${index}`}
data-test={`configure-auth-oidc-container-${index}`}
title={
<Localized id="configure-auth-oidc-loginWith">
<span>Login with OIDC</span>
@@ -26,7 +26,7 @@ const SSOKeyField: StatelessComponent<Props> = ({
disabled,
onRegenerate,
}) => (
<FormField id="configure-auth-sso-key">
<FormField data-test="configure-auth-sso-key">
<Localized id="configure-auth-sso-key">
<InputLabel>Key</InputLabel>
</Localized>
@@ -1,31 +1,83 @@
import { FormApi } from "final-form";
import { FORM_ERROR, FormApi } from "final-form";
import { Localized } from "fluent-react/compat";
import { RouteProps } from "found";
import { merge } from "lodash";
import { get, merge } from "lodash";
import React from "react";
import { graphql } from "react-relay";
import { AuthContainerQueryResponse } from "talk-admin/__generated__/AuthContainerQuery.graphql";
import { TalkContext, withContext } from "talk-framework/lib/bootstrap";
import { getMessage } from "talk-framework/lib/i18n";
import { Spinner } from "talk-ui/components";
import {
AddSubmitHook,
RemoveSubmitHook,
SubmitHook,
withSubmitHookContext,
} from "../../../submitHook";
import Auth from "../components/Auth";
interface Props extends AuthContainerQueryResponse {
localeBundles: TalkContext["localeBundles"];
form: FormApi;
submitting?: boolean;
addSubmitHook: AddSubmitHook;
}
export default class AuthContainer extends React.Component<Props> {
public static routeConfig: RouteProps;
private initialValues = {};
private removeSubmitHook: RemoveSubmitHook;
constructor(props: Props) {
super(props);
this.removeSubmitHook = this.props.addSubmitHook(this.submitHook);
}
public componentDidMount() {
this.props.form.initialize({ auth: this.initialValues });
}
public componentWillUnmount() {
this.removeSubmitHook();
}
private submitHook: SubmitHook = async (data, cancel) => {
const integrations = [
get(data, "auth.integrations.google"),
get(data, "auth.integrations.facebook"),
get(data, "auth.integrations.sso"),
get(data, "auth.integrations.local"),
...(get(data, "auth.integrations.oidc") || []),
];
if (!integrations.some((i: any) => i.enabled && i.targetFilter.admin)) {
cancel({
[FORM_ERROR]: (
<Localized id="configure-auth-pleaseEnableAuthForAdmin">
<span>
Please enable at least one authentication integration for Talk
Admin
</span>
</Localized>
),
});
} else if (
!integrations.some((i: any) => i.enabled && i.targetFilter.stream)
) {
const confirmMessage = getMessage(
this.props.localeBundles,
"configure-auth-confirmNoAuthForCommentStream",
"No authentication integration has been enabled for the Comment Stream. Do you really want to continue?"
);
if (!window.confirm(confirmMessage)) {
cancel();
}
}
return;
};
private handleOnInitValues = (values: any) => {
this.initialValues = merge(this.initialValues, values);
};
@@ -41,8 +93,12 @@ export default class AuthContainer extends React.Component<Props> {
}
}
const enhanced = withSubmitHookContext(addSubmitHook => ({ addSubmitHook }))(
withContext(({ localeBundles }) => ({ localeBundles }))(AuthContainer)
);
AuthContainer.routeConfig = {
Component: AuthContainer,
Component: enhanced,
query: graphql`
query AuthContainerQuery {
settings {
@@ -1,7 +1,10 @@
import { noop } from "lodash";
import React from "react";
export type SubmitHook = (data: any) => Promise<any> | any;
export type SubmitHook = (
data: any,
cancel: (errors?: Record<string, React.ReactNode>) => void
) => Promise<any> | any;
export type RemoveSubmitHook = () => void;
export type AddSubmitHook = (hook: SubmitHook) => RemoveSubmitHook;
export type SubmitHookContext = AddSubmitHook;
@@ -3,7 +3,7 @@
exports[`change settings: during submit: oidc without errors 1`] = `
<div
className="ConfigBox-root"
id="configure-auth-oidc-container-0"
data-test="configure-auth-oidc-container-0"
>
<div
className="Flex-root ConfigBox-title Flex-flex Flex-justifySpaceBetween"
@@ -422,7 +422,7 @@ integration to register for a new account.
exports[`change settings: enable facebook configure box 1`] = `
<div
className="ConfigBox-root"
id="configure-auth-facebook-container"
data-test="configure-auth-facebook-container"
>
<div
className="Flex-root ConfigBox-title Flex-flex Flex-justifySpaceBetween"
@@ -687,7 +687,7 @@ integration to register for a new account.
exports[`change settings: enable oidc configure box 1`] = `
<div
className="ConfigBox-root"
id="configure-auth-oidc-container-0"
data-test="configure-auth-oidc-container-0"
>
<div
className="Flex-root ConfigBox-title Flex-flex Flex-justifySpaceBetween"
@@ -1106,7 +1106,7 @@ integration to register for a new account.
exports[`change settings: oidc validation errors 1`] = `
<div
className="ConfigBox-root"
id="configure-auth-oidc-container-0"
data-test="configure-auth-oidc-container-0"
>
<div
className="Flex-root ConfigBox-title Flex-flex Flex-justifySpaceBetween"
@@ -1613,10 +1613,21 @@ integration to register for a new account.
</div>
`;
exports[`prevents admin lock out 1`] = `
<div
className="CallOut-root CallOut-colorError CallOut-fullWidth"
data-test="configure-auth-submitError"
>
<span>
Please enable at least one authentication integration for Talk Admin
</span>
</div>
`;
exports[`regenerate sso key 1`] = `
<div
className="HorizontalGutter-root FormField-root HorizontalGutter-half"
id="configure-auth-sso-key"
data-test="configure-auth-sso-key"
>
<label
className="Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
@@ -1676,7 +1687,7 @@ and all signed-in users will be signed out.
exports[`renders configure auth 1`] = `
<div
id="configure-container"
data-test="configure-container"
>
<form
autoComplete="off"
@@ -1719,19 +1730,23 @@ exports[`renders configure auth 1`] = `
</ul>
</nav>
</div>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorSuccess Button-variantFilled Button-disabled"
disabled={true}
id="configure-sideBar-saveChanges"
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
<div
className="HorizontalGutter-root HorizontalGutter-double"
>
Save Changes
</button>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorSuccess Button-variantFilled Button-disabled"
data-test="configure-sideBar-saveChanges"
disabled={true}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Save Changes
</button>
</div>
</div>
<div
className="Main-root"
@@ -1840,7 +1855,7 @@ however it could also be used to spoof/impersonate another user.
className="CheckBox-root"
>
<input
checked={false}
checked={true}
className="CheckBox-input"
disabled={false}
id="auth.integrations.local.enabled"
@@ -1889,7 +1904,7 @@ however it could also be used to spoof/impersonate another user.
<input
checked={true}
className="CheckBox-input"
disabled={true}
disabled={false}
id="auth.integrations.local.targetFilter.admin"
name="auth.integrations.local.targetFilter.admin"
onBlur={[Function]}
@@ -1914,7 +1929,7 @@ however it could also be used to spoof/impersonate another user.
<input
checked={true}
className="CheckBox-input"
disabled={true}
disabled={false}
id="auth.integrations.local.targetFilter.stream"
name="auth.integrations.local.targetFilter.stream"
onBlur={[Function]}
@@ -1958,7 +1973,7 @@ integration to register for a new account.
<input
checked={true}
className="CheckBox-input"
disabled={true}
disabled={false}
id="auth.integrations.local.allowRegistration"
name="auth.integrations.local.allowRegistration"
onBlur={[Function]}
@@ -1984,7 +1999,7 @@ integration to register for a new account.
</div>
<div
className="ConfigBox-root"
id="configure-auth-oidc-container-0"
data-test="configure-auth-oidc-container-0"
>
<div
className="Flex-root ConfigBox-title Flex-flex Flex-justifySpaceBetween"
@@ -2449,7 +2464,7 @@ integration to register for a new account.
>
<div
className="HorizontalGutter-root FormField-root HorizontalGutter-half"
id="configure-auth-sso-key"
data-test="configure-auth-sso-key"
>
<label
className="Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
@@ -2879,7 +2894,7 @@ integration to register for a new account.
</div>
<div
className="ConfigBox-root"
id="configure-auth-facebook-container"
data-test="configure-auth-facebook-container"
>
<div
className="Flex-root ConfigBox-title Flex-flex Flex-justifySpaceBetween"
@@ -82,6 +82,82 @@ it("regenerate sso key", async () => {
).toMatchSnapshot();
});
it("prevents admin lock out", async () => {
const testRenderer = await createTestRenderer();
// Let's disable local auth.
testRenderer.root
.find(inputPredicate("auth.integrations.local.enabled"))
.props.onChange();
// Send form
testRenderer.root.findByProps({ id: "configure-form" }).props.onSubmit();
await timeout();
expect(
limitSnapshotTo("configure-auth-submitError", testRenderer.toJSON())
).toMatchSnapshot();
});
it("prevents stream lock out", async () => {
let settingsRecord = cloneDeep(settings);
const testRenderer = await createTestRenderer({
Mutation: {
updateSettings: createSinonStub(s =>
s.callsFake((_: any, data: any) => {
expect(data.input.settings.auth.integrations.local).toEqual({
enabled: true,
allowRegistration: true,
targetFilter: {
admin: true,
stream: false,
},
});
settingsRecord = merge(settingsRecord, data.input.settings);
return {
settings: settingsRecord,
clientMutationId: data.input.clientMutationId,
};
})
),
},
});
const origConfirm = window.confirm;
const stubContinue = sinon.stub().returns(true);
const stubCancel = sinon.stub().returns(false);
try {
window.confirm = stubCancel;
// Let's disable stream target in local auth.
testRenderer.root
.find(inputPredicate("auth.integrations.local.targetFilter.stream"))
.props.onChange();
// Send form
testRenderer.root.findByProps({ id: "configure-form" }).props.onSubmit();
// Submit button should not be disabled because we canceled the submit.
expect(
testRenderer.root.findByProps({
"data-test": "configure-sideBar-saveChanges",
}).props.disabled
).toBe(true);
expect(stubCancel.calledOnce).toBe(true);
window.confirm = stubContinue;
// Let's enable stream target in local auth.
testRenderer.root
.find(inputPredicate("auth.integrations.local.targetFilter.stream"))
.props.onChange();
// Send form
testRenderer.root.findByProps({ id: "configure-form" }).props.onSubmit();
expect(stubContinue.calledOnce).toBe(true);
} finally {
window.confirm = origConfirm;
}
});
it("change settings", async () => {
let settingsRecord = cloneDeep(settings);
const testRenderer = await createTestRenderer({
@@ -197,8 +273,9 @@ it("change settings", async () => {
// Submit button should be disabled.
expect(
testRenderer.root.find(inputPredicate("configure-sideBar-saveChanges"))
.props.disabled
testRenderer.root.findByProps({
"data-test": "configure-sideBar-saveChanges",
}).props.disabled
).toBe(true);
// Disable other fields while submitting
+1 -1
View File
@@ -6,7 +6,7 @@ export const settings = {
integrations: {
oidc: [],
local: {
enabled: false,
enabled: true,
allowRegistration: true,
targetFilter: {
admin: true,
@@ -1,10 +1,10 @@
export default function limitSnapshotTo(id: string, node: any) {
if (node.props && node.props.id === id) {
export default function limitSnapshotTo(dataTest: string, node: any) {
if (node.props && node.props["data-test"] === dataTest) {
return node;
}
if (node.children) {
for (const child of node.children) {
const result: any = limitSnapshotTo(id, child);
const result: any = limitSnapshotTo(dataTest, child);
if (result) {
return result;
}
+5
View File
@@ -49,6 +49,11 @@ configure-auth-registrationDescription =
Allow users that have not signed up before with this authentication
integration to register for a new account.
configure-auth-registrationCheckBox = Allow Registration
configure-auth-pleaseEnableAuthForAdmin =
Please enable at least one authentication integration for Talk Admin
configure-auth-confirmNoAuthForCommentStream =
No authentication integration has been enabled for the Comment Stream.
Do you really want to continue?
configure-auth-facebook-loginWith = Login with Facebook
configure-auth-facebook-toEnableIntegration =