mirror of
https://github.com/wassname/talk.git
synced 2026-08-07 11:29:44 +08:00
[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:
@@ -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), {
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user