[next] Admin Configure (#2076)

* feat: Add RadioButton and CheckBox

* feat: configure facebook and google auth

* feat: configure sso, localAuth and displayName + some tests

* test: add integration tests for configure auth

* test: more integration tests

* feat: add oidc support

* test: add oidc integration test

* feat: generate sso key initially

* fix: import fetchQuery from correct package

* fix: admin url

* fix: set timezone to utc when testing

* refactor: improve route config

* fix: remove obsolete line

* fix: clientMutationId increment

* fix: oidc only create when enabled

* fix: copy

* test: update snapshots

* feat: fixed graphql logging extension

* Update src/locales/en-US/admin.ftl

Co-Authored-By: cvle <vinh@wikiwi.io>

* Apply suggestions from code review

Co-Authored-By: cvle <vinh@wikiwi.io>

* test: update snapshots

* fix: change Local Auth to Email Authentication

* fix: copy updates
This commit is contained in:
Kiwi
2018-11-19 22:47:32 +00:00
committed by Wyatt Johnson
parent 3f949b3712
commit 05350d651f
215 changed files with 7774 additions and 939 deletions
@@ -0,0 +1,39 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { DiscoverOIDCConfigurationQuery as QueryTypes } from "talk-admin/__generated__/DiscoverOIDCConfigurationQuery.graphql";
import { createFetchContainer, fetchQuery } from "talk-framework/lib/relay";
export type DiscoverOIDCConfigurationVariables = QueryTypes["variables"];
const query = graphql`
query DiscoverOIDCConfigurationQuery($issuer: String!) {
discoverOIDCConfiguration(issuer: $issuer) {
issuer
authorizationURL
tokenURL
jwksURI
}
}
`;
function fetch(
environment: Environment,
variables: DiscoverOIDCConfigurationVariables
) {
return fetchQuery<QueryTypes["response"]["discoverOIDCConfiguration"]>(
environment,
query,
variables,
{ force: true }
);
}
export const withDiscoverOIDCConfigurationFetch = createFetchContainer(
"discoverOIDCConfiguration",
fetch
);
export type DiscoverOIDCConfigurationFetch = (
variables: DiscoverOIDCConfigurationVariables
) => Promise<QueryTypes["response"]["discoverOIDCConfiguration"]>;
+4
View File
@@ -0,0 +1,4 @@
export {
withDiscoverOIDCConfigurationFetch,
DiscoverOIDCConfigurationFetch,
} from "./DiscoverOIDCConfigurationQuery";
@@ -0,0 +1,71 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutationContainer,
} from "talk-framework/lib/relay";
import { Omit } from "talk-framework/types";
import { CreateOIDCAuthIntegrationMutation as MutationTypes } from "talk-admin/__generated__/CreateOIDCAuthIntegrationMutation.graphql";
export type CreateOIDCAuthIntegrationInput = Omit<
MutationTypes["variables"]["input"],
"clientMutationId"
>;
const mutation = graphql`
mutation CreateOIDCAuthIntegrationMutation(
$input: CreateOIDCAuthIntegrationInput!
) {
createOIDCAuthIntegration(input: $input) {
settings {
auth {
...OIDCConfigListContainer_authReadOnly
}
}
clientMutationId
}
}
`;
let clientMutationId = 0;
function commit(
environment: Environment,
input: CreateOIDCAuthIntegrationInput
) {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
updater: store => {
const record = store
.getRootField("createOIDCAuthIntegration")!
.getLinkedRecord("settings")!
.getLinkedRecord("auth")!
.getLinkedRecord("integrations");
if (record) {
store
.getRoot()
.getLinkedRecord("settings")!
.getLinkedRecord("auth")!
.getLinkedRecord("integrations")!
.copyFieldsFrom(record);
}
},
});
}
export const withCreateOIDCAuthIntegrationMutation = createMutationContainer(
"createOIDCAuthIntegration",
commit
);
export type CreateOIDCAuthIntegrationMutation = (
input: CreateOIDCAuthIntegrationInput
) => Promise<MutationTypes["response"]["createOIDCAuthIntegration"]>;
@@ -0,0 +1,66 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutationContainer,
} from "talk-framework/lib/relay";
import { RegenerateSSOKeyMutation as MutationTypes } from "talk-admin/__generated__/RegenerateSSOKeyMutation.graphql";
const mutation = graphql`
mutation RegenerateSSOKeyMutation($input: RegenerateSSOKeyInput!) {
regenerateSSOKey(input: $input) {
settings {
auth {
integrations {
sso {
key
keyGeneratedAt
}
}
}
}
clientMutationId
}
}
`;
let clientMutationId = 0;
function commit(environment: Environment) {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation,
variables: {
input: {
clientMutationId: (clientMutationId++).toString(),
},
},
updater: store => {
const record = store
.getRootField("regenerateSSOKey")!
.getLinkedRecord("settings")!
.getLinkedRecord("auth")!
.getLinkedRecord("integrations")!
.getLinkedRecord("sso");
if (record) {
store
.getRoot()
.getLinkedRecord("settings")!
.getLinkedRecord("auth")!
.getLinkedRecord("integrations")!
.getLinkedRecord("sso")!
.copyFieldsFrom(record);
}
},
});
}
export const withRegenerateSSOKeyMutation = createMutationContainer(
"regenerateSSOKey",
commit
);
export type RegenerateSSOKeyMutation = () => Promise<
MutationTypes["response"]["regenerateSSOKey"]
>;
@@ -0,0 +1,56 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutationContainer,
} from "talk-framework/lib/relay";
import { Omit } from "talk-framework/types";
import { UpdateOIDCAuthIntegrationMutation as MutationTypes } from "talk-admin/__generated__/UpdateOIDCAuthIntegrationMutation.graphql";
export type UpdateOIDCAuthIntegrationInput = Omit<
MutationTypes["variables"]["input"],
"clientMutationId"
>;
const mutation = graphql`
mutation UpdateOIDCAuthIntegrationMutation(
$input: UpdateOIDCAuthIntegrationInput!
) {
updateOIDCAuthIntegration(input: $input) {
settings {
auth {
...OIDCConfigListContainer_authReadOnly
}
}
clientMutationId
}
}
`;
let clientMutationId = 0;
function commit(
environment: Environment,
input: UpdateOIDCAuthIntegrationInput
) {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
});
}
export const withUpdateOIDCAuthIntegrationMutation = createMutationContainer(
"updateOIDCAuthIntegration",
commit
);
export type UpdateOIDCAuthIntegrationMutation = (
input: UpdateOIDCAuthIntegrationInput
) => Promise<MutationTypes["response"]["updateOIDCAuthIntegration"]>;
@@ -0,0 +1,70 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutationContainer,
} from "talk-framework/lib/relay";
import { Omit } from "talk-framework/types";
import { UpdateSettingsMutation as MutationTypes } from "talk-admin/__generated__/UpdateSettingsMutation.graphql";
export type UpdateSettingsInput = Omit<
MutationTypes["variables"]["input"],
"clientMutationId"
>;
const mutation = graphql`
mutation UpdateSettingsMutation($input: UpdateSettingsInput!) {
updateSettings(input: $input) {
settings {
auth {
...FacebookConfigContainer_auth
...FacebookConfigContainer_authReadOnly
...GoogleConfigContainer_auth
...GoogleConfigContainer_authReadOnly
...SSOConfigContainer_auth
...SSOConfigContainer_authReadOnly
...OIDCConfigListContainer_auth
...OIDCConfigListContainer_authReadOnly
...DisplayNamesConfigContainer_auth
}
}
clientMutationId
}
}
`;
let clientMutationId = 0;
function commit(environment: Environment, input: UpdateSettingsInput) {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
updater: store => {
const record = store
.getRootField("updateSettings")!
.getLinkedRecord("settings");
if (record) {
store
.getRoot()
.getLinkedRecord("settings")!
.copyFieldsFrom(record);
}
},
});
}
export const withUpdateSettingsMutation = createMutationContainer(
"updateSettings",
commit
);
export type UpdateSettingsMutation = (
input: UpdateSettingsInput
) => Promise<MutationTypes["response"]["updateSettings"]>;
+17
View File
@@ -3,3 +3,20 @@ export {
withSetRedirectPathMutation,
SetRedirectPathMutation,
} from "./SetRedirectPathMutation";
export {
withUpdateSettingsMutation,
UpdateSettingsMutation,
UpdateSettingsInput,
} from "./UpdateSettingsMutation";
export {
withRegenerateSSOKeyMutation,
RegenerateSSOKeyMutation,
} from "./RegenerateSSOKeyMutation";
export {
withCreateOIDCAuthIntegrationMutation,
CreateOIDCAuthIntegrationMutation,
} from "./CreateOIDCAuthIntegrationMutation";
export {
withUpdateOIDCAuthIntegrationMutation,
UpdateOIDCAuthIntegrationMutation,
} from "./UpdateOIDCAuthIntegrationMutation";
+8 -2
View File
@@ -5,7 +5,9 @@ import App from "./components/App";
import RedirectAppContainer from "./containers/RedirectAppContainer";
import RedirectLoginContainer from "./containers/RedirectLoginContainer";
import Community from "./routes/community/components/Community";
import Configure from "./routes/configure/components/Configure";
import ConfigureMisc from "./routes/configure/components/Misc";
import ConfigureContainer from "./routes/configure/containers/ConfigureContainer";
import ConfigureAuthContainer from "./routes/configure/sections/auth/containers/AuthContainer";
import Login from "./routes/login/components/Login";
import Moderate from "./routes/moderate/components/Moderate";
import Stories from "./routes/stories/components/Stories";
@@ -18,7 +20,11 @@ export default makeRouteConfig(
<Route path="moderate" Component={Moderate} />
<Route path="community" Component={Community} />
<Route path="stories" Component={Stories} />
<Route path="configure" Component={Configure} />
<Route path="configure" Component={ConfigureContainer}>
<Redirect from="/" to="/admin/configure/auth" />
<Route path="auth" {...ConfigureAuthContainer.routeConfig} />
<Route path="misc" Component={ConfigureMisc} />
</Route>
</Route>
</Route>
<Route Component={RedirectAppContainer}>
@@ -0,0 +1,20 @@
.root {
border: 1px solid var(--palette-grey-lighter);
}
.title {
padding: calc(0.5 * var(--spacing-unit)) calc(1.5 * var(--spacing-unit));
background-color: var(--palette-text-primary);
color: var(--palette-text-light);
font-family: var(--font-family-sans-serif);
font-weight: var(--font-weight-medium);
font-size: calc(18rem / var(--rem-base));
line-height: calc(20em / 18);
letter-spacing: calc(-0.1em / 18);
}
.content {
padding: calc(2 * var(--spacing-unit)) calc(3 * var(--spacing-unit));
}
@@ -0,0 +1,16 @@
import { shallow } from "enzyme";
import React from "react";
import { PropTypesOf } from "talk-framework/types";
import ConfigBox from "./ConfigBox";
it("renders correctly", () => {
const props: PropTypesOf<typeof ConfigBox> = {
topRight: <span>topRight</span>,
title: <span>title</span>,
children: "child",
};
const wrapper = shallow(<ConfigBox {...props} />);
expect(wrapper).toMatchSnapshot();
});
@@ -0,0 +1,29 @@
import React, { StatelessComponent } from "react";
import { Flex } from "talk-ui/components";
import styles from "./ConfigBox.css";
interface Props {
id?: string;
topRight?: React.ReactElement<any>;
title?: React.ReactNode;
children: React.ReactNode;
}
const ConfigBox: StatelessComponent<Props> = ({
id,
title,
topRight,
children,
}) => (
<div className={styles.root} id={id}>
<Flex className={styles.title} justifyContent="space-between">
<div>{title}</div>
<div>{topRight}</div>
</Flex>
<div className={styles.content}>{children}</div>
</div>
);
export default ConfigBox;
@@ -1,9 +1,16 @@
import { shallow } from "enzyme";
import { noop } from "lodash";
import React from "react";
import { PropTypesOf } from "talk-framework/types";
import Configure from "./Configure";
it("renders correctly", () => {
const wrapper = shallow(<Configure />);
const props: PropTypesOf<typeof Configure> = {
onSave: noop,
onChange: noop,
};
const wrapper = shallow(<Configure {...props} />);
expect(wrapper).toMatchSnapshot();
});
@@ -1,10 +1,62 @@
import { FormApi, FormState } from "final-form";
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import { HorizontalGutter, Typography } from "talk-ui/components";
import { Form, FormSpy } from "react-final-form";
const Configure: StatelessComponent = ({ children }) => (
<HorizontalGutter>
<Typography variant="heading3">Configure</Typography>
</HorizontalGutter>
import { Button, HorizontalGutter } from "talk-ui/components";
import Layout from "./Layout";
import Main from "./Main";
import { Link, Navigation } from "./Navigation";
import SideBar from "./SideBar";
interface Props {
onSave: (settings: any, form: FormApi) => void;
onChange: (formState: FormState) => void;
}
const Configure: StatelessComponent<Props> = ({
onSave,
onChange,
children,
}) => (
<div id="configure-container">
<Form onSubmit={onSave}>
{({ handleSubmit, submitting, pristine, form }) => (
<form autoComplete="off" onSubmit={handleSubmit} id="configure-form">
<FormSpy onChange={onChange} />
<Layout>
<SideBar>
<HorizontalGutter size="double">
<Navigation>
<Localized id="configure-sideBarNavigation-authentication">
<Link to="/admin/configure/auth">Auth</Link>
</Localized>
<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>
</SideBar>
<Main>
{React.cloneElement(React.Children.only(children), {
form,
submitting,
})}
</Main>
</Layout>
</form>
)}
</Form>
</div>
);
export default Configure;
@@ -0,0 +1,5 @@
.root {
padding-bottom: calc(0.5 * var(--spacing-unit));
border-bottom: 1px solid var(--palette-text-primary);
margin-bottom: calc(1.5 * var(--spacing-unit));
}
@@ -0,0 +1,14 @@
import { shallow } from "enzyme";
import React from "react";
import { PropTypesOf } from "talk-framework/types";
import Header from "./Header";
it("renders correctly", () => {
const props: PropTypesOf<typeof Header> = {
children: "child",
};
const wrapper = shallow(<Header {...props} />);
expect(wrapper).toMatchSnapshot();
});
@@ -0,0 +1,12 @@
import React, { StatelessComponent } from "react";
import { Typography } from "talk-ui/components";
import styles from "./Header.css";
const Header: StatelessComponent = ({ children }) => (
<Typography variant="heading1" className={styles.root}>
{children}
</Typography>
);
export default Header;
@@ -0,0 +1,5 @@
.root {
border: 0;
border-bottom: 1px solid var(--palette-divider);
padding-top: 1px;
}
@@ -0,0 +1 @@
export const root: string;
@@ -0,0 +1,9 @@
import { shallow } from "enzyme";
import React from "react";
import HorizontalRule from "./HorizontalRule";
it("renders correctly", () => {
const wrapper = shallow(<HorizontalRule />);
expect(wrapper).toMatchSnapshot();
});
@@ -0,0 +1,9 @@
import React, { StatelessComponent } from "react";
import styles from "./HorizontalRule.css";
const HorizontalRule: StatelessComponent = ({ children }) => (
<hr className={styles.root} />
);
export default HorizontalRule;
@@ -0,0 +1,4 @@
.root {
padding: calc(3 * var(--spacing-unit)) calc(5 * var(--spacing-unit))
calc(5 * var(--spacing-unit)) calc(5 * var(--spacing-unit));
}
@@ -0,0 +1,14 @@
import { shallow } from "enzyme";
import React from "react";
import { PropTypesOf } from "talk-framework/types";
import Layout from "./Layout";
it("renders correctly", () => {
const props: PropTypesOf<typeof Layout> = {
children: "child",
};
const wrapper = shallow(<Layout {...props} />);
expect(wrapper).toMatchSnapshot();
});
@@ -0,0 +1,10 @@
import React, { StatelessComponent } from "react";
import { Flex } from "talk-ui/components";
import styles from "./Layout.css";
const Layout: StatelessComponent = ({ children }) => (
<Flex className={styles.root}>{children}</Flex>
);
export default Layout;
@@ -0,0 +1,4 @@
.root {
width: 100%;
max-width: calc(67 * var(--spacing-unit));
}
@@ -0,0 +1,14 @@
import { shallow } from "enzyme";
import React from "react";
import { PropTypesOf } from "talk-framework/types";
import Main from "./Main";
it("renders correctly", () => {
const props: PropTypesOf<typeof Main> = {
children: "child",
};
const wrapper = shallow(<Main {...props} />);
expect(wrapper).toMatchSnapshot();
});
@@ -0,0 +1,9 @@
import React, { StatelessComponent } from "react";
import styles from "./Main.css";
const Main: StatelessComponent = ({ children }) => (
<div className={styles.root}>{children}</div>
);
export default Main;
@@ -0,0 +1,14 @@
import { shallow } from "enzyme";
import React from "react";
import { PropTypesOf } from "talk-framework/types";
import Misc from "./Misc";
it("renders correctly", () => {
const props: PropTypesOf<typeof Misc> = {
children: "child",
};
const wrapper = shallow(<Misc {...props} />);
expect(wrapper).toMatchSnapshot();
});
@@ -0,0 +1,14 @@
import React, { StatelessComponent } from "react";
import { Typography } from "talk-ui/components";
import Header from "./Header";
const Misc: StatelessComponent = ({ children }) => (
<div>
<Header>Misc Integrations</Header>
<Typography>Other stuff</Typography>
</div>
);
export default Misc;
@@ -0,0 +1,26 @@
.link {
display: inline-block;
text-decoration: none;
text-transform: uppercase;
padding: calc(0.5 * var(--spacing-unit)) var(--spacing-unit)
calc(0.5 * var(--spacing-unit)) calc(var(--spacing-unit) + 2px);
margin-left: 2px;
border-left: 1px solid var(--palette-grey-lighter);
color: var(--palette-text-primary);
font-family: var(--font-family-sans-serif);
font-weight: var(--font-weight-bold);
font-size: calc(18rem / var(--rem-base));
line-height: calc(20em / 18);
letter-spacing: calc(-0.1em / 18);
&:hover {
cursor: pointer;
}
}
.linkActive {
margin-left: 0px;
border-left: calc(0.5 * var(--spacing-unit)) solid var(--palette-brand);
padding-left: var(--spacing-unit);
}
@@ -0,0 +1,16 @@
import { shallow } from "enzyme";
import React from "react";
import { PropTypesOf } from "talk-framework/types";
import Link from "./Link";
it("renders correctly", () => {
const props: PropTypesOf<typeof Link> = {
className: "customClassName",
to: "/admin",
children: "child",
};
const wrapper = shallow(<Link {...props} />);
expect(wrapper).toMatchSnapshot();
});
@@ -0,0 +1,24 @@
import { Link as FoundLink, LocationDescriptor } from "found";
import React, { StatelessComponent } from "react";
import styles from "./Link.css";
interface Props {
className?: string;
children: React.ReactNode;
to: string | LocationDescriptor;
}
const Link: StatelessComponent<Props> = props => (
<li className={props.className}>
<FoundLink
to={props.to}
className={styles.link}
activeClassName={styles.linkActive}
>
{props.children}
</FoundLink>
</li>
);
export default Link;
@@ -0,0 +1,7 @@
.root {
}
.ul {
list-style: none;
padding: 0;
}
@@ -0,0 +1,14 @@
import { shallow } from "enzyme";
import React from "react";
import { PropTypesOf } from "talk-framework/types";
import Navigation from "./Navigation";
it("renders correctly", () => {
const props: PropTypesOf<typeof Navigation> = {
children: "child",
};
const wrapper = shallow(<Navigation {...props} />);
expect(wrapper).toMatchSnapshot();
});
@@ -0,0 +1,11 @@
import React, { StatelessComponent } from "react";
import styles from "./Navigation.css";
const Navigation: StatelessComponent = ({ children }) => (
<nav className={styles.root}>
<ul className={styles.ul}>{children}</ul>
</nav>
);
export default Navigation;
@@ -0,0 +1,15 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<li
className="customClassName"
>
<Link
activeClassName="Link-linkActive"
className="Link-link"
to="/admin"
>
child
</Link>
</li>
`;
@@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<nav
className="Navigation-root"
>
<ul
className="Navigation-ul"
>
child
</ul>
</nav>
`;
@@ -0,0 +1,2 @@
export { default as Navigation } from "./Navigation";
export { default as Link } from "./Link";
@@ -0,0 +1,8 @@
.root {
width: calc(25 * var(--spacing-unit));
padding-top: calc(0.5 * var(--spacing-unit));
padding-right: calc(3 * var(--spacing-unit));
position: sticky;
top: 0;
align-self: flex-start;
}
@@ -0,0 +1,14 @@
import { shallow } from "enzyme";
import React from "react";
import { PropTypesOf } from "talk-framework/types";
import SideBar from "./SideBar";
it("renders correctly", () => {
const props: PropTypesOf<typeof SideBar> = {
children: "child",
};
const wrapper = shallow(<SideBar {...props} />);
expect(wrapper).toMatchSnapshot();
});
@@ -0,0 +1,9 @@
import React, { StatelessComponent } from "react";
import styles from "./SideBar.css";
const SideBar: StatelessComponent = ({ children }) => (
<div className={styles.root}>{children}</div>
);
export default SideBar;
@@ -0,0 +1,28 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<div
className="ConfigBox-root"
>
<withPropsOnChange(Flex)
className="ConfigBox-title"
justifyContent="space-between"
>
<div>
<span>
title
</span>
</div>
<div>
<span>
topRight
</span>
</div>
</withPropsOnChange(Flex)>
<div
className="ConfigBox-content"
>
child
</div>
</div>
`;
@@ -1,11 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<withPropsOnChange(HorizontalGutter)>
<withPropsOnChange(Typography)
variant="heading3"
<div
id="configure-container"
>
<ReactFinalForm
onSubmit={[Function]}
>
Configure
</withPropsOnChange(Typography)>
</withPropsOnChange(HorizontalGutter)>
<Component />
</ReactFinalForm>
</div>
`;
@@ -0,0 +1,10 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<withPropsOnChange(Typography)
className="Header-root"
variant="heading1"
>
child
</withPropsOnChange(Typography)>
`;
@@ -0,0 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<hr
className="HorizontalRule-root"
/>
`;
@@ -0,0 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<withPropsOnChange(Flex)
className="Layout-root"
>
child
</withPropsOnChange(Flex)>
`;
@@ -0,0 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<div
className="Main-root"
>
child
</div>
`;
@@ -0,0 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<div>
<Header>
Misc Integrations
</Header>
<withPropsOnChange(Typography)>
Other stuff
</withPropsOnChange(Typography)>
</div>
`;
@@ -0,0 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<div
className="SideBar-root"
>
child
</div>
`;
@@ -0,0 +1,104 @@
import { FormApi, FormState } from "final-form";
import { Router } from "found";
import React from "react";
import {
UpdateSettingsInput,
UpdateSettingsMutation,
withUpdateSettingsMutation,
} from "talk-admin/mutations";
import { TalkContext, withContext } from "talk-framework/lib/bootstrap";
import { BadUserInputError } from "talk-framework/lib/errors";
import { getMessage } from "talk-framework/lib/i18n";
import Configure from "../components/Configure";
import {
AddSubmitHook,
SubmitHook,
SubmitHookContextProvider,
} from "../submitHook";
interface Props {
localeBundles: TalkContext["localeBundles"];
router: Router;
updateSettings: UpdateSettingsMutation;
children: React.ReactNode;
}
class ConfigureContainer extends React.Component<Props> {
private dirty = false;
private removeTransitionHook: () => void;
private submitHooks: SubmitHook[] = [];
constructor(props: Props) {
super(props);
this.dirty = false;
const warningMessage = getMessage(
props.localeBundles,
"configure-unsavedInputWarning",
"You have unsaved input. Are you sure you want to leave this page?"
);
this.removeTransitionHook = props.router.addTransitionHook(
() => (this.dirty ? warningMessage : true)
);
}
public componentWillUnmount() {
this.removeTransitionHook();
}
private handleSave = async (
data: UpdateSettingsInput["settings"],
form: FormApi
) => {
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);
if (result) {
nextData = result;
}
}
await this.props.updateSettings({ settings: nextData });
form.initialize(data);
} catch (error) {
if (error instanceof BadUserInputError) {
return error.invalidArgsLocalized;
}
// tslint:disable-next-line:no-console
console.error(error);
}
return undefined;
};
private handleChange = ({ dirty }: FormState) => {
this.dirty = dirty;
};
private addSubmitHook: AddSubmitHook = hook => {
this.submitHooks.push(hook);
return () => {
this.submitHooks = this.submitHooks.filter(h => h === hook);
};
};
public render() {
return (
<SubmitHookContextProvider value={this.addSubmitHook}>
<Configure onChange={this.handleChange} onSave={this.handleSave}>
{this.props.children}
</Configure>
</SubmitHookContextProvider>
);
}
}
const enhanced = withContext(({ localeBundles }) => ({ localeBundles }))(
withUpdateSettingsMutation(ConfigureContainer)
);
export default enhanced;
@@ -0,0 +1,31 @@
import React, { StatelessComponent } from "react";
import { PropTypesOf } from "talk-framework/types";
import { HorizontalGutter } from "talk-ui/components";
import DisplayNamesConfigContainer from "../containers/DisplayNamesConfigContainer";
import AuthIntegrationsConfig from "./AuthIntegrationsConfig";
interface Props {
disabled?: boolean;
auth: PropTypesOf<typeof AuthIntegrationsConfig>["auth"] &
PropTypesOf<typeof DisplayNamesConfigContainer>["auth"];
onInitValues: (values: any) => void;
}
const Auth: StatelessComponent<Props> = ({ disabled, auth, onInitValues }) => (
<HorizontalGutter size="double">
<DisplayNamesConfigContainer
disabled={disabled}
auth={auth}
onInitValues={onInitValues}
/>
<AuthIntegrationsConfig
disabled={disabled}
auth={auth}
onInitValues={onInitValues}
/>
</HorizontalGutter>
);
export default Auth;
@@ -0,0 +1,69 @@
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import { PropTypesOf } from "talk-framework/types";
import { HorizontalGutter } from "talk-ui/components";
import Header from "../../../components/Header";
import FacebookConfigContainer from "../containers/FacebookConfigContainer";
import GoogleConfigContainer from "../containers/GoogleConfigContainer";
import LocalAuthConfigContainer from "../containers/LocalAuthConfigContainer";
import OIDCConfigListContainer from "../containers/OIDCConfigListContainer";
import SSOConfigContainer from "../containers/SSOConfigContainer";
interface Props {
disabled?: boolean;
auth: PropTypesOf<typeof FacebookConfigContainer>["auth"] &
PropTypesOf<typeof FacebookConfigContainer>["authReadOnly"] &
PropTypesOf<typeof GoogleConfigContainer>["auth"] &
PropTypesOf<typeof GoogleConfigContainer>["authReadOnly"] &
PropTypesOf<typeof SSOConfigContainer>["auth"] &
PropTypesOf<typeof SSOConfigContainer>["authReadOnly"] &
PropTypesOf<typeof LocalAuthConfigContainer>["auth"] &
PropTypesOf<typeof OIDCConfigListContainer>["auth"] &
PropTypesOf<typeof OIDCConfigListContainer>["authReadOnly"];
onInitValues: (values: any) => void;
}
const AuthIntegrationsConfig: StatelessComponent<Props> = ({
disabled,
auth,
onInitValues,
}) => (
<HorizontalGutter size="double">
<Localized id="configure-auth-authIntegrations">
<Header>Auth Integrations</Header>
</Localized>
<LocalAuthConfigContainer
disabled={disabled}
auth={auth}
onInitValues={onInitValues}
/>
<OIDCConfigListContainer
disabled={disabled}
auth={auth}
authReadOnly={auth}
onInitValues={onInitValues}
/>
<SSOConfigContainer
disabled={disabled}
auth={auth}
authReadOnly={auth}
onInitValues={onInitValues}
/>
<GoogleConfigContainer
disabled={disabled}
auth={auth}
authReadOnly={auth}
onInitValues={onInitValues}
/>
<FacebookConfigContainer
disabled={disabled}
auth={auth}
authReadOnly={auth}
onInitValues={onInitValues}
/>
</HorizontalGutter>
);
export default AuthIntegrationsConfig;
@@ -0,0 +1,51 @@
import { Localized } from "fluent-react/compat";
import { identity } from "lodash";
import React, { StatelessComponent } from "react";
import { Field } from "react-final-form";
import { Validator } from "talk-framework/lib/validation";
import { FormField, InputLabel, TextField } from "talk-ui/components";
import ValidationMessage from "./ValidationMessage";
interface Props {
validate?: Validator;
name: string;
disabled: boolean;
}
const ClientSecretField: StatelessComponent<Props> = ({
name,
disabled,
validate,
}) => (
<FormField>
<Localized id="configure-auth-clientID">
<InputLabel>Client ID</InputLabel>
</Localized>
<Field name={name} parse={identity} validate={validate}>
{({ input, meta }) => (
<>
<TextField
name={input.name}
onChange={input.onChange}
value={input.value}
disabled={disabled}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
/>
{meta.touched &&
(meta.error || meta.submitError) && (
<ValidationMessage>
{meta.error || meta.submitError}
</ValidationMessage>
)}
</>
)}
</Field>
</FormField>
);
export default ClientSecretField;
@@ -0,0 +1,56 @@
import { Localized } from "fluent-react/compat";
import { identity } from "lodash";
import React, { StatelessComponent } from "react";
import { Field } from "react-final-form";
import { Validator } from "talk-framework/lib/validation";
import { FormField, InputLabel, TextField } from "talk-ui/components";
import ValidationMessage from "./ValidationMessage";
interface Props {
validate?: Validator;
name: string;
disabled: boolean;
}
const ClientSecretField: StatelessComponent<Props> = ({
name,
disabled,
validate,
}) => (
<FormField>
<Localized id="configure-auth-clientSecret">
<InputLabel>Client Secret</InputLabel>
</Localized>
<Field
name={name}
key={(disabled && "on") || "off"}
parse={identity}
validate={validate}
>
{({ input, meta }) => (
<>
<TextField
name={input.name}
onChange={input.onChange}
value={input.value}
disabled={disabled}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
/>
{meta.touched &&
(meta.error || meta.submitError) && (
<ValidationMessage>
{meta.error || meta.submitError}
</ValidationMessage>
)}
</>
)}
</Field>
</FormField>
);
export default ClientSecretField;
@@ -0,0 +1,54 @@
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import { Field } from "react-final-form";
import { CheckBox, FormField } from "talk-ui/components";
import ConfigBox from "../../../components/ConfigBox";
interface Props {
id?: string;
name: string;
title: React.ReactNode;
disabled?: boolean;
children: (disabledInside: boolean) => React.ReactNode;
}
const bool = (v: any) => !!v;
const ConfigBoxWithToggleField: StatelessComponent<Props> = ({
id,
name,
title,
disabled,
children,
}) => (
<Field name={name} type="checkbox" parse={bool}>
{({ input }) => (
<ConfigBox
id={id}
title={title}
topRight={
<FormField>
<Localized id="configure-auth-configBoxEnabled">
<CheckBox
id={input.name}
name={input.name}
onChange={input.onChange}
checked={input.value}
disabled={disabled}
light
>
Enabled
</CheckBox>
</Localized>
</FormField>
}
>
{children(disabled || !input.value)}
</ConfigBox>
)}
</Field>
);
export default ConfigBoxWithToggleField;
@@ -0,0 +1,3 @@
.description {
width: calc(30 * var(--spacing-unit));
}
@@ -0,0 +1,22 @@
import React, { StatelessComponent } from "react";
import { InputDescription } from "talk-ui/components";
import { PropTypesOf } from "talk-ui/types";
interface Props {
container?: PropTypesOf<typeof InputDescription>["container"];
children: React.ReactNode;
}
import styles from "./ConfigDescription.css";
const ConfigDescription: StatelessComponent<Props> = ({
children,
container,
}) => (
<InputDescription className={styles.description} container={container}>
{children}
</InputDescription>
);
export default ConfigDescription;
@@ -0,0 +1,94 @@
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import { Field } from "react-final-form";
import {
Flex,
FormField,
HorizontalGutter,
RadioButton,
Typography,
} from "talk-ui/components";
import Header from "../../../components/Header";
interface Props {
disabled?: boolean;
}
const parseStringBool = (v: string) => v === "true";
const DisplayNamesConfig: StatelessComponent<Props> = ({ disabled }) => (
<HorizontalGutter size="oneAndAHalf">
<Localized id="configure-auth-displayNamesConfig-title">
<Header>Display Names</Header>
</Localized>
<Localized id="configure-auth-displayNamesConfig-explanationShort">
<Typography>
Some AUTH integrations include a Display Name as well as a User Name.
</Typography>
</Localized>
<Localized id="configure-auth-displayNamesConfig-explanationLong">
<Typography>
A User Name has to be unique (there can only be one Juan_Doe, for
example), whereas a Display Name does not. If your AUTH provider allows
for Display Names, you can enable this option. This allows for fewer
strange names (Juan_Doe23245) however it could also be used to
spoof/impersonate another user.
</Typography>
</Localized>
<FormField>
<Flex direction="row" itemGutter="double">
<Field
name={"auth.displayName.enabled"}
type="radio"
parse={parseStringBool}
value
>
{({ input }) => (
<Localized id="configure-auth-displayNamesConfig-showDisplayNames">
<RadioButton
id={`${input.name}-true`}
name={input.name}
onChange={input.onChange}
onFocus={input.onFocus}
onBlur={input.onBlur}
checked={input.checked}
disabled={disabled}
value={input.value}
>
Show Display Names (if available)
</RadioButton>
</Localized>
)}
</Field>
<Field
name={"auth.displayName.enabled"}
type="radio"
parse={parseStringBool}
value={false}
>
{({ input }) => (
<Localized id="configure-auth-displayNamesConfig-hideDisplayNames">
<RadioButton
id={`${input.name}-false`}
name={input.name}
onChange={input.onChange}
onFocus={input.onFocus}
onBlur={input.onBlur}
checked={input.checked}
disabled={disabled}
value={input.value}
>
Hide Display Names (if available)
</RadioButton>
</Localized>
)}
</Field>
</Flex>
</FormField>
</HorizontalGutter>
);
export default DisplayNamesConfig;
@@ -0,0 +1,93 @@
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import { required, Validator } from "talk-framework/lib/validation";
import { HorizontalGutter, TextLink, Typography } from "talk-ui/components";
import HorizontalRule from "../../../components/HorizontalRule";
import ClientIDField from "./ClientIDField";
import ClientSecretField from "./ClientSecretField";
import ConfigBoxWithToggleField from "./ConfigBoxWithToggleField";
import RedirectField from "./RedirectField";
import RegistrationField from "./RegistrationField";
import TargetFilterField from "./TargetFilterField";
interface Props {
disabled?: boolean;
callbackURL: string;
}
const FacebookLink = () => (
<TextLink target="_blank">
{"https://developers.facebook.com/docs/facebook-login/web"}
</TextLink>
);
const validateWhenEnabled = (validator: Validator): Validator => (
v,
values
) => {
if (values.auth.integrations.facebook.enabled) {
return validator(v, values);
}
return "";
};
const FacebookConfig: StatelessComponent<Props> = ({
disabled,
callbackURL,
}) => (
<ConfigBoxWithToggleField
id="configure-auth-facebook-container"
title={
<Localized id="configure-auth-facebook-loginWith">
<span>Login with Facebook</span>
</Localized>
}
name="auth.integrations.facebook.enabled"
disabled={disabled}
>
{disabledInside => (
<HorizontalGutter size="double">
<Localized
id="configure-auth-facebook-toEnableIntegration"
link={<FacebookLink />}
>
<Typography>
To enable the integration with Facebook Authentication, you need to
create and set up a web application. For more information visit:<br />
{"https://developers.facebook.com/docs/facebook-login/web"}
</Typography>
</Localized>
<HorizontalRule />
<RedirectField url={callbackURL} />
<HorizontalRule />
<ClientIDField
name="auth.integrations.facebook.clientID"
validate={validateWhenEnabled(required)}
disabled={disabledInside}
/>
<ClientSecretField
name="auth.integrations.facebook.clientSecret"
validate={validateWhenEnabled(required)}
disabled={disabledInside}
/>
<TargetFilterField
label={
<Localized id="configure-auth-facebook-useLoginOn">
<span>Use Facebook login on</span>
</Localized>
}
name="auth.integrations.facebook.targetFilter"
disabled={disabledInside}
/>
<RegistrationField
name="auth.integrations.facebook.allowRegistration"
disabled={disabledInside}
/>
</HorizontalGutter>
)}
</ConfigBoxWithToggleField>
);
export default FacebookConfig;
@@ -0,0 +1,93 @@
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import { required, Validator } from "talk-framework/lib/validation";
import { HorizontalGutter, TextLink, Typography } from "talk-ui/components";
import HorizontalRule from "../../../components/HorizontalRule";
import ClientIDField from "./ClientIDField";
import ClientSecretField from "./ClientSecretField";
import ConfigBoxWithToggleField from "./ConfigBoxWithToggleField";
import RedirectField from "./RedirectField";
import RegistrationField from "./RegistrationField";
import TargetFilterField from "./TargetFilterField";
interface Props {
disabled?: boolean;
callbackURL: string;
}
const GoogleLink = () => (
<TextLink target="_blank">
{
"https://developers.google.com/identity/protocols/OAuth2WebServer#creatingcred"
}
</TextLink>
);
const validateWhenEnabled = (validator: Validator): Validator => (
v,
values
) => {
if (values.auth.integrations.google.enabled) {
return validator(v, values);
}
return "";
};
const GoogleConfig: StatelessComponent<Props> = ({ disabled, callbackURL }) => (
<ConfigBoxWithToggleField
title={
<Localized id="configure-auth-google-loginWith">
<span>Login with Google</span>
</Localized>
}
name="auth.integrations.google.enabled"
disabled={disabled}
>
{disabledInside => (
<HorizontalGutter size="double">
<Localized
id="configure-auth-google-toEnableIntegration"
link={<GoogleLink />}
>
<Typography>
To enable the integration with Google Authentication you need to
create and set up a web application. For more information visit:<br />
{
"https://developers.google.com/identity/protocols/OAuth2WebServer#creatingcred"
}
</Typography>
</Localized>
<HorizontalRule />
<RedirectField url={callbackURL} />
<HorizontalRule />
<ClientIDField
name="auth.integrations.google.clientID"
validate={validateWhenEnabled(required)}
disabled={disabledInside}
/>
<ClientSecretField
name="auth.integrations.google.clientSecret"
validate={validateWhenEnabled(required)}
disabled={disabledInside}
/>
<TargetFilterField
label={
<Localized id="configure-auth-google-useLoginOn">
<span>Use Google login on</span>
</Localized>
}
name="auth.integrations.google.targetFilter"
disabled={disabledInside}
/>
<RegistrationField
name="auth.integrations.google.allowRegistration"
disabled={disabledInside}
/>
</HorizontalGutter>
)}
</ConfigBoxWithToggleField>
);
export default GoogleConfig;
@@ -0,0 +1,44 @@
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import { HorizontalGutter } from "talk-ui/components";
import ConfigBoxWithToggleField from "./ConfigBoxWithToggleField";
import RegistrationField from "./RegistrationField";
import TargetFilterField from "./TargetFilterField";
interface Props {
disabled?: boolean;
}
const LocalAuthConfig: StatelessComponent<Props> = ({ disabled }) => (
<ConfigBoxWithToggleField
title={
<Localized id="configure-auth-local-loginWith">
<span>Login with LocalAuth</span>
</Localized>
}
name="auth.integrations.local.enabled"
disabled={disabled}
>
{disabledInside => (
<HorizontalGutter size="double">
<TargetFilterField
label={
<Localized id="configure-auth-local-useLoginOn">
<span>Use LocalAuth login on</span>
</Localized>
}
name="auth.integrations.local.targetFilter"
disabled={disabledInside}
/>
<RegistrationField
name="auth.integrations.local.allowRegistration"
disabled={disabledInside}
/>
</HorizontalGutter>
)}
</ConfigBoxWithToggleField>
);
export default LocalAuthConfig;
@@ -0,0 +1,4 @@
.redirectDescriptionIcon {
color: var(--palette-text-secondary);
flex-shrink: 0;
}
@@ -0,0 +1,317 @@
import { Localized } from "fluent-react/compat";
import { identity } from "lodash";
import React, { StatelessComponent } from "react";
import { Field } from "react-final-form";
import {
composeValidators,
required,
validateURL,
Validator,
} from "talk-framework/lib/validation";
import {
Button,
Flex,
FormField,
HorizontalGutter,
Icon,
InputLabel,
TextField,
TextLink,
Typography,
} from "talk-ui/components";
import HorizontalRule from "../../../components/HorizontalRule";
import ClientIDField from "./ClientIDField";
import ClientSecretField from "./ClientSecretField";
import ConfigBoxWithToggleField from "./ConfigBoxWithToggleField";
import ConfigDescription from "./ConfigDescription";
import styles from "./OIDCConfig.css";
import RedirectField from "./RedirectField";
import RegistrationField from "./RegistrationField";
import TargetFilterField from "./TargetFilterField";
import ValidationMessage from "./ValidationMessage";
interface Props {
index: number;
disabled?: boolean;
callbackURL: string;
disableForDiscover?: boolean;
onDiscover?: () => void;
}
const OIDCLink = () => (
<TextLink target="_blank">{"https://openid.net/connect/"}</TextLink>
);
const OIDCConfig: StatelessComponent<Props> = ({
disabled,
callbackURL,
index,
onDiscover,
disableForDiscover,
}) => {
const validateWhenEnabled = (validator: Validator): Validator => (
v,
values
) => {
if (values.auth.integrations.oidc[0].enabled) {
return validator(v, values);
}
return "";
};
return (
<ConfigBoxWithToggleField
id={`configure-auth-oidc-container-${index}`}
title={
<Localized id="configure-auth-oidc-loginWith">
<span>Login with OIDC</span>
</Localized>
}
name={`auth.integrations.oidc.${index}.enabled`}
disabled={disabled}
>
{disabledInside => (
<HorizontalGutter size="double">
<Localized id="configure-auth-oidc-toLearnMore" link={<OIDCLink />}>
<Typography>
{"To learn more: https://openid.net/connect/"}
</Typography>
</Localized>
<HorizontalRule />
<RedirectField
url={callbackURL}
description={
<ConfigDescription container="div">
<Flex itemGutter="half">
<Icon className={styles.redirectDescriptionIcon}>error</Icon>
<Localized id="configure-auth-oidc-redirectDescription">
<div>
For OpenID Connect, your Redirect URI will not appear
until you after you save this integration
</div>
</Localized>
</Flex>
</ConfigDescription>
}
/>
<HorizontalRule />
<FormField>
<Localized id="configure-auth-oidc-providerName">
<InputLabel>Provider Name</InputLabel>
</Localized>
<Localized id="configure-auth-oidc-providerNameDescription">
<ConfigDescription>
The provider of the OIDC integration. This will be used when the
name of the provider needs to be displayed, e.g. Log in with
{" <Facebook>"}
</ConfigDescription>
</Localized>
<Field
name={`auth.integrations.oidc.${index}.name`}
validate={validateWhenEnabled(required)}
parse={identity}
>
{({ input, meta }) => (
<>
<TextField
name={input.name}
onChange={input.onChange}
value={input.value}
disabled={disabledInside}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
/>
{meta.touched &&
(meta.error || meta.submitError) && (
<ValidationMessage>
{meta.error || meta.submitError}
</ValidationMessage>
)}
</>
)}
</Field>
</FormField>
<ClientIDField
validate={validateWhenEnabled(required)}
name={`auth.integrations.oidc.${index}.clientID`}
disabled={disabledInside}
/>
<ClientSecretField
validate={validateWhenEnabled(required)}
name={`auth.integrations.oidc.${index}.clientSecret`}
disabled={disabledInside}
/>
<FormField>
<Localized id="configure-auth-oidc-issuer">
<InputLabel>Issuer</InputLabel>
</Localized>
<Localized id="configure-auth-oidc-issuerDescription">
<ConfigDescription>
After entering your Issuer information, click the Discover
button to have Talk complete the remaining fields. You may also
enter the information manually
</ConfigDescription>
</Localized>
<Field
name={`auth.integrations.oidc.${index}.issuer`}
validate={validateWhenEnabled(
composeValidators(required, validateURL)
)}
parse={identity}
>
{({ input, meta }) => (
<>
<Flex direction="row" itemGutter="half" alignItems="center">
<TextField
name={input.name}
onChange={input.onChange}
value={input.value}
disabled={disabledInside || disableForDiscover}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
/>
<Button
id="configure-auth-oidc-discover-0"
variant="filled"
color="primary"
size="small"
disabled={disabledInside || disableForDiscover}
onClick={onDiscover}
>
Discover
</Button>
</Flex>
{meta.touched &&
(meta.error || meta.submitError) && (
<ValidationMessage>
{meta.error || meta.submitError}
</ValidationMessage>
)}
</>
)}
</Field>
</FormField>
<FormField>
<Localized id="configure-auth-oidc-authorizationURL">
<InputLabel>authorizationURL</InputLabel>
</Localized>
<Field
name={`auth.integrations.oidc.${index}.authorizationURL`}
validate={validateWhenEnabled(
composeValidators(required, validateURL)
)}
parse={identity}
>
{({ input, meta }) => (
<>
<TextField
name={input.name}
onChange={input.onChange}
value={input.value}
disabled={disabledInside || disableForDiscover}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
/>
{meta.touched &&
(meta.error || meta.submitError) && (
<ValidationMessage>
{meta.error || meta.submitError}
</ValidationMessage>
)}
</>
)}
</Field>
</FormField>
<FormField>
<Localized id="configure-auth-oidc-tokenURL">
<InputLabel>tokenURL</InputLabel>
</Localized>
<Field
name={`auth.integrations.oidc.${index}.tokenURL`}
validate={validateWhenEnabled(
composeValidators(required, validateURL)
)}
parse={identity}
>
{({ input, meta }) => (
<>
<TextField
name={input.name}
onChange={input.onChange}
value={input.value}
disabled={disabledInside || disableForDiscover}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
/>
{meta.touched &&
(meta.error || meta.submitError) && (
<ValidationMessage>
{meta.error || meta.submitError}
</ValidationMessage>
)}
</>
)}
</Field>
</FormField>
<FormField>
<Localized id="configure-auth-oidc-jwksURI">
<InputLabel>jwksURI</InputLabel>
</Localized>
<Field
name={`auth.integrations.oidc.${index}.jwksURI`}
validate={validateWhenEnabled(
composeValidators(required, validateURL)
)}
parse={identity}
>
{({ input, meta }) => (
<>
<TextField
name={input.name}
onChange={input.onChange}
value={input.value}
disabled={disabledInside || disableForDiscover}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
/>
{meta.touched &&
(meta.error || meta.submitError) && (
<ValidationMessage>
{meta.error || meta.submitError}
</ValidationMessage>
)}
</>
)}
</Field>
</FormField>
<TargetFilterField
label={
<Localized id="configure-auth-oidc-useLoginOn">
<span>Use OIDC login on</span>
</Localized>
}
name={`auth.integrations.oidc.${index}.targetFilter`}
disabled={disabledInside}
/>
<RegistrationField
name={`auth.integrations.oidc.${index}.allowRegistration`}
disabled={disabledInside}
/>
</HorizontalGutter>
)}
</ConfigBoxWithToggleField>
);
};
export default OIDCConfig;
@@ -0,0 +1,25 @@
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import { CopyButton } from "talk-framework/components";
import { Flex, FormField, InputLabel, TextField } from "talk-ui/components";
interface Props {
description?: React.ReactNode;
url: string;
}
const RedirectField: StatelessComponent<Props> = ({ url, description }) => (
<FormField>
<Localized id="configure-auth-redirectURI">
<InputLabel>Redirect URI</InputLabel>
</Localized>
{description}
<Flex direction="row" itemGutter="half" alignItems="center">
<TextField name="redirectURI" value={url} readOnly />
<CopyButton text={url} />
</Flex>
</FormField>
);
export default RedirectField;
@@ -0,0 +1,44 @@
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import { Field } from "react-final-form";
import { CheckBox, FormField, InputLabel } from "talk-ui/components";
import ConfigDescription from "./ConfigDescription";
interface Props {
name: string;
disabled: boolean;
}
const RegistrationField: StatelessComponent<Props> = ({ name, disabled }) => (
<FormField>
<Localized id="configure-auth-registration">
<InputLabel>Registration</InputLabel>
</Localized>
<Localized id="configure-auth-registrationDescription">
<ConfigDescription>
Allow users to create a new account with this provider.
</ConfigDescription>
</Localized>
<FormField>
<Field name={name} type="checkbox">
{({ input }) => (
<Localized id="configure-auth-registrationCheckBox">
<CheckBox
id={input.name}
name={input.name}
onChange={input.onChange}
checked={input.value}
disabled={disabled}
>
Allow Registration
</CheckBox>
</Localized>
)}
</Field>
</FormField>
</FormField>
);
export default RegistrationField;
@@ -0,0 +1,48 @@
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import { PropTypesOf } from "talk-framework/types";
import { HorizontalGutter } from "talk-ui/components";
import SSOKeyFieldContainer from "../containers/SSOKeyFieldContainer";
import ConfigBoxWithToggleField from "./ConfigBoxWithToggleField";
import RegistrationField from "./RegistrationField";
import TargetFilterField from "./TargetFilterField";
interface Props {
disabled?: boolean;
sso: PropTypesOf<typeof SSOKeyFieldContainer>["sso"];
}
const SSOConfig: StatelessComponent<Props> = ({ disabled, sso }) => (
<ConfigBoxWithToggleField
title={
<Localized id="configure-auth-sso-loginWith">
<span>Login with SSO</span>
</Localized>
}
name="auth.integrations.sso.enabled"
disabled={disabled}
>
{disabledInside => (
<HorizontalGutter size="double">
<SSOKeyFieldContainer sso={sso} disabled={disabledInside} />
<TargetFilterField
label={
<Localized id="configure-auth-sso-useLoginOn">
<span>Use SSO login on</span>
</Localized>
}
name="auth.integrations.sso.targetFilter"
disabled={disabledInside}
/>
<RegistrationField
name="auth.integrations.sso.allowRegistration"
disabled={disabledInside}
/>
</HorizontalGutter>
)}
</ConfigBoxWithToggleField>
);
export default SSOConfig;
@@ -0,0 +1,16 @@
.keyGenerated {
composes: button from "talk-ui/shared/typography.css";
color: var(--palette-text-secondary);
width: calc(29 * var(--spacing-unit));
flex-shrink: 0;
}
.warnIcon {
color: var(--palette-text-secondary);
flex-shrink: 0;
padding-top: 3px;
}
.warn {
color: var(--palette-text-secondary);
}
@@ -0,0 +1,65 @@
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import {
Button,
Flex,
FormField,
Icon,
InputLabel,
TextField,
Typography,
} from "talk-ui/components";
import styles from "./SSOKeyField.css";
interface Props {
disabled?: boolean;
generatedKey?: string;
keyGeneratedAt?: any;
onRegenerate?: () => void;
}
const SSOKeyField: StatelessComponent<Props> = ({
generatedKey,
keyGeneratedAt,
disabled,
onRegenerate,
}) => (
<FormField id="configure-auth-sso-key">
<Localized id="configure-auth-sso-key">
<InputLabel>Key</InputLabel>
</Localized>
<Flex direction="row" itemGutter="half" alignItems="center">
<TextField name="key" value={generatedKey} readOnly />
<Localized id="configure-auth-sso-regenerate">
<Button
id="configure-auth-sso-regenerate"
variant="filled"
color="primary"
size="small"
disabled={disabled}
onClick={onRegenerate}
>
Regenerate
</Button>
</Localized>
</Flex>
<Flex direction="row" itemGutter="half">
<Localized id="configure-auth-sso-regenerateAt" $date={keyGeneratedAt}>
<Typography className={styles.keyGenerated}>
KEY GENERATED AT: {keyGeneratedAt}
</Typography>
</Localized>
<Icon className={styles.warnIcon}>warning</Icon>
<Localized id="configure-auth-sso-regenerateWarning">
<Typography className={styles.warn} variant="bodyCopy">
Regenerating a key will invalidate any existing user sessions, and all
signed-in users will be signed out
</Typography>
</Localized>
</Flex>
</FormField>
);
export default SSOKeyField;
@@ -0,0 +1,57 @@
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import { Field } from "react-final-form";
import { CheckBox, Flex, FormField, InputLabel } from "talk-ui/components";
const bool = (v: any) => !!v;
interface Props {
label: React.ReactNode;
name: string;
disabled: boolean;
}
const TargetFilterField: StatelessComponent<Props> = ({
name,
label,
disabled,
}) => (
<FormField>
<InputLabel>{label}</InputLabel>
<Flex direction="row" itemGutter="double">
<Field name={`${name}.admin`} type="checkbox" parse={bool}>
{({ input, meta }) => (
<Localized id="configure-auth-targetFilterTalkAdmin">
<CheckBox
id={input.name}
name={input.name}
onChange={input.onChange}
checked={!!input.value}
disabled={disabled}
>
Talk Admin
</CheckBox>
</Localized>
)}
</Field>
<Field name={`${name}.stream`} type="checkbox" parse={bool}>
{({ input }) => (
<Localized id="configure-auth-targetFilterCommentStream">
<CheckBox
id={input.name}
name={input.name}
onChange={input.onChange}
checked={!!input.value}
disabled={disabled}
>
Comment Stream
</CheckBox>
</Localized>
)}
</Field>
</Flex>
</FormField>
);
export default TargetFilterField;
@@ -0,0 +1,3 @@
.root {
min-width: calc(29 * var(--spacing-unit));
}
@@ -0,0 +1,15 @@
import React, { StatelessComponent } from "react";
import { ValidationMessage as UIValidationMessage } from "talk-ui/components";
interface Props {
children: React.ReactNode;
}
import styles from "./ValidationMessage.css";
const ValidationMessage: StatelessComponent<Props> = ({ children }) => (
<UIValidationMessage className={styles.root}>{children}</UIValidationMessage>
);
export default ValidationMessage;
@@ -0,0 +1,67 @@
import { FormApi } from "final-form";
import { RouteProps } from "found";
import { merge } from "lodash";
import React from "react";
import { graphql } from "react-relay";
import { AuthContainerQueryResponse } from "talk-admin/__generated__/AuthContainerQuery.graphql";
import { Spinner } from "talk-ui/components";
import Auth from "../components/Auth";
interface Props extends AuthContainerQueryResponse {
form: FormApi;
submitting?: boolean;
}
export default class AuthContainer extends React.Component<Props> {
public static routeConfig: RouteProps;
private initialValues = {};
constructor(props: Props) {
super(props);
}
public componentDidMount() {
this.props.form.initialize({ auth: this.initialValues });
}
private handleOnInitValues = (values: any) => {
this.initialValues = merge(this.initialValues, values);
};
public render() {
return (
<Auth
disabled={this.props.submitting}
auth={this.props.settings.auth}
onInitValues={this.handleOnInitValues}
/>
);
}
}
AuthContainer.routeConfig = {
Component: AuthContainer,
query: graphql`
query AuthContainerQuery {
settings {
auth {
...FacebookConfigContainer_auth
...FacebookConfigContainer_authReadOnly
...GoogleConfigContainer_auth
...GoogleConfigContainer_authReadOnly
...SSOConfigContainer_auth
...SSOConfigContainer_authReadOnly
...LocalAuthConfigContainer_auth
...DisplayNamesConfigContainer_auth
...OIDCConfigListContainer_auth
...OIDCConfigListContainer_authReadOnly
}
}
}
`,
cacheConfig: { force: true },
render: ({ Component, props }) =>
props && Component ? <Component {...props} /> : <Spinner />,
};
@@ -0,0 +1,37 @@
import React from "react";
import { graphql } from "react-relay";
import { DisplayNamesConfigContainer_auth as AuthData } from "talk-admin/__generated__/DisplayNamesConfigContainer_auth.graphql";
import { withFragmentContainer } from "talk-framework/lib/relay";
import DisplayNamesConfig from "../components/DisplayNamesConfig";
interface Props {
auth: AuthData;
onInitValues: (values: AuthData) => void;
disabled?: boolean;
}
class DisplayNamesConfigContainer extends React.Component<Props> {
constructor(props: Props) {
super(props);
props.onInitValues(props.auth);
}
public render() {
const { disabled } = this.props;
return <DisplayNamesConfig disabled={disabled} />;
}
}
const enhanced = withFragmentContainer<Props>({
auth: graphql`
fragment DisplayNamesConfigContainer_auth on Auth {
displayName {
enabled
}
}
`,
})(DisplayNamesConfigContainer);
export default enhanced;
@@ -0,0 +1,62 @@
import React from "react";
import { graphql } from "react-relay";
import { FacebookConfigContainer_auth as AuthData } from "talk-admin/__generated__/FacebookConfigContainer_auth.graphql";
import { FacebookConfigContainer_authReadOnly as AuthReadOnlyData } from "talk-admin/__generated__/FacebookConfigContainer_authReadOnly.graphql";
import { withFragmentContainer } from "talk-framework/lib/relay";
import FacebookConfig from "../components/FacebookConfig";
interface Props {
auth: AuthData;
authReadOnly: AuthReadOnlyData;
onInitValues: (values: AuthData) => void;
disabled?: boolean;
}
class FacebookConfigContainer extends React.Component<Props> {
constructor(props: Props) {
super(props);
props.onInitValues(props.auth);
}
public render() {
const { disabled, authReadOnly } = this.props;
return (
<FacebookConfig
disabled={disabled}
callbackURL={authReadOnly.integrations.facebook.callbackURL}
/>
);
}
}
const enhanced = withFragmentContainer<Props>({
auth: graphql`
fragment FacebookConfigContainer_auth on Auth {
integrations {
facebook {
enabled
allowRegistration
targetFilter {
admin
stream
}
clientID
clientSecret
}
}
}
`,
authReadOnly: graphql`
fragment FacebookConfigContainer_authReadOnly on Auth {
integrations {
facebook {
callbackURL
}
}
}
`,
})(FacebookConfigContainer);
export default enhanced;
@@ -0,0 +1,62 @@
import React from "react";
import { graphql } from "react-relay";
import { GoogleConfigContainer_auth as AuthData } from "talk-admin/__generated__/GoogleConfigContainer_auth.graphql";
import { GoogleConfigContainer_authReadOnly as AuthReadOnlyData } from "talk-admin/__generated__/GoogleConfigContainer_authReadOnly.graphql";
import { withFragmentContainer } from "talk-framework/lib/relay";
import GoogleConfig from "../components/GoogleConfig";
interface Props {
auth: AuthData;
authReadOnly: AuthReadOnlyData;
onInitValues: (values: AuthData) => void;
disabled?: boolean;
}
class GoogleConfigContainer extends React.Component<Props> {
constructor(props: Props) {
super(props);
props.onInitValues(props.auth);
}
public render() {
const { disabled, authReadOnly } = this.props;
return (
<GoogleConfig
disabled={disabled}
callbackURL={authReadOnly.integrations.google.callbackURL}
/>
);
}
}
const enhanced = withFragmentContainer<Props>({
auth: graphql`
fragment GoogleConfigContainer_auth on Auth {
integrations {
google {
enabled
allowRegistration
targetFilter {
admin
stream
}
clientID
clientSecret
}
}
}
`,
authReadOnly: graphql`
fragment GoogleConfigContainer_authReadOnly on Auth {
integrations {
google {
callbackURL
}
}
}
`,
})(GoogleConfigContainer);
export default enhanced;
@@ -0,0 +1,44 @@
import React from "react";
import { graphql } from "react-relay";
import { LocalAuthConfigContainer_auth as AuthData } from "talk-admin/__generated__/LocalAuthConfigContainer_auth.graphql";
import { withFragmentContainer } from "talk-framework/lib/relay";
import LocalAuthConfig from "../components/LocalAuthConfig";
interface Props {
auth: AuthData;
onInitValues: (values: AuthData) => void;
disabled?: boolean;
}
class LocalAuthConfigContainer extends React.Component<Props> {
constructor(props: Props) {
super(props);
props.onInitValues(props.auth);
}
public render() {
const { disabled } = this.props;
return <LocalAuthConfig disabled={disabled} />;
}
}
const enhanced = withFragmentContainer<Props>({
auth: graphql`
fragment LocalAuthConfigContainer_auth on Auth {
integrations {
local {
enabled
allowRegistration
targetFilter {
admin
stream
}
}
}
}
`,
})(LocalAuthConfigContainer);
export default enhanced;
@@ -0,0 +1,70 @@
import { FormApi } from "final-form";
import PropTypes from "prop-types";
import React from "react";
import {
DiscoverOIDCConfigurationFetch,
withDiscoverOIDCConfigurationFetch,
} from "talk-admin/fetches";
import OIDCConfig from "../components/OIDCConfig";
interface Props {
index: number;
callbackURL: string;
disabled?: boolean;
discoverOIDCConfiguration: DiscoverOIDCConfigurationFetch;
}
interface State {
awaitingResponse: boolean;
}
class OIDCConfigContainer extends React.Component<Props, State> {
public static contextTypes = {
reactFinalForm: PropTypes.object,
};
public state = {
awaitingResponse: false,
};
private handleDiscover = async () => {
const form = this.context.reactFinalForm as FormApi;
this.setState({ awaitingResponse: true });
try {
const config = await this.props.discoverOIDCConfiguration({
issuer: form.getState().values.auth.integrations.oidc[0].issuer,
});
if (config) {
form.change(
"auth.integrations.oidc.0.authorizationURL",
config.authorizationURL
);
form.change("auth.integrations.oidc.0.jwksURI", config.jwksURI);
form.change("auth.integrations.oidc.0.tokenURL", config.tokenURL);
}
} catch (error) {
// tslint:disable-next-line:no-console
console.warn(error);
}
this.setState({ awaitingResponse: false });
};
public render() {
const { disabled, index, callbackURL } = this.props;
return (
<OIDCConfig
disabled={disabled}
index={index}
callbackURL={callbackURL}
onDiscover={this.handleDiscover}
disableForDiscover={this.state.awaitingResponse}
/>
);
}
}
const enhanced = withDiscoverOIDCConfigurationFetch(OIDCConfigContainer);
export default enhanced;
@@ -0,0 +1,150 @@
import { cloneDeep } from "lodash";
import React from "react";
import { graphql } from "react-relay";
import { OIDCConfigListContainer_auth as AuthData } from "talk-admin/__generated__/OIDCConfigListContainer_auth.graphql";
import { OIDCConfigListContainer_authReadOnly as AuthReadOnlyData } from "talk-admin/__generated__/OIDCConfigListContainer_authReadOnly.graphql";
import {
CreateOIDCAuthIntegrationMutation,
UpdateOIDCAuthIntegrationMutation,
withCreateOIDCAuthIntegrationMutation,
withUpdateOIDCAuthIntegrationMutation,
} from "talk-admin/mutations";
import { withFragmentContainer } from "talk-framework/lib/relay";
import {
AddSubmitHook,
RemoveSubmitHook,
SubmitHook,
withSubmitHookContext,
} from "../../../submitHook";
import OIDCConfigContainer from "./OIDCConfigContainer";
interface Props {
auth: AuthData;
authReadOnly: AuthReadOnlyData;
disabled?: boolean;
addSubmitHook: AddSubmitHook;
onInitValues: (values: AuthData) => void;
createOIDCAuthIntegration: CreateOIDCAuthIntegrationMutation;
updateOIDCAuthIntegration: UpdateOIDCAuthIntegrationMutation;
}
class OIDCConfigListContainer extends React.Component<Props> {
private removeSubmitHook: RemoveSubmitHook;
constructor(props: Props) {
super(props);
props.onInitValues(this.getAuthWithDefault());
this.removeSubmitHook = this.props.addSubmitHook(this.submitHook);
}
public componentWillUnmount() {
this.removeSubmitHook();
}
private submitHook: SubmitHook = async (data: any) => {
const cloned = cloneDeep(data);
const oidc = cloned.auth.integrations.oidc;
delete cloned.auth.integrations.oidc;
if (this.props.auth.integrations.oidc.length === 0) {
if (oidc[0].enabled) {
await this.props.createOIDCAuthIntegration({ configuration: oidc[0] });
}
} else {
await this.props.updateOIDCAuthIntegration({
configuration: oidc[0],
id: this.props.authReadOnly.integrations.oidc[0].id,
});
}
return cloned;
};
private getAuthWithDefault(): AuthData {
return this.props.auth.integrations.oidc.length === 0
? ({
integrations: {
oidc: [
{
clientID: "",
clientSecret: "",
allowRegistration: false,
targetFilter: {
admin: true,
stream: true,
},
name: "",
authorizationURL: "",
tokenURL: "",
jwksURI: "",
issuer: "",
},
],
},
} as any)
: this.props.auth;
}
public render() {
const { disabled, authReadOnly } = this.props;
const integrations = this.getAuthWithDefault().integrations.oidc.map(
(data, i) => (
<OIDCConfigContainer
key={i}
disabled={disabled}
index={i}
callbackURL={
(authReadOnly.integrations.oidc[i] &&
authReadOnly.integrations.oidc[i].callbackURL) ||
""
}
/>
)
);
return <>{integrations}</>;
}
}
const enhanced = withFragmentContainer<Props>({
auth: graphql`
fragment OIDCConfigListContainer_auth on Auth {
integrations {
oidc {
enabled
allowRegistration
targetFilter {
admin
stream
}
name
clientID
clientSecret
authorizationURL
tokenURL
jwksURI
issuer
}
}
}
`,
authReadOnly: graphql`
fragment OIDCConfigListContainer_authReadOnly on Auth {
integrations {
oidc {
id
callbackURL
}
}
}
`,
})(
withCreateOIDCAuthIntegrationMutation(
withUpdateOIDCAuthIntegrationMutation(
withSubmitHookContext(addSubmitHook => ({ addSubmitHook }))(
OIDCConfigListContainer
)
)
)
);
export default enhanced;
@@ -0,0 +1,60 @@
import React from "react";
import { graphql } from "react-relay";
import { SSOConfigContainer_auth as AuthData } from "talk-admin/__generated__/SSOConfigContainer_auth.graphql";
import { SSOConfigContainer_authReadOnly as AuthReadOnlyData } from "talk-admin/__generated__/SSOConfigContainer_authReadOnly.graphql";
import { withFragmentContainer } from "talk-framework/lib/relay";
import SSOConfig from "../components/SSOConfig";
interface Props {
auth: AuthData;
authReadOnly: AuthReadOnlyData;
onInitValues: (values: AuthData) => void;
disabled?: boolean;
}
class SSOConfigContainer extends React.Component<Props> {
constructor(props: Props) {
super(props);
props.onInitValues(props.auth);
}
public render() {
const { disabled } = this.props;
return (
<SSOConfig
disabled={disabled}
sso={this.props.authReadOnly.integrations.sso}
/>
);
}
}
const enhanced = withFragmentContainer<Props>({
auth: graphql`
fragment SSOConfigContainer_auth on Auth {
integrations {
sso {
enabled
allowRegistration
targetFilter {
admin
stream
}
}
}
}
`,
authReadOnly: graphql`
fragment SSOConfigContainer_authReadOnly on Auth {
integrations {
sso {
...SSOKeyFieldContainer_sso
}
}
}
`,
})(SSOConfigContainer);
export default enhanced;
@@ -0,0 +1,58 @@
import React from "react";
import { graphql } from "react-relay";
import { SSOKeyFieldContainer_sso as SSOData } from "talk-admin/__generated__/SSOKeyFieldContainer_sso.graphql";
import {
RegenerateSSOKeyMutation,
withRegenerateSSOKeyMutation,
} from "talk-admin/mutations";
import { withFragmentContainer } from "talk-framework/lib/relay";
import SSOKeyField from "../components/SSOKeyField";
interface Props {
sso: SSOData;
disabled?: boolean;
regenerateSSOKey: RegenerateSSOKeyMutation;
}
interface State {
awaitingResponse: boolean;
}
class SSOKeyFieldContainer extends React.Component<Props, State> {
public state = {
awaitingResponse: false,
};
private handleRegenerate = async () => {
this.setState({ awaitingResponse: true });
await this.props.regenerateSSOKey();
this.setState({ awaitingResponse: false });
};
public render() {
const { disabled } = this.props;
return (
<SSOKeyField
disabled={disabled || this.state.awaitingResponse}
generatedKey={this.props.sso.key || undefined}
keyGeneratedAt={this.props.sso.keyGeneratedAt || undefined}
onRegenerate={this.handleRegenerate}
/>
);
}
}
const enhanced = withRegenerateSSOKeyMutation(
withFragmentContainer<Props>({
sso: graphql`
fragment SSOKeyFieldContainer_sso on SSOAuthIntegration {
key
keyGeneratedAt
}
`,
})(SSOKeyFieldContainer)
);
export default enhanced;
@@ -0,0 +1,14 @@
import { noop } from "lodash";
import React from "react";
export type SubmitHook = (data: any) => Promise<any> | any;
export type RemoveSubmitHook = () => void;
export type AddSubmitHook = (hook: SubmitHook) => RemoveSubmitHook;
export type SubmitHookContext = AddSubmitHook;
const { Provider, Consumer } = React.createContext<SubmitHookContext>(
() => noop
);
export const SubmitHookContextProvider = Provider;
export const SubmitHookContextConsumer = Consumer;
@@ -0,0 +1,9 @@
export {
AddSubmitHook,
SubmitHook,
RemoveSubmitHook,
SubmitHookContext,
SubmitHookContextConsumer,
SubmitHookContextProvider,
} from "./SubmitHookContext";
export { default as withSubmitHookContext } from "./withSubmitHookContext";
@@ -0,0 +1,12 @@
import { createContextHOC } from "talk-framework/helpers";
import {
SubmitHookContext,
SubmitHookContextConsumer,
} from "./SubmitHookContext";
const withSubmitHookContext = createContextHOC<SubmitHookContext>(
"withSubmitHookContext",
SubmitHookContextConsumer
);
export default withSubmitHookContext;
@@ -195,7 +195,6 @@ exports[`accepts correct password 1`] = `
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
@@ -405,7 +404,6 @@ exports[`accepts valid email 1`] = `
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
@@ -628,7 +626,6 @@ exports[`checks for invalid email 1`] = `
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
@@ -825,7 +822,6 @@ exports[`renders sign in form 1`] = `
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
@@ -1048,7 +1044,6 @@ exports[`shows error when submitting empty form 1`] = `
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
@@ -1245,7 +1240,6 @@ exports[`shows server error 1`] = `
disabled={true}
onBlur={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
@@ -1447,7 +1441,6 @@ exports[`shows server error 2`] = `
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
@@ -1644,7 +1637,6 @@ exports[`submits form successfully 1`] = `
disabled={true}
onBlur={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
@@ -1841,7 +1833,6 @@ exports[`submits form successfully 2`] = `
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,287 @@
import { cloneDeep, get, merge } from "lodash";
import sinon from "sinon";
import { timeout } from "talk-common/utils";
import {
createSinonStub,
inputPredicate,
limitSnapshotTo,
replaceHistoryLocation,
} from "talk-framework/testHelpers";
import create from "../create";
import { settings } from "../fixtures";
beforeEach(async () => {
replaceHistoryLocation("http://localhost/admin/configure/auth");
});
const createTestRenderer = async (resolver: any = {}) => {
const resolvers = {
...resolver,
Query: {
...resolver.Query,
settings: sinon
.stub()
.returns(merge(settings, get(resolver, "Query.settings"))),
},
};
const { testRenderer } = create({
// Set this to true, to see graphql responses.
logNetwork: false,
resolvers,
initLocalState: localRecord => {
localRecord.setValue(true, "loggedIn");
},
});
await timeout();
return testRenderer;
};
it("renders configure auth", async () => {
const testRenderer = await createTestRenderer();
expect(
limitSnapshotTo("configure-container", testRenderer.toJSON())
).toMatchSnapshot();
});
it("regenerate sso key", async () => {
const testRenderer = await createTestRenderer({
Mutation: {
regenerateSSOKey: createSinonStub(s =>
s.callsFake((_: any, data: any) => {
return {
settings: {
auth: {
integrations: {
sso: {
key: "==GENERATED_KEY==",
keyGeneratedAt: "2018-11-12T23:26:06.239Z",
},
},
},
},
clientMutationId: data.input.clientMutationId,
};
})
),
},
});
testRenderer.root
.find(inputPredicate("auth.integrations.sso.enabled"))
.props.onChange({});
testRenderer.root
.find(inputPredicate("configure-auth-sso-regenerate"))
.props.onClick();
await timeout();
expect(
limitSnapshotTo("configure-auth-sso-key", testRenderer.toJSON())
).toMatchSnapshot();
});
it("change settings", async () => {
let settingsRecord = cloneDeep(settings);
const testRenderer = await createTestRenderer({
Query: {
discoverOIDCConfiguration: createSinonStub(s =>
s.callsFake((_: any, data: any) => {
expect(data).toEqual({ issuer: "http://issuer.com" });
return {
issuer: "http://issuer.com",
tokenURL: "http://issuer.com/tokenURL",
jwksURI: "http://issuer.com/jwksURI",
authorizationURL: "http://issuer.com/authorizationURL",
};
})
),
},
Mutation: {
updateSettings: createSinonStub(s =>
s.callsFake((_: any, data: any) => {
expect(data.input.settings.auth.integrations.facebook).toEqual({
enabled: true,
allowRegistration: true,
targetFilter: {
admin: true,
stream: true,
},
clientID: "myClientID",
clientSecret: "myClientSecret",
});
settingsRecord = merge(settingsRecord, data.input.settings);
return {
settings: settingsRecord,
clientMutationId: data.input.clientMutationId,
};
})
),
createOIDCAuthIntegration: createSinonStub(s =>
s.callsFake((_: any, data: any) => {
expect(data.input.configuration).toEqual({
enabled: true,
allowRegistration: false,
targetFilter: {
admin: true,
stream: true,
},
name: "name",
clientID: "clientID",
clientSecret: "clientSecret",
issuer: "http://issuer.com",
jwksURI: "http://issuer.com/jwksURI",
authorizationURL: "http://issuer.com/authorizationURL",
tokenURL: "http://issuer.com/tokenURL",
});
(settingsRecord.auth.integrations.oidc as any).push({
id: "generatedID",
enabled: false,
callbackURL: "http://localhost/oidc/callback",
...data.input.configuration,
});
return {
settings: settingsRecord,
clientMutationId: data.input.clientMutationId,
};
})
),
updateOIDCAuthIntegration: createSinonStub(s =>
s.callsFake((_: any, data: any) => {
expect(data.input.configuration).toEqual({
enabled: true,
allowRegistration: false,
targetFilter: {
admin: true,
stream: true,
},
name: "name",
clientID: "clientID",
clientSecret: "clientSecret2",
issuer: "http://issuer.com",
jwksURI: "http://issuer.com/jwksURI",
authorizationURL: "http://issuer.com/authorizationURL",
tokenURL: "http://issuer.com/tokenURL",
});
(settingsRecord.auth.integrations.oidc[0] as any) = merge(
settingsRecord.auth.integrations.oidc[0],
data.input.configuration
);
return {
integration: settingsRecord.auth.integrations.oidc[0],
settings: settingsRecord,
clientMutationId: data.input.clientMutationId,
};
})
),
},
});
// Let's change some facebook settings.
testRenderer.root
.find(inputPredicate("auth.integrations.facebook.enabled"))
.props.onChange({});
testRenderer.root
.find(inputPredicate("auth.integrations.facebook.clientID"))
.props.onChange("myClientID");
testRenderer.root
.find(inputPredicate("auth.integrations.facebook.clientSecret"))
.props.onChange("myClientSecret");
expect(
limitSnapshotTo("configure-auth-facebook-container", testRenderer.toJSON())
).toMatchSnapshot("enable facebook configure box");
// Send form, this will perform creating an initial oidc record and update settings.
testRenderer.root.findByProps({ id: "configure-form" }).props.onSubmit();
// Submit button should be disabled.
expect(
testRenderer.root.find(inputPredicate("configure-sideBar-saveChanges"))
.props.disabled
).toBe(true);
// Disable other fields while submitting
// We are only testing for one here right now..
expect(
testRenderer.root.find(inputPredicate("auth.integrations.facebook.enabled"))
.props.disabled
).toBe(true);
await timeout();
expect(
testRenderer.root.find(inputPredicate("auth.integrations.facebook.enabled"))
.props.disabled
).toBe(false);
// Now let's enable oidc
testRenderer.root
.find(inputPredicate("auth.integrations.oidc.0.enabled"))
.props.onChange({});
expect(
limitSnapshotTo("configure-auth-oidc-container-0", testRenderer.toJSON())
).toMatchSnapshot("enable oidc configure box");
// Try to submit form, this will give validation error messages.
testRenderer.root.findByProps({ id: "configure-form" }).props.onSubmit();
expect(
limitSnapshotTo("configure-auth-oidc-container-0", testRenderer.toJSON())
).toMatchSnapshot("oidc validation errors");
// Fill form
testRenderer.root
.find(inputPredicate("auth.integrations.oidc.0.name"))
.props.onChange("name");
testRenderer.root
.find(inputPredicate("auth.integrations.oidc.0.clientID"))
.props.onChange("clientID");
testRenderer.root
.find(inputPredicate("auth.integrations.oidc.0.clientSecret"))
.props.onChange("clientSecret");
testRenderer.root
.find(inputPredicate("auth.integrations.oidc.0.issuer"))
.props.onChange("http://issuer.com");
// Discover the rest.
testRenderer.root
.find(inputPredicate("configure-auth-oidc-discover-0"))
.props.onClick();
await timeout();
// Try to submit again, this should work now.
testRenderer.root.findByProps({ id: "configure-form" }).props.onSubmit();
expect(
limitSnapshotTo("configure-auth-oidc-container-0", testRenderer.toJSON())
).toMatchSnapshot("during submit: oidc without errors");
// Disable other fields while submitting
// We are only testing for one here right now..
expect(
testRenderer.root.find(inputPredicate("auth.integrations.oidc.0.enabled"))
.props.disabled
).toBe(true);
await timeout();
expect(
testRenderer.root.find(inputPredicate("auth.integrations.oidc.0.enabled"))
.props.disabled
).toBe(false);
// Change clientSecret
testRenderer.root
.find(inputPredicate("auth.integrations.oidc.0.clientSecret"))
.props.onChange("clientSecret2");
testRenderer.root.findByProps({ id: "configure-form" }).props.onSubmit();
// Disable other fields while submitting
// We are only testing for one here right now..
expect(
testRenderer.root.find(inputPredicate("auth.integrations.oidc.0.enabled"))
.props.disabled
).toBe(true);
await timeout();
expect(
testRenderer.root.find(inputPredicate("auth.integrations.oidc.0.enabled"))
.props.disabled
).toBe(false);
});
+50
View File
@@ -0,0 +1,50 @@
export const settings = {
auth: {
displayName: {
enabled: false,
},
integrations: {
oidc: [],
local: {
enabled: false,
allowRegistration: true,
targetFilter: {
admin: true,
stream: true,
},
},
sso: {
enabled: false,
allowRegistration: true,
targetFilter: {
admin: true,
stream: true,
},
key: null,
keyGeneratedAt: null,
},
google: {
enabled: false,
allowRegistration: true,
targetFilter: {
admin: true,
stream: true,
},
clientID: "",
clientSecret: "",
callbackURL: "http://localhost/google/callback",
},
facebook: {
enabled: false,
allowRegistration: true,
targetFilter: {
admin: true,
stream: true,
},
clientID: "",
clientSecret: "",
callbackURL: "http://localhost/facebook/callback",
},
},
},
};
@@ -23,9 +23,9 @@ it("redirect to redirectPath when already logged in", async () => {
logNetwork: false,
initLocalState: localRecord => {
localRecord.setValue(true, "loggedIn");
localRecord.setValue("/admin/configure", "redirectPath");
localRecord.setValue("/admin/community", "redirectPath");
},
});
await timeout();
expect(window.location.toString()).toBe("http://localhost/admin/configure");
expect(window.location.toString()).toBe("http://localhost/admin/community");
});