[next] Auth Callback + Chunking (#2139)

* feat: added new auth-callback

* fix: removed unused polyfill code

* fix: fixed missed intersection observer

* feat: enabled vendor chunks

* fix: fix some issues with chunk splitting

* fix: added intersection-observer to app polyfill

* fix: fixed test

* fix: removed lodash plugin which caused issue in prod

* chore: access_token -> accessToken

* feat: Show social login errors

* fix: lint + add test

* fix: restore width after facebook social login
This commit is contained in:
Wyatt Johnson
2019-01-11 15:31:24 +01:00
committed by Kiwi
parent 0e941222c5
commit 94eb72a9bf
42 changed files with 880 additions and 277 deletions
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<title>Talk - Auth Callback</title>
<meta charset="utf-8">
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, user-scalable=no">
</head>
<body></body>
</html>
+38
View File
@@ -0,0 +1,38 @@
import { authRedirectBackTo as key } from "talk-framework/helpers/storageKeys";
try {
// Pull the redirection
const value = sessionStorage.getItem(key);
if (!value) {
throw new Error(`${key} session storage key not set`);
}
if (process.env.NODE_ENV === "production") {
// Remove the redirect URL that we pulled from sessionStorage.
sessionStorage.removeItem(key);
}
// Parse the URL from the redirect parameter, and pull out the pathname.
const parser = document.createElement("a");
parser.href = value;
const redirectBackTo = parser.pathname + parser.search;
if (!redirectBackTo) {
throw new Error(`url stored in the ${key} session storage key was invalid`);
}
if (process.env.NODE_ENV !== "production") {
// Remove the redirect URL that we pulled from sessionStorage.
sessionStorage.removeItem(key);
}
// Now that we have a valid redirection URL, we should append the current
// hash that includes the credentials or errors from the callback.
const redirectBackToWithToken = redirectBackTo + location.hash;
// Send the user off to the redirection URL.
location.href = redirectBackToWithToken;
} catch (err) {
// Place the error message right into the document body.
document.body.appendChild(document.createTextNode(err.message));
}
@@ -2,7 +2,7 @@
exports[`renders sign in 1`] = `
<div>
<withContext(createMutationContainer(withContext(createMutationContainer(Relay(SignInContainer)))))
<withContext(createMutationContainer(withContext(createMutationContainer(withContext(createMutationContainer(withContext(withLocalStateContainer(Relay(SignInContainer)))))))))
auth={Object {}}
/>
</div>
@@ -1,4 +1,6 @@
import { authRedirectBackTo } from "talk-framework/helpers/storageKeys";
export default function redirectOAuth2(redirectURL: string) {
sessionStorage.setItem("authRedirectBackTo", window.location.pathname);
sessionStorage.setItem(authRedirectBackTo, window.location.pathname);
window.location.href = redirectURL;
}
+3
View File
@@ -42,6 +42,9 @@ async function main() {
);
ReactDOM.render(<Index />, document.getElementById("app"));
// Set width.
window.resizeTo(350, window.outerHeight);
// Poll height.
pollPopupHeight();
}
@@ -2,29 +2,16 @@
exports[`get auth token from url 1`] = `
"{
\\"client:root\\": {
\\"__id\\": \\"client:root\\",
\\"__typename\\": \\"__Root\\",
\\"local\\": {
\\"__ref\\": \\"client:root.local\\"
}
\\"__id\\": \\"client:root.local\\",
\\"__typename\\": \\"Local\\",
\\"authToken\\": \\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIzMWIyNjU5MS00ZTlhLTQzODgtYTdmZi1lMWJkYzVkOTdjY2UifQ==\\",
\\"authJTI\\": \\"31b26591-4e9a-4388-a7ff-e1bdc5d97cce\\",
\\"loggedIn\\": true,
\\"network\\": {
\\"__ref\\": \\"client:root.local.network\\"
},
\\"client:root.local\\": {
\\"__id\\": \\"client:root.local\\",
\\"__typename\\": \\"Local\\",
\\"authToken\\": \\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIzMWIyNjU5MS00ZTlhLTQzODgtYTdmZi1lMWJkYzVkOTdjY2UifQ==\\",
\\"authJTI\\": \\"31b26591-4e9a-4388-a7ff-e1bdc5d97cce\\",
\\"loggedIn\\": true,
\\"network\\": {
\\"__ref\\": \\"client:root.local.network\\"
},
\\"view\\": \\"SIGN_IN\\"
},
\\"client:root.local.network\\": {
\\"__id\\": \\"client:root.local.network\\",
\\"__typename\\": \\"Network\\",
\\"isOffline\\": false
}
\\"view\\": \\"SIGN_IN\\",
\\"error\\": null
}"
`;
@@ -47,7 +34,8 @@ exports[`init local state 1`] = `
\\"network\\": {
\\"__ref\\": \\"client:root.local.network\\"
},
\\"view\\": \\"SIGN_IN\\"
\\"view\\": \\"SIGN_IN\\",
\\"error\\": null
},
\\"client:root.local.network\\": {
\\"__id\\": \\"client:root.local.network\\",
@@ -41,9 +41,18 @@ it("set view from query", async () => {
it("get auth token from url", async () => {
const restoreHistoryLocation = replaceHistoryLocation(
`http://localhost/#${createAuthToken()}`
`http://localhost/#accessToken=${createAuthToken()}`
);
await initLocalState(environment, context as any);
expect(JSON.stringify(source.toJSON(), null, 2)).toMatchSnapshot();
expect(JSON.stringify(source.get(LOCAL_ID), null, 2)).toMatchSnapshot();
restoreHistoryLocation();
});
it("get error from url", async () => {
const restoreHistoryLocation = replaceHistoryLocation(
`http://localhost/#error=error`
);
await initLocalState(environment, context as any);
expect(source.get(LOCAL_ID)!.error).toBe("error");
restoreHistoryLocation();
});
+20 -11
View File
@@ -4,17 +4,22 @@ import { parseQuery } from "talk-common/utils";
import { TalkContext } from "talk-framework/lib/bootstrap";
import { initLocalBaseState, LOCAL_ID } from "talk-framework/lib/relay";
function getAuthTokenFromHashAndClearIt() {
const authToken = window.location.hash
? window.location.hash.substr(1)
: null;
function getParamsFromHashAndClearIt() {
try {
const params = window.location.hash
? parseQuery(window.location.hash.substr(1))
: {};
// Remove hash with token.
if (window.location.hash) {
window.history.replaceState(null, document.title, location.pathname);
// Remove hash with token.
if (window.location.hash) {
window.history.replaceState(null, document.title, location.pathname);
}
return params;
} catch (err) {
window.console.error(err);
return {};
}
return authToken;
}
/**
@@ -24,8 +29,9 @@ export default async function initLocalState(
environment: Environment,
context: TalkContext
) {
const authToken = getAuthTokenFromHashAndClearIt();
await initLocalBaseState(environment, context, authToken);
const { error = null, accessToken = null } = getParamsFromHashAndClearIt();
await initLocalBaseState(environment, context, accessToken);
commitLocalUpdate(environment, s => {
const localRecord = s.get(LOCAL_ID)!;
@@ -35,5 +41,8 @@ export default async function initLocalState(
// Set default view.
localRecord.setValue(query.view || "SIGN_IN", "view");
// Set error.
localRecord.setValue(error, "error");
});
}
+1
View File
@@ -14,6 +14,7 @@ type Local {
authJTI: String
loggedIn: Boolean!
view: View!
error: String
}
extend type Query {
@@ -0,0 +1,23 @@
import { commitLocalUpdate, Environment } from "relay-runtime";
import { TalkContext } from "talk-framework/lib/bootstrap";
import { createMutationContainer } from "talk-framework/lib/relay";
import { LOCAL_ID } from "talk-framework/lib/relay/withLocalStateContainer";
export type ClearErrorMutation = () => Promise<void>;
export async function commit(
environment: Environment,
input: undefined,
{ pym }: TalkContext
) {
return commitLocalUpdate(environment, store => {
const record = store.get(LOCAL_ID)!;
record.setValue(null, "error");
});
}
export const withClearErrorMutation = createMutationContainer(
"clearError",
commit
);
@@ -15,7 +15,7 @@ export async function commit(
const result = await signIn(rest, pick(input, ["email", "password"]));
// Put the token on the hash and clean the session.
// It'll be picked up by initLocalState.
location.hash = result.token;
location.hash = `accessToken=${result.token}`;
clearSession();
}
@@ -18,7 +18,7 @@ export async function commit(
);
// Put the token on the hash and clean the session.
// It'll be picked up by initLocalState.
location.hash = result.token;
location.hash = `accessToken=${result.token}`;
clearSession();
}
+4
View File
@@ -14,3 +14,7 @@ export {
withSetPasswordMutation,
SetPasswordMutation,
} from "./SetPasswordMutation";
export {
withClearErrorMutation,
ClearErrorMutation,
} from "./ClearErrorMutation";
@@ -831,6 +831,181 @@ exports[`renders sign in view 1`] = `
</div>
`;
exports[`renders sign in view with error 1`] = `
<div
data-testid="signIn-container"
>
<div
className="Flex-root Bar-root Flex-flex Flex-justifyCenter Flex-alignCenter"
>
<div>
<h1
className="Typography-root Typography-heading2 Typography-colorTextPrimary Typography-alignCenter Title-root"
>
Sign In
</h1>
<h1
className="Typography-root Typography-heading4 Typography-colorTextPrimary Typography-alignCenter Subtitle-root"
>
to join the conversation
</h1>
</div>
</div>
<div
className="Flex-root SubBar-root Flex-flex Flex-justifyCenter Flex-alignCenter"
>
<div>
<div
className="Flex-root Typography-root Typography-bodyCopy Typography-colorTextPrimary Flex-flex"
>
<span>
Don't have an account? 
</span>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorPrimary Button-variantUnderlined"
data-testid="gotoSignUpButton"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Sign Up
</button>
</div>
</div>
</div>
<div
className="Main-root"
data-testid="signIn-main"
>
<div
className="HorizontalGutter-root HorizontalGutter-oneAndAHalf"
>
<div
className="CallOut-root CallOut-colorError CallOut-fullWidth"
>
Social Login Error
</div>
<form
autoComplete="off"
onSubmit={[Function]}
>
<div
className="HorizontalGutter-root HorizontalGutter-full"
>
<div
className="HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<label
className="Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
htmlFor="email"
>
Email Address
</label>
<input
className="TextField-root TextField-colorRegular TextField-fullWidth"
disabled={false}
id="email"
name="email"
onChange={[Function]}
placeholder="Email Address"
type="text"
value=""
/>
</div>
<div
className="HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<label
className="Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
htmlFor="password"
>
Password
</label>
<div
className="PasswordField-fullWidth PasswordField-root"
>
<div
className="PasswordField-wrapper"
>
<input
className="PasswordField-colorRegular PasswordField-fullWidth PasswordField-input"
disabled={false}
id="password"
name="password"
onChange={[Function]}
placeholder="Password"
type="password"
value=""
/>
<div
className="PasswordField-icon"
onClick={[Function]}
role="button"
tabIndex={0}
title="Show password"
>
<span
aria-hidden="true"
className="Icon-root Icon-sm"
>
visibility
</span>
</div>
</div>
</div>
<div
className="Flex-root Flex-flex Flex-justifyFlexEnd"
>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorPrimary Button-variantUnderlined"
data-testid="gotoForgotPasswordButton"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Forgot your password?
</button>
</div>
</div>
<button
className="BaseButton-root Button-root Button-sizeLarge Button-colorBrand Button-variantFilled Button-fullWidth"
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
<span
aria-hidden="true"
className="Icon-root ButtonIcon-root Icon-md"
>
email
</span>
<span>
Sign in with Email
</span>
</button>
</div>
</form>
<div
className="HorizontalGutter-root HorizontalGutter-full"
/>
</div>
</div>
</div>
`;
exports[`shows error when submitting empty form 1`] = `
<form
autoComplete="off"
+30 -2
View File
@@ -14,7 +14,10 @@ import mockWindow from "./mockWindow";
let windowMock: ReturnType<typeof mockWindow>;
async function createTestRenderer(customResolver: any = {}) {
async function createTestRenderer(
customResolver: any = {},
error: string | null = null
) {
const resolvers = {
...customResolver,
Query: {
@@ -31,6 +34,7 @@ async function createTestRenderer(customResolver: any = {}) {
resolvers,
initLocalState: localRecord => {
localRecord.setValue("SIGN_IN", "view");
localRecord.setValue(error, "error");
},
});
const container = await waitForElement(() =>
@@ -62,6 +66,30 @@ it("renders sign in view", async () => {
expect(testRenderer.toJSON()).toMatchSnapshot();
});
it("renders sign in view with error", async () => {
const { testRenderer, container } = await createTestRenderer(
{},
"Social Login Error"
);
expect(within(container).toJSON()).toMatchSnapshot();
within(testRenderer.root)
.getByTestID("gotoSignUpButton")
.props.onClick();
within(testRenderer.root)
.getByTestID("gotoSignInButton")
.props.onClick();
const container2 = await waitForElement(() =>
within(testRenderer.root).getByTestID("signIn-container")
);
// Error shouldn't be there anymore.
await wait(() =>
expect(
within(container2).queryByText("Social Login Error", { exact: false })
).toBeNull()
);
});
it("shows error when submitting empty form", async () => {
const { form } = await createTestRenderer();
form!.props.onSubmit();
@@ -170,7 +198,7 @@ it("submits form successfully", async () => {
expect(toJSON(form!)).toMatchSnapshot();
// Wait for window hash to contain a token.
await wait(() => expect(location.hash).toBe(`#${authToken}`));
await wait(() => expect(location.hash).toBe(`#accessToken=${authToken}`));
restMock.verify();
});
+1 -1
View File
@@ -224,7 +224,7 @@ it("submits form successfully", async () => {
expect(toJSON(main)).toMatchSnapshot();
// Wait for window hash to contain a token.
await wait(() => expect(location.hash).toBe(`#${authToken}`));
await wait(() => expect(location.hash).toBe(`#accessToken=${authToken}`));
restMock.verify();
});
@@ -17,6 +17,22 @@ it("renders correctly", () => {
googleEnabled: true,
oidcEnabled: true,
auth: {},
error: null,
};
const renderer = createRenderer();
renderer.render(<SignInN {...props} />);
expect(renderer.getRenderOutput()).toMatchSnapshot();
});
it("renders error", () => {
const props: PropTypesOf<typeof SignInN> = {
onGotoSignUp: noop,
emailEnabled: true,
facebookEnabled: true,
googleEnabled: true,
oidcEnabled: true,
auth: {},
error: "Server Error",
};
const renderer = createRenderer();
renderer.render(<SignInN {...props} />);
@@ -31,6 +47,7 @@ it("renders without email login", () => {
googleEnabled: true,
oidcEnabled: true,
auth: {},
error: null,
};
const renderer = createRenderer();
renderer.render(<SignInN {...props} />);
@@ -6,7 +6,13 @@ import Main from "talk-auth/components/Main";
import OrSeparator from "talk-auth/components/OrSeparator";
import AutoHeightContainer from "talk-auth/containers/AutoHeightContainer";
import { PropTypesOf } from "talk-framework/types";
import { Button, Flex, HorizontalGutter, Typography } from "talk-ui/components";
import {
Button,
CallOut,
Flex,
HorizontalGutter,
Typography,
} from "talk-ui/components";
import SignInWithEmailContainer from "../containers/SignInWithEmailContainer";
import SignInWithFacebookContainer from "../containers/SignInWithFacebookContainer";
@@ -14,6 +20,7 @@ import SignInWithGoogleContainer from "../containers/SignInWithGoogleContainer";
import SignInWithOIDCContainer from "../containers/SignInWithOIDCContainer";
export interface SignInForm {
error: string | null;
onGotoSignUp: () => void;
emailEnabled?: boolean;
facebookEnabled?: boolean;
@@ -31,6 +38,7 @@ const SignIn: StatelessComponent<SignInForm> = ({
googleEnabled,
oidcEnabled,
auth,
error,
}) => {
const oneClickIntegrationEnabled =
facebookEnabled || googleEnabled || oidcEnabled;
@@ -68,6 +76,11 @@ const SignIn: StatelessComponent<SignInForm> = ({
</SubBar>
<Main data-testid="signIn-main">
<HorizontalGutter size="oneAndAHalf">
{error && (
<CallOut color="error" fullWidth>
{error}
</CallOut>
)}
{emailEnabled && <SignInWithEmailContainer />}
{emailEnabled && oneClickIntegrationEnabled && <OrSeparator />}
<HorizontalGutter>
@@ -59,6 +59,71 @@ exports[`renders correctly 1`] = `
</div>
`;
exports[`renders error 1`] = `
<div
data-testid="signIn-container"
>
<AutoHeightContainer />
<Localized
id="signIn-signInToJoinHeader"
subtitle={<Subtitle />}
title={<Title />}
>
<Bar>
&lt;title&gt;Sign In&lt;/title&gt;&lt;subtitle&gt;to join the conversation&lt;/subtitle&gt;
</Bar>
</Localized>
<SubBar>
<Localized
button={
<withPropsOnChange(Button)
color="primary"
data-testid="gotoSignUpButton"
onClick={[Function]}
size="small"
variant="underlined"
/>
}
id="signIn-noAccountSignUp"
>
<withPropsOnChange(Typography)
container={[Function]}
variant="bodyCopy"
>
Don't have an account? &lt;button&gt;Sign Up&lt;/button&gt;
</withPropsOnChange(Typography)>
</Localized>
</SubBar>
<Main
data-testid="signIn-main"
>
<withPropsOnChange(HorizontalGutter)
size="oneAndAHalf"
>
<withPropsOnChange(CallOut)
color="error"
fullWidth={true}
>
Server Error
</withPropsOnChange(CallOut)>
<withContext(createMutationContainer(withContext(createMutationContainer(SignInContainer)))) />
<OrSeparator />
<withPropsOnChange(HorizontalGutter)>
<Relay(SignInWithFacebookContainer)
auth={Object {}}
/>
<Relay(SignInWithGoogleContainer)
auth={Object {}}
/>
<Relay(SignInWithOIDCContainer)
auth={Object {}}
/>
</withPropsOnChange(HorizontalGutter)>
</withPropsOnChange(HorizontalGutter)>
</Main>
</div>
`;
exports[`renders without email login 1`] = `
<div
data-testid="signIn-container"
@@ -1,28 +1,43 @@
import React, { Component } from "react";
import { SignInContainer_auth as AuthData } from "talk-auth/__generated__/SignInContainer_auth.graphql";
import { SignInContainerLocal as LocalData } from "talk-auth/__generated__/SignInContainerLocal.graphql";
import {
ClearErrorMutation,
SetViewMutation,
SignInMutation,
withClearErrorMutation,
withSetViewMutation,
withSignInMutation,
} from "talk-auth/mutations";
import { graphql, withFragmentContainer } from "talk-framework/lib/relay";
import {
graphql,
withFragmentContainer,
withLocalStateContainer,
} from "talk-framework/lib/relay";
import SignIn from "../components/SignIn";
interface Props {
local: LocalData;
auth: AuthData;
signIn: SignInMutation;
setView: SetViewMutation;
clearError: ClearErrorMutation;
}
class SignInContainer extends Component<Props> {
private goToSignUp = () => this.props.setView({ view: "SIGN_UP" });
public componentWillUnmount() {
this.props.clearError();
}
public render() {
const integrations = this.props.auth.integrations;
return (
<SignIn
error={this.props.local.error}
auth={this.props.auth}
onGotoSignUp={this.goToSignUp}
emailEnabled={
@@ -44,42 +59,52 @@ class SignInContainer extends Component<Props> {
}
const enhanced = withSetViewMutation(
withSignInMutation(
withFragmentContainer<Props>({
auth: graphql`
fragment SignInContainer_auth on Auth {
...SignInWithOIDCContainer_auth
...SignInWithGoogleContainer_auth
...SignInWithFacebookContainer_auth
integrations {
local {
enabled
targetFilter {
stream
}
}
facebook {
enabled
targetFilter {
stream
}
}
google {
enabled
targetFilter {
stream
}
}
oidc {
enabled
targetFilter {
stream
}
}
withClearErrorMutation(
withSignInMutation(
withLocalStateContainer(
graphql`
fragment SignInContainerLocal on Local {
error
}
}
`,
})(SignInContainer)
`
)(
withFragmentContainer<Props>({
auth: graphql`
fragment SignInContainer_auth on Auth {
...SignInWithOIDCContainer_auth
...SignInWithGoogleContainer_auth
...SignInWithFacebookContainer_auth
integrations {
local {
enabled
targetFilter {
stream
}
}
facebook {
enabled
targetFilter {
stream
}
}
google {
enabled
targetFilter {
stream
}
}
oidc {
enabled
targetFilter {
stream
}
}
}
}
`,
})(SignInContainer)
)
)
)
);
export default enhanced;
@@ -0,0 +1 @@
export const authRedirectBackTo = "authRedirectBackTo";
@@ -4,6 +4,7 @@ export default (process.env.NODE_ENV !== "development"
embed: {
stream: "/embed/stream",
auth: "/embed/auth",
authCallback: "/embed/auth/callback",
},
}
: {
@@ -11,5 +12,6 @@ export default (process.env.NODE_ENV !== "development"
embed: {
stream: "/stream.html",
auth: "/auth.html",
authCallback: "/auth-callback.html",
},
});
@@ -1,7 +1,6 @@
import * as React from "react";
import { createContextHOC } from "talk-framework/helpers";
import ensurePolyfill from "./ensurePolyfill";
export type IntersectionCallback = (entry: IntersectionObserverEntry) => void;
export type Observe = (
@@ -24,18 +23,16 @@ export class IntersectionProvider extends React.Component<any, any> {
private unmounted = false;
public componentDidMount() {
ensurePolyfill().then(() => {
if (this.unmounted) {
return;
}
this.observer = new IntersectionObserver(this.onIntersect, {
root: this.props.node ? this.props.node : undefined,
rootMargin: "0px",
threshold: 0.25,
});
this.elementBuffer.forEach(element => this.observer.observe(element));
this.elementBuffer = [];
if (this.unmounted) {
return;
}
this.observer = new IntersectionObserver(this.onIntersect, {
root: this.props.node ? this.props.node : undefined,
rootMargin: "0px",
threshold: 0.25,
});
this.elementBuffer.forEach(element => this.observer.observe(element));
this.elementBuffer = [];
}
public componentWillUnmount() {
@@ -1,9 +0,0 @@
/**
* Loads intersection-observer polyfill if it doesn't exist.
*/
export default async function ensurePolyfill() {
if (!(window as any).IntersectionObserver) {
await import("intersection-observer");
}
return;
}
@@ -4,4 +4,3 @@ export {
withIntersectionContext,
} from "./IntersectionContext";
export { default as withInView } from "./withInView";
export { default as ensurePolyfill } from "./ensurePolyfill";
+1 -1
View File
@@ -20,7 +20,7 @@ function withStyles<T>(
resolvedClasses[k] += ` ${props.classes[k]}`;
} else if (process.env.NODE_ENV !== "production") {
// tslint:disable:next-line: no-console
console.warn("Extending non existant className", k);
console.warn("Extending non existent className", k);
}
});
}