From d140e2449f8a510bc740492e44d9643cbbc3eae0 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 9 Aug 2018 16:14:35 +0200 Subject: [PATCH 1/6] Support authentication --- package-lock.json | 6 ++ package.json | 1 + .../framework/lib/bootstrap/TalkContext.tsx | 6 ++ .../framework/lib/bootstrap/createContext.tsx | 21 ++++- .../framework/lib/network/fetchQuery.ts | 23 +++-- .../client/framework/lib/network/index.ts | 2 +- .../lib/storage/InMemoryStorage.spec.ts | 25 ++++++ .../framework/lib/storage/InMemoryStorage.ts | 45 ++++++++++ .../framework/lib/storage/LocalStorage.ts | 5 ++ .../framework/lib/storage/SessionStorage.ts | 5 ++ .../client/framework/lib/storage/index.ts | 3 + .../lib/storage/prefixStorage.spec.ts | 86 +++++++++++++++++++ .../framework/lib/storage/prefixStorage.ts | 41 +++++++++ .../mutations/SetAuthTokenMutation.spec.ts | 34 ++++++++ .../mutations/SetAuthTokenMutation.ts | 34 ++++++++ src/core/client/framework/mutations/index.ts | 5 ++ src/core/client/stream/index.tsx | 2 +- .../__snapshots__/initLocalState.spec.ts.snap | 1 + .../stream/local/initLocalState.spec.ts | 16 +++- .../client/stream/local/initLocalState.ts | 9 +- src/core/client/stream/local/local.graphql | 1 + src/core/client/test/setup.ts | 1 + 22 files changed, 357 insertions(+), 15 deletions(-) create mode 100644 src/core/client/framework/lib/storage/InMemoryStorage.spec.ts create mode 100644 src/core/client/framework/lib/storage/InMemoryStorage.ts create mode 100644 src/core/client/framework/lib/storage/LocalStorage.ts create mode 100644 src/core/client/framework/lib/storage/SessionStorage.ts create mode 100644 src/core/client/framework/lib/storage/index.ts create mode 100644 src/core/client/framework/lib/storage/prefixStorage.spec.ts create mode 100644 src/core/client/framework/lib/storage/prefixStorage.ts create mode 100644 src/core/client/framework/mutations/SetAuthTokenMutation.spec.ts create mode 100644 src/core/client/framework/mutations/SetAuthTokenMutation.ts create mode 100644 src/core/client/framework/mutations/index.ts diff --git a/package-lock.json b/package-lock.json index 100de84ee..c504d8d7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12988,6 +12988,12 @@ "pretty-format": "^23.2.0" } }, + "jest-localstorage-mock": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/jest-localstorage-mock/-/jest-localstorage-mock-2.2.0.tgz", + "integrity": "sha512-x+P0vcwr4540bCAYzTEpiD9rs+zh/QZzyiABV+MU6yM2OPwPlrrLyUx/6gValMyt6tg5lX6Z53o2rHWfUht5Xw==", + "dev": true + }, "jest-matcher-utils": { "version": "23.2.0", "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-23.2.0.tgz", diff --git a/package.json b/package.json index d546b9c17..5e5d7b210 100644 --- a/package.json +++ b/package.json @@ -151,6 +151,7 @@ "html-webpack-plugin": "^3.2.0", "jest": "^23.4.1", "jest-junit": "^5.1.0", + "jest-localstorage-mock": "^2.2.0", "jsdom": "^11.11.0", "loader-utils": "^1.1.0", "material-design-icons": "^3.0.1", diff --git a/src/core/client/framework/lib/bootstrap/TalkContext.tsx b/src/core/client/framework/lib/bootstrap/TalkContext.tsx index 90ed10530..f7987877c 100644 --- a/src/core/client/framework/lib/bootstrap/TalkContext.tsx +++ b/src/core/client/framework/lib/bootstrap/TalkContext.tsx @@ -18,6 +18,12 @@ export interface TalkContext { /** formatter for timeago. */ timeagoFormatter?: Formatter; + /** Session Storage */ + localStorage: Storage; + + /** Session storage */ + sessionStorage: Storage; + /** * A way to listen for clicks that are e.g. outside of the * current frame for `ClickOutside` diff --git a/src/core/client/framework/lib/bootstrap/createContext.tsx b/src/core/client/framework/lib/bootstrap/createContext.tsx index 2e6bd2aff..5c71cb386 100644 --- a/src/core/client/framework/lib/bootstrap/createContext.tsx +++ b/src/core/client/framework/lib/bootstrap/createContext.tsx @@ -5,11 +5,16 @@ import { Child as PymChild } from "pym.js"; import React from "react"; import { Formatter } from "react-timeago"; import { Environment, Network, RecordSource, Store } from "relay-runtime"; +import { LOCAL_ID } from "talk-framework/lib/relay"; +import { + createLocalStorage, + createSessionStorage, +} from "talk-framework/lib/storage"; import { ClickFarAwayRegister } from "talk-ui/components/ClickOutside"; import { generateMessages, LocalesData, negotiateLanguages } from "../i18n"; -import { fetchQuery } from "../network"; +import { createFetch, TokenGetter } from "../network"; import { TalkContext } from "./TalkContext"; interface CreateContextArguments { @@ -61,9 +66,17 @@ export default async function createContext({ eventEmitter = new EventEmitter2({ wildcard: true }), }: CreateContextArguments): Promise { // Initialize Relay. + const source = new RecordSource(); + const tokenGetter: TokenGetter = () => { + const localState = source.get(LOCAL_ID); + if (localState) { + return localState.authToken || ""; + } + return ""; + }; const relayEnvironment = new Environment({ - network: Network.create(fetchQuery), - store: new Store(new RecordSource()), + network: Network.create(createFetch(tokenGetter)), + store: new Store(source), }); // Listen for outside clicks. @@ -99,6 +112,8 @@ export default async function createContext({ pym, eventEmitter, registerClickFarAway, + localStorage: createLocalStorage(), + sessionStorage: createSessionStorage(), }; // Run custom initializations. diff --git a/src/core/client/framework/lib/network/fetchQuery.ts b/src/core/client/framework/lib/network/fetchQuery.ts index 6a5d4bb82..bc6b734d3 100644 --- a/src/core/client/framework/lib/network/fetchQuery.ts +++ b/src/core/client/framework/lib/network/fetchQuery.ts @@ -26,17 +26,28 @@ function getError(errors: Error[]): Error { return new GraphQLError(errors as any); } +export type TokenGetter = () => string; +type CreateFetch = (token?: TokenGetter) => FetchFunction; + /** - * fetchQuery is a simple implementation of the `FetchFunction` + * createFetch returns a simple implementation of the `FetchFunction` * required by Relay. It'll return a `NetworkError` on failure. */ -const fetchQuery: FetchFunction = async (operation, variables) => { +const createFetch: CreateFetch = tokenGetter => async ( + operation, + variables +) => { + const token = tokenGetter && tokenGetter(); + const headers: Record = { + "Content-Type": "application/json", + }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } try { const response = await fetch("/api/tenant/graphql", { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers, body: JSON.stringify({ query: operation.text, variables, @@ -58,4 +69,4 @@ const fetchQuery: FetchFunction = async (operation, variables) => { } }; -export default fetchQuery; +export default createFetch; diff --git a/src/core/client/framework/lib/network/index.ts b/src/core/client/framework/lib/network/index.ts index 57943dc38..75b935dce 100644 --- a/src/core/client/framework/lib/network/index.ts +++ b/src/core/client/framework/lib/network/index.ts @@ -1 +1 @@ -export { default as fetchQuery } from "./fetchQuery"; +export { default as createFetch, TokenGetter } from "./fetchQuery"; diff --git a/src/core/client/framework/lib/storage/InMemoryStorage.spec.ts b/src/core/client/framework/lib/storage/InMemoryStorage.spec.ts new file mode 100644 index 000000000..8c141ed61 --- /dev/null +++ b/src/core/client/framework/lib/storage/InMemoryStorage.spec.ts @@ -0,0 +1,25 @@ +import createInMemoryStorage from "./InMemoryStorage"; + +it("should set and unset values", () => { + const storage = createInMemoryStorage(); + storage.setItem("test", "value"); + expect(storage.getItem("test")).toBe("value"); + storage.removeItem("test"); + expect(storage.getItem("test")).toBeUndefined(); +}); + +it("should return length", () => { + const storage = createInMemoryStorage(); + storage.setItem("a", "value"); + storage.setItem("b", "value"); + storage.setItem("c", "value"); + expect(storage.length).toBe(3); +}); + +it("should nth value", () => { + const storage = createInMemoryStorage(); + storage.setItem("a", "a"); + storage.setItem("b", "b"); + storage.setItem("c", "c"); + expect(storage.key(2)).toBe("c"); +}); diff --git a/src/core/client/framework/lib/storage/InMemoryStorage.ts b/src/core/client/framework/lib/storage/InMemoryStorage.ts new file mode 100644 index 000000000..3c5b15d7c --- /dev/null +++ b/src/core/client/framework/lib/storage/InMemoryStorage.ts @@ -0,0 +1,45 @@ +/** + * InMemoryStorage is a dumb implementation of the Storage interface that will + * not persist the data at all. It implements the Storage interface found: + * + * https://developer.mozilla.org/en-US/docs/Web/API/Storage + */ +class InMemoryStorage implements Storage { + private storage: Record; + + constructor() { + this.storage = {}; + } + + get length() { + return Object.keys(this.storage).length; + } + + public clear() { + this.storage = {}; + } + + public key(n: number) { + if (this.length <= n) { + return null; + } + + return this.storage[Object.keys(this.storage)[n]]; + } + + public getItem(key: string) { + return this.storage[key]; + } + + public setItem(key: string, value: string) { + this.storage[key] = value; + } + + public removeItem(key: string) { + delete this.storage[key]; + } +} + +export default function createInMemoryStorage() { + return new InMemoryStorage(); +} diff --git a/src/core/client/framework/lib/storage/LocalStorage.ts b/src/core/client/framework/lib/storage/LocalStorage.ts new file mode 100644 index 000000000..72588d880 --- /dev/null +++ b/src/core/client/framework/lib/storage/LocalStorage.ts @@ -0,0 +1,5 @@ +import prefixStorage from "./prefixStorage"; + +export default function createLocalStorage(): Storage { + return prefixStorage(window.localStorage, "talk"); +} diff --git a/src/core/client/framework/lib/storage/SessionStorage.ts b/src/core/client/framework/lib/storage/SessionStorage.ts new file mode 100644 index 000000000..7b45e09a3 --- /dev/null +++ b/src/core/client/framework/lib/storage/SessionStorage.ts @@ -0,0 +1,5 @@ +import prefixStorage from "./prefixStorage"; + +export default function createSessionStorage(): Storage { + return prefixStorage(window.sessionStorage, "talk"); +} diff --git a/src/core/client/framework/lib/storage/index.ts b/src/core/client/framework/lib/storage/index.ts new file mode 100644 index 000000000..a558f693b --- /dev/null +++ b/src/core/client/framework/lib/storage/index.ts @@ -0,0 +1,3 @@ +export { default as createInMemoryStorage } from "./InMemoryStorage"; +export { default as createLocalStorage } from "./LocalStorage"; +export { default as createSessionStorage } from "./SessionStorage"; diff --git a/src/core/client/framework/lib/storage/prefixStorage.spec.ts b/src/core/client/framework/lib/storage/prefixStorage.spec.ts new file mode 100644 index 000000000..6e8e8088d --- /dev/null +++ b/src/core/client/framework/lib/storage/prefixStorage.spec.ts @@ -0,0 +1,86 @@ +import sinon from "sinon"; +import prefixStorage from "./prefixStorage"; + +it("should call clear", () => { + const storage = { + clear: sinon.mock().once(), + }; + + const prefixed = prefixStorage(storage as any, "talk"); + prefixed.clear(); + storage.clear.verify(); +}); + +it("should call length", () => { + const ret = 10; + const storage = { + get length() { + return ret; + }, + }; + + const prefixed = prefixStorage(storage as any, "talk"); + expect(prefixed.length).toBe(ret); +}); + +it("should call key", () => { + const ret = "value"; + const storage = { + key: sinon + .mock() + .withArgs(3) + .returns(ret), + }; + + const prefixed = prefixStorage(storage as any, "talk"); + expect(prefixed.key(3)).toBe(ret); + (storage.key as any).verify(); +}); + +it("should call key", () => { + const ret = "value"; + const storage = { + key: sinon + .mock() + .withArgs(3) + .returns(ret), + }; + + const prefixed = prefixStorage(storage as any, "talk"); + expect(prefixed.key(3)).toBe(ret); + (storage.key as any).verify(); +}); + +it("should prefix setItem", () => { + const storage = { + setItem: sinon.mock().withArgs("talk:key", "value"), + }; + + const prefixed = prefixStorage(storage as any, "talk"); + prefixed.setItem("key", "value"); + storage.setItem.verify(); +}); + +it("should prefix removeItem", () => { + const storage = { + removeItem: sinon.mock().withArgs("talk:key"), + }; + + const prefixed = prefixStorage(storage as any, "talk"); + prefixed.removeItem("key"); + storage.removeItem.verify(); +}); + +it("should prefix getItem", () => { + const ret = "value"; + const storage = { + getItem: sinon + .mock() + .withArgs("talk:key") + .returns(ret), + }; + + const prefixed = prefixStorage(storage as any, "talk"); + expect(prefixed.getItem("key")).toBe(ret); + (storage.getItem as any).verify(); +}); diff --git a/src/core/client/framework/lib/storage/prefixStorage.ts b/src/core/client/framework/lib/storage/prefixStorage.ts new file mode 100644 index 000000000..c44f7c364 --- /dev/null +++ b/src/core/client/framework/lib/storage/prefixStorage.ts @@ -0,0 +1,41 @@ +/** + * PrefixedStorage decorates a Storage and prefixes keys in + * getItem, setItem and removeItem with given prefix. + */ +class PrefixedStorage implements Storage { + private storage: Storage; + private prefix: string; + + constructor(storage: Storage, prefix: string) { + this.storage = storage; + this.prefix = prefix; + } + + get length() { + return this.storage.length; + } + + public clear() { + this.storage.clear(); + } + + public key(n: number) { + return this.storage.key(n); + } + + public getItem(key: string) { + return this.storage.getItem(`${this.prefix}:${key}`); + } + + public setItem(key: string, value: string) { + return this.storage.setItem(`${this.prefix}:${key}`, value); + } + + public removeItem(key: string) { + return this.storage.removeItem(`${this.prefix}:${key}`); + } +} + +export default function prefixStorage(storage: Storage, prefix: string) { + return new PrefixedStorage(storage, prefix); +} diff --git a/src/core/client/framework/mutations/SetAuthTokenMutation.spec.ts b/src/core/client/framework/mutations/SetAuthTokenMutation.spec.ts new file mode 100644 index 000000000..7440078ad --- /dev/null +++ b/src/core/client/framework/mutations/SetAuthTokenMutation.spec.ts @@ -0,0 +1,34 @@ +import { commitLocalUpdate, Environment, RecordSource } from "relay-runtime"; + +import { timeout } from "talk-common/utils"; +import { LOCAL_ID } from "talk-framework/lib/relay"; +import { createRelayEnvironment } from "talk-framework/testHelpers"; + +import { commit } from "./SetAuthTokenMutation"; + +let environment: Environment; +const source: RecordSource = new RecordSource(); + +beforeAll(() => { + environment = createRelayEnvironment({ + source, + }); +}); + +it("Sets auth token", async () => { + const authToken = "auth token"; + commit(environment, { authToken }); + expect(source.get(LOCAL_ID)!.authToken).toEqual(authToken); +}); + +it("Should call gc", async () => { + commitLocalUpdate(environment, store => { + store.create("should-disappear", "tmp"); + }); + const authToken = null; + expect(source.get("should-disappear")).not.toBeUndefined(); + commit(environment, { authToken }); + await timeout(); + expect(source.get(LOCAL_ID)!.authToken).toEqual(authToken); + expect(source.get("should-disappear")).toBeUndefined(); +}); diff --git a/src/core/client/framework/mutations/SetAuthTokenMutation.ts b/src/core/client/framework/mutations/SetAuthTokenMutation.ts new file mode 100644 index 000000000..38ecde460 --- /dev/null +++ b/src/core/client/framework/mutations/SetAuthTokenMutation.ts @@ -0,0 +1,34 @@ +import { commitLocalUpdate, Environment } from "relay-runtime"; + +import { createMutationContainer } from "talk-framework/lib/relay"; +import { LOCAL_ID } from "talk-framework/lib/relay/withLocalStateContainer"; + +export interface SetAuthTokenInput { + authToken: string | null; +} + +export type SetAuthTokenMutation = (input: SetAuthTokenInput) => Promise; + +export async function commit( + environment: Environment, + input: SetAuthTokenInput +) { + return commitLocalUpdate(environment, store => { + const record = store.get(LOCAL_ID)!; + record.setValue(input.authToken, "authToken"); + + // Force gc to trigger. + environment + .retain({ + dataID: "tmp", + node: { selections: [] }, + variables: {}, + }) + .dispose(); + }); +} + +export const withSetAuthTokenMutation = createMutationContainer( + "setCommentID", + commit +); diff --git a/src/core/client/framework/mutations/index.ts b/src/core/client/framework/mutations/index.ts new file mode 100644 index 000000000..21875e6d8 --- /dev/null +++ b/src/core/client/framework/mutations/index.ts @@ -0,0 +1,5 @@ +export { + withSetAuthTokenMutation, + SetAuthTokenMutation, + SetAuthTokenInput, +} from "./SetAuthTokenMutation"; diff --git a/src/core/client/stream/index.tsx b/src/core/client/stream/index.tsx index 6d622216e..d6619f706 100644 --- a/src/core/client/stream/index.tsx +++ b/src/core/client/stream/index.tsx @@ -18,7 +18,7 @@ const pymFeatures = [withSetCommentID]; // This is called when the context is first initialized. async function init(context: TalkContext) { - await initLocalState(context.relayEnvironment); + await initLocalState(context.relayEnvironment, context); pymFeatures.forEach(f => f(context)); } diff --git a/src/core/client/stream/local/__snapshots__/initLocalState.spec.ts.snap b/src/core/client/stream/local/__snapshots__/initLocalState.spec.ts.snap index 52ffec402..c8dda11c7 100644 --- a/src/core/client/stream/local/__snapshots__/initLocalState.spec.ts.snap +++ b/src/core/client/stream/local/__snapshots__/initLocalState.spec.ts.snap @@ -12,6 +12,7 @@ exports[`init local state 1`] = ` \\"client:root.local\\": { \\"__id\\": \\"client:root.local\\", \\"__typename\\": \\"Local\\", + \\"authToken\\": \\"\\", \\"network\\": { \\"__ref\\": \\"client:root.local.network\\" }, diff --git a/src/core/client/stream/local/initLocalState.spec.ts b/src/core/client/stream/local/initLocalState.spec.ts index d1842c9a5..2d2a3b486 100644 --- a/src/core/client/stream/local/initLocalState.spec.ts +++ b/src/core/client/stream/local/initLocalState.spec.ts @@ -2,6 +2,7 @@ import { Environment, RecordSource } from "relay-runtime"; import { timeout } from "talk-common/utils"; import { LOCAL_ID } from "talk-framework/lib/relay"; +import { createInMemoryStorage } from "talk-framework/lib/storage"; import { createRelayEnvironment } from "talk-framework/testHelpers"; import initLocalState from "./initLocalState"; @@ -18,7 +19,7 @@ beforeEach(() => { }); it("init local state", async () => { - initLocalState(environment); + initLocalState(environment, { localStorage: createInMemoryStorage() } as any); await timeout(); expect(JSON.stringify(source.toJSON(), null, 2)).toMatchSnapshot(); }); @@ -32,7 +33,7 @@ it("set assetID from query", () => { document.title, `http://localhost/?assetID=${assetID}` ); - initLocalState(environment); + initLocalState(environment, { localStorage: createInMemoryStorage() } as any); expect(source.get(LOCAL_ID)!.assetID).toBe(assetID); window.history.replaceState(previousState, document.title, previousLocation); }); @@ -46,7 +47,16 @@ it("set commentID from query", () => { document.title, `http://localhost/?commentID=${commentID}` ); - initLocalState(environment); + initLocalState(environment, { localStorage: createInMemoryStorage() } as any); expect(source.get(LOCAL_ID)!.commentID).toBe(commentID); window.history.replaceState(previousState, document.title, previousLocation); }); + +it("set authToken from localStorage", () => { + const authToken = "auth-token"; + const localStorage = createInMemoryStorage(); + localStorage.setItem("authToken", authToken); + initLocalState(environment, { localStorage } as any); + expect(source.get(LOCAL_ID)!.authToken).toBe(authToken); + localStorage.removeItem("authToken"); +}); diff --git a/src/core/client/stream/local/initLocalState.ts b/src/core/client/stream/local/initLocalState.ts index 827e90c50..dd021f756 100644 --- a/src/core/client/stream/local/initLocalState.ts +++ b/src/core/client/stream/local/initLocalState.ts @@ -1,6 +1,7 @@ import qs from "query-string"; import { commitLocalUpdate, Environment } from "relay-runtime"; +import { TalkContext } from "talk-framework/lib/bootstrap"; import { createAndRetain, LOCAL_ID, @@ -17,7 +18,10 @@ import { /** * Initializes the local state, before we start the App. */ -export default async function initLocalState(environment: Environment) { +export default async function initLocalState( + environment: Environment, + { localStorage }: TalkContext +) { commitLocalUpdate(environment, s => { const root = s.getRoot(); @@ -25,6 +29,9 @@ export default async function initLocalState(environment: Environment) { const localRecord = createAndRetain(environment, s, LOCAL_ID, LOCAL_TYPE); root.setLinkedRecord(localRecord, "local"); + // Set auth token + localRecord.setValue(localStorage.getItem("authToken") || "", "authToken"); + // Parse query params const query = qs.parse(location.search); diff --git a/src/core/client/stream/local/local.graphql b/src/core/client/stream/local/local.graphql index 52604d35b..37426b867 100644 --- a/src/core/client/stream/local/local.graphql +++ b/src/core/client/stream/local/local.graphql @@ -21,6 +21,7 @@ type Local { assetURL: String commentID: String authPopup: AuthPopup! + authToken: String } extend type Query { diff --git a/src/core/client/test/setup.ts b/src/core/client/test/setup.ts index 1609f2653..de1ba3c31 100644 --- a/src/core/client/test/setup.ts +++ b/src/core/client/test/setup.ts @@ -1,3 +1,4 @@ +import "jest-localstorage-mock"; import "./enzyme"; import "./jsdom"; From dbcf3c57567e77cc57c97d8b190a2c89cd460fb3 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 10 Aug 2018 12:07:46 +0200 Subject: [PATCH 2/6] Fix linter --- package-lock.json | 23 +++++++++++++------ package.json | 4 ++-- .../framework/lib/bootstrap/TalkContext.tsx | 2 +- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index d853f3b07..9d97aba8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22383,9 +22383,9 @@ "dev": true }, "tslint": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/tslint/-/tslint-5.10.0.tgz", - "integrity": "sha1-EeJrzLiK+gLdDZlWyuPUVAtfVMM=", + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/tslint/-/tslint-5.11.0.tgz", + "integrity": "sha1-mPMMAurjzecAYgHkwzywi0hYHu0=", "dev": true, "requires": { "babel-code-frame": "^6.22.0", @@ -22399,7 +22399,7 @@ "resolve": "^1.3.2", "semver": "^5.3.0", "tslib": "^1.8.0", - "tsutils": "^2.12.1" + "tsutils": "^2.27.2" }, "dependencies": { "glob": { @@ -22415,13 +22415,22 @@ "once": "^1.3.0", "path-is-absolute": "^1.0.0" } + }, + "tsutils": { + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-2.29.0.tgz", + "integrity": "sha512-g5JVHCIJwzfISaXpXE1qvNalca5Jwob6FjI4AoPlqMusJ6ftFE7IkkFoMhVLRgK+4Kx3gkzb8UZK5t5yTTvEmA==", + "dev": true, + "requires": { + "tslib": "^1.8.1" + } } } }, "tslint-config-prettier": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/tslint-config-prettier/-/tslint-config-prettier-1.13.0.tgz", - "integrity": "sha512-assE77K7K8Q9j8CVIHiU3d1uoKc8N5v7UPpkQ9IE8BEPWkvSYR37lDuYekDlAMFqR1IpD6CrS+uOJLl6pw7Wdw==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/tslint-config-prettier/-/tslint-config-prettier-1.14.0.tgz", + "integrity": "sha512-SomD+aLvAwoihMtyCfkhhWKt9wcpSY2ZpgDV6OuxLYi8+7uOwE2g03aa+jJLSmY0Ys8s3ZLM5Iwfuwu3giCSQQ==", "dev": true }, "tslint-loader": { diff --git a/package.json b/package.json index 7aae0f6cb..1c4d0b1ce 100644 --- a/package.json +++ b/package.json @@ -196,8 +196,8 @@ "ts-node": "^6.2.0", "tsconfig-paths": "^3.4.2", "tsconfig-paths-webpack-plugin": "^3.1.4", - "tslint": "^5.10.0", - "tslint-config-prettier": "^1.13.0", + "tslint": "^5.11.0", + "tslint-config-prettier": "^1.14.0", "tslint-loader": "^3.6.0", "tslint-plugin-prettier": "^1.3.0", "tslint-react": "^3.6.0", diff --git a/src/core/client/framework/lib/bootstrap/TalkContext.tsx b/src/core/client/framework/lib/bootstrap/TalkContext.tsx index 7567f9412..06726bcb2 100644 --- a/src/core/client/framework/lib/bootstrap/TalkContext.tsx +++ b/src/core/client/framework/lib/bootstrap/TalkContext.tsx @@ -24,7 +24,7 @@ export interface TalkContext { /** Session storage */ sessionStorage: Storage; - + /** media query values for testing purposes */ mediaQueryValues?: MediaQueryMatchers; From ea02e47241eaa1f3d0af3def4f5fe559eae09707 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 10 Aug 2018 15:57:07 +0200 Subject: [PATCH 3/6] Add PostMessage Service --- .../framework/lib/bootstrap/TalkContext.tsx | 4 ++ .../framework/lib/bootstrap/createContext.tsx | 2 + .../client/framework/lib/postMessage.spec.ts | 69 +++++++++++++++++++ src/core/client/framework/lib/postMessage.ts | 66 ++++++++++++++++++ 4 files changed, 141 insertions(+) create mode 100644 src/core/client/framework/lib/postMessage.spec.ts create mode 100644 src/core/client/framework/lib/postMessage.ts diff --git a/src/core/client/framework/lib/bootstrap/TalkContext.tsx b/src/core/client/framework/lib/bootstrap/TalkContext.tsx index 75ddc974c..f1d62c81c 100644 --- a/src/core/client/framework/lib/bootstrap/TalkContext.tsx +++ b/src/core/client/framework/lib/bootstrap/TalkContext.tsx @@ -6,6 +6,7 @@ import { MediaQueryMatchers } from "react-responsive"; import { Formatter } from "react-timeago"; import { Environment } from "relay-runtime"; +import { PostMessageService } from "talk-framework/lib/postMessage"; import { UIContext } from "talk-ui/components"; import { ClickFarAwayRegister } from "talk-ui/components/ClickOutside"; @@ -22,6 +23,9 @@ export interface TalkContext { /** media query values for testing purposes */ mediaQueryValues?: MediaQueryMatchers; + /** postMessage service */ + postMessage: PostMessageService; + /** * A way to listen for clicks that are e.g. outside of the * current frame for `ClickOutside` diff --git a/src/core/client/framework/lib/bootstrap/createContext.tsx b/src/core/client/framework/lib/bootstrap/createContext.tsx index 2e6bd2aff..91470277a 100644 --- a/src/core/client/framework/lib/bootstrap/createContext.tsx +++ b/src/core/client/framework/lib/bootstrap/createContext.tsx @@ -10,6 +10,7 @@ import { ClickFarAwayRegister } from "talk-ui/components/ClickOutside"; import { generateMessages, LocalesData, negotiateLanguages } from "../i18n"; import { fetchQuery } from "../network"; +import { PostMessageService } from "../postMessage"; import { TalkContext } from "./TalkContext"; interface CreateContextArguments { @@ -99,6 +100,7 @@ export default async function createContext({ pym, eventEmitter, registerClickFarAway, + postMessage: new PostMessageService(), }; // Run custom initializations. diff --git a/src/core/client/framework/lib/postMessage.spec.ts b/src/core/client/framework/lib/postMessage.spec.ts new file mode 100644 index 000000000..1c8a61a1f --- /dev/null +++ b/src/core/client/framework/lib/postMessage.spec.ts @@ -0,0 +1,69 @@ +import { PostMessageService } from "./postMessage"; + +it("post and subscribe to a message", done => { + const postMessage = new PostMessageService(); + const cancel = postMessage.on("test", value => { + expect(value).toBe("value"); + done(); + cancel(); + }); + postMessage.send("test", "value", window); +}); + +it("send to a different origin", done => { + const postMessage = new PostMessageService(); + const cancelA = postMessage.on("testA", value => { + throw new Error("Should not reach this"); + }); + const cancelB = postMessage.on("testB", value => { + done(); + cancelA(); + cancelB(); + }); + postMessage.send("testA", "value", window, "http://i-do-not-exist.de"); + postMessage.send("testB", "value", window); +}); + +it("should cancel", done => { + const postMessage = new PostMessageService(); + const cancelA = postMessage.on("testA", value => { + throw new Error("Should not reach this"); + }); + const cancelB = postMessage.on("testB", value => { + done(); + cancelB(); + }); + cancelA(); + postMessage.send("testA", "value", window); + postMessage.send("testB", "value", window); +}); + +it("different scopes are isolated", done => { + const postMessageA = new PostMessageService("scopeA"); + const postMessageB = new PostMessageService("scopeB"); + const cancelA = postMessageA.on("testA", value => { + throw new Error("Should not reach this"); + }); + const cancelB = postMessageA.on("testB", value => { + done(); + cancelA(); + cancelB(); + }); + postMessageB.send("testA", "value", window); + postMessageA.send("testB", "value", window); +}); + +it("different message names are isolated", done => { + const postMessage = new PostMessageService(); + const cancelA = postMessage.on("testA", value => { + expect(value).toBe("valueA"); + }); + const cancelB = postMessage.on("testB", value => { + expect(value).toBe("valueB"); + done(); + cancelA(); + cancelB(); + }); + postMessage.send("testA", "valueA", window); + postMessage.send("testB", "valueB", window); +}); diff --git a/src/core/client/framework/lib/postMessage.ts b/src/core/client/framework/lib/postMessage.ts new file mode 100644 index 000000000..70523cd5a --- /dev/null +++ b/src/core/client/framework/lib/postMessage.ts @@ -0,0 +1,66 @@ +type PostMessageHandler = (value: any, name: string) => void; + +/** + * Wrapper around the HTML postMessage API. + */ +export class PostMessageService { + private origin: string; + private scope: string; + + constructor( + scope = "talk", + origin: string = `${location.protocol}//${location.host}` + ) { + this.origin = origin; + this.scope = scope; + } + + /** + * Send a message over the postMessage API + * @param name string name of the message + * @param value string value of the message + * @param target Window target window, e.g. window.opener + * @param targetOrigin string origin of target + */ + public send(name: string, value: any, target: Window, targetOrigin?: string) { + if (!target) { + return; + } + + // Serialize the message to be sent via postMessage. + const msg = { name, value, scope: this.scope }; + + // Send the message. + target.postMessage(msg, targetOrigin || this.origin); + } + + /** + * Subscribe to messages + * @param name string Name of the message + * @param handler PostMessageHandler + */ + public on(name: string, handler: PostMessageHandler) { + const listener = (event: MessageEvent) => { + if (!event.origin) { + if (process.env.NODE_ENV !== "test") { + // tslint:disable-next-line:no-console + console.warn("empty origin received in postMessage", name); + } + } else if (event.origin !== this.origin) { + return; + } + if (event.data.scope !== this.scope) { + return; + } + if (event.data.name !== name) { + return; + } + handler(event.data.value, event.data.name); + }; + // Attach the listener to the target. + window.addEventListener("message", listener); + return () => { + window.removeEventListener("message", listener); + }; + } +} From 9510fbed57bc0de5210fa89b8c4d2ae5575016f8 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 10 Aug 2018 16:05:50 +0200 Subject: [PATCH 4/6] Add complex value test --- src/core/client/framework/lib/postMessage.spec.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/core/client/framework/lib/postMessage.spec.ts b/src/core/client/framework/lib/postMessage.spec.ts index 1c8a61a1f..c5cc5a82a 100644 --- a/src/core/client/framework/lib/postMessage.spec.ts +++ b/src/core/client/framework/lib/postMessage.spec.ts @@ -10,6 +10,17 @@ it("post and subscribe to a message", done => { postMessage.send("test", "value", window); }); +it("should support complex value", done => { + const complex = { foo: "bar" }; + const postMessage = new PostMessageService(); + const cancel = postMessage.on("test", value => { + expect(value).toBe(complex); + done(); + cancel(); + }); + postMessage.send("test", complex, window); +}); + it("send to a different origin", done => { const postMessage = new PostMessageService(); const cancelA = postMessage.on("testA", value => { From 37f733dfa0c02ff891808ed1fe122d09c07062c4 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 10 Aug 2018 16:12:08 +0200 Subject: [PATCH 5/6] Add post message listeners --- src/core/client/stream/index.tsx | 14 +++++++-- src/core/client/stream/listeners/index.ts | 5 ++++ .../listeners/onPostMessageAuthError.ts | 11 +++++++ .../onPostMessageSetAuthToken.spec.ts | 30 +++++++++++++++++++ .../listeners/onPostMessageSetAuthToken.ts | 16 ++++++++++ .../onPymSetCommentID.spec.ts} | 6 ++-- .../onPymSetCommentID.ts} | 2 +- src/core/client/stream/pym/index.ts | 1 - 8 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 src/core/client/stream/listeners/index.ts create mode 100644 src/core/client/stream/listeners/onPostMessageAuthError.ts create mode 100644 src/core/client/stream/listeners/onPostMessageSetAuthToken.spec.ts create mode 100644 src/core/client/stream/listeners/onPostMessageSetAuthToken.ts rename src/core/client/stream/{pym/withSetCommentID.spec.ts => listeners/onPymSetCommentID.spec.ts} (88%) rename src/core/client/stream/{pym/withSetCommentID.ts => listeners/onPymSetCommentID.ts} (91%) delete mode 100644 src/core/client/stream/pym/index.ts diff --git a/src/core/client/stream/index.tsx b/src/core/client/stream/index.tsx index 4ac024d6d..dcd2af7e1 100644 --- a/src/core/client/stream/index.tsx +++ b/src/core/client/stream/index.tsx @@ -10,16 +10,24 @@ import { } from "talk-framework/lib/bootstrap"; import AppContainer from "./containers/AppContainer"; +import { + onPostMessageAuthError, + onPostMessageSetAuthToken, + onPymSetCommentID, +} from "./listeners"; import { initLocalState } from "./local"; import localesData from "./locales"; -import { withSetCommentID } from "./pym"; -const pymFeatures = [withSetCommentID]; +const listeners = [ + onPymSetCommentID, + onPostMessageSetAuthToken, + onPostMessageAuthError, +]; // This is called when the context is first initialized. async function init(context: TalkContext) { await initLocalState(context.relayEnvironment); - pymFeatures.forEach(f => f(context)); + listeners.forEach(f => f(context)); } async function main() { diff --git a/src/core/client/stream/listeners/index.ts b/src/core/client/stream/listeners/index.ts new file mode 100644 index 000000000..7901544e0 --- /dev/null +++ b/src/core/client/stream/listeners/index.ts @@ -0,0 +1,5 @@ +export { default as onPymSetCommentID } from "./onPymSetCommentID"; +export { + default as onPostMessageSetAuthToken, +} from "./onPostMessageSetAuthToken"; +export { default as onPostMessageAuthError } from "./onPostMessageAuthError"; diff --git a/src/core/client/stream/listeners/onPostMessageAuthError.ts b/src/core/client/stream/listeners/onPostMessageAuthError.ts new file mode 100644 index 000000000..668a4ba1f --- /dev/null +++ b/src/core/client/stream/listeners/onPostMessageAuthError.ts @@ -0,0 +1,11 @@ +import { TalkContext } from "talk-framework/lib/bootstrap"; + +export default function onPostMessageSetAuthToken({ + postMessage, +}: TalkContext) { + // Auth popup will use this to send back errors during login. + postMessage!.on("authError", error => { + // tslint:disable-next-line:no-console + console.error(error); + }); +} diff --git a/src/core/client/stream/listeners/onPostMessageSetAuthToken.spec.ts b/src/core/client/stream/listeners/onPostMessageSetAuthToken.spec.ts new file mode 100644 index 000000000..37ed034d6 --- /dev/null +++ b/src/core/client/stream/listeners/onPostMessageSetAuthToken.spec.ts @@ -0,0 +1,30 @@ +import { Environment, RecordSource } from "relay-runtime"; + +import { LOCAL_ID } from "talk-framework/lib/relay"; +import { createRelayEnvironment } from "talk-framework/testHelpers"; + +import onPostMessageSetAuthToken from "./onPostMessageSetAuthToken"; + +let relayEnvironment: Environment; +const source: RecordSource = new RecordSource(); + +beforeAll(() => { + relayEnvironment = createRelayEnvironment({ + source, + }); +}); + +it("Sets auth token", () => { + const token = "auth-token"; + const context = { + postMessage: { + on: (name: string, cb: (token: string) => void) => { + expect(name).toBe("setAuthToken"); + cb(token); + }, + }, + relayEnvironment, + }; + onPostMessageSetAuthToken(context as any); + expect(source.get(LOCAL_ID)!.authToken).toEqual(token); +}); diff --git a/src/core/client/stream/listeners/onPostMessageSetAuthToken.ts b/src/core/client/stream/listeners/onPostMessageSetAuthToken.ts new file mode 100644 index 000000000..9321e0395 --- /dev/null +++ b/src/core/client/stream/listeners/onPostMessageSetAuthToken.ts @@ -0,0 +1,16 @@ +import { commitLocalUpdate } from "react-relay"; + +import { TalkContext } from "talk-framework/lib/bootstrap"; +import { LOCAL_ID } from "talk-framework/lib/relay"; + +export default function onPostMessageSetAuthToken({ + relayEnvironment, + postMessage, +}: TalkContext) { + // Auth popup will use this to handle a successful login. + postMessage!.on("setAuthToken", token => { + commitLocalUpdate(relayEnvironment, s => { + s.get(LOCAL_ID)!.setValue(token, "authToken"); + }); + }); +} diff --git a/src/core/client/stream/pym/withSetCommentID.spec.ts b/src/core/client/stream/listeners/onPymSetCommentID.spec.ts similarity index 88% rename from src/core/client/stream/pym/withSetCommentID.spec.ts rename to src/core/client/stream/listeners/onPymSetCommentID.spec.ts index 3403a0092..6b5e67ea2 100644 --- a/src/core/client/stream/pym/withSetCommentID.spec.ts +++ b/src/core/client/stream/listeners/onPymSetCommentID.spec.ts @@ -3,7 +3,7 @@ import { Environment, RecordSource } from "relay-runtime"; import { LOCAL_ID } from "talk-framework/lib/relay"; import { createRelayEnvironment } from "talk-framework/testHelpers"; -import withSetCommentID from "./withSetCommentID"; +import onPymSetCommentID from "./onPymSetCommentID"; let relayEnvironment: Environment; const source: RecordSource = new RecordSource(); @@ -25,7 +25,7 @@ it("Sets comment id", () => { }, relayEnvironment, }; - withSetCommentID(context as any); + onPymSetCommentID(context as any); expect(source.get(LOCAL_ID)!.commentID).toEqual(id); }); @@ -40,6 +40,6 @@ it("Sets comment id to null when empty", () => { }, relayEnvironment, }; - withSetCommentID(context as any); + onPymSetCommentID(context as any); expect(source.get(LOCAL_ID)!.commentID).toEqual(null); }); diff --git a/src/core/client/stream/pym/withSetCommentID.ts b/src/core/client/stream/listeners/onPymSetCommentID.ts similarity index 91% rename from src/core/client/stream/pym/withSetCommentID.ts rename to src/core/client/stream/listeners/onPymSetCommentID.ts index 903921d9b..4b5547173 100644 --- a/src/core/client/stream/pym/withSetCommentID.ts +++ b/src/core/client/stream/listeners/onPymSetCommentID.ts @@ -3,7 +3,7 @@ import { commitLocalUpdate } from "react-relay"; import { TalkContext } from "talk-framework/lib/bootstrap"; import { LOCAL_ID } from "talk-framework/lib/relay"; -export default function withSetCommentID({ +export default function onPymSetCommentID({ relayEnvironment, pym, }: TalkContext) { diff --git a/src/core/client/stream/pym/index.ts b/src/core/client/stream/pym/index.ts deleted file mode 100644 index 34ba3fc8f..000000000 --- a/src/core/client/stream/pym/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as withSetCommentID } from "./withSetCommentID"; From e1c60bc18dc7457df6ec2b44250adea01ca14804 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 10 Aug 2018 16:18:29 +0200 Subject: [PATCH 6/6] Use setAuthToken mutation --- .../stream/listeners/onPostMessageSetAuthToken.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/core/client/stream/listeners/onPostMessageSetAuthToken.ts b/src/core/client/stream/listeners/onPostMessageSetAuthToken.ts index 9321e0395..77be702ad 100644 --- a/src/core/client/stream/listeners/onPostMessageSetAuthToken.ts +++ b/src/core/client/stream/listeners/onPostMessageSetAuthToken.ts @@ -1,16 +1,13 @@ -import { commitLocalUpdate } from "react-relay"; - import { TalkContext } from "talk-framework/lib/bootstrap"; -import { LOCAL_ID } from "talk-framework/lib/relay"; + +import { commit as setAuthToken } from "talk-framework/mutations/SetAuthTokenMutation"; export default function onPostMessageSetAuthToken({ relayEnvironment, postMessage, }: TalkContext) { // Auth popup will use this to handle a successful login. - postMessage!.on("setAuthToken", token => { - commitLocalUpdate(relayEnvironment, s => { - s.get(LOCAL_ID)!.setValue(token, "authToken"); - }); + postMessage!.on("setAuthToken", (authToken: string) => { + setAuthToken(relayEnvironment, { authToken }); }); }