Support authentication

This commit is contained in:
Chi Vinh Le
2018-08-09 16:14:35 +02:00
parent ff62dc4ecc
commit d140e2449f
22 changed files with 357 additions and 15 deletions
+6
View File
@@ -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",
+1
View File
@@ -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",
@@ -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`
@@ -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<TalkContext> {
// 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.
@@ -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<string, string> = {
"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;
@@ -1 +1 @@
export { default as fetchQuery } from "./fetchQuery";
export { default as createFetch, TokenGetter } from "./fetchQuery";
@@ -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");
});
@@ -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<string, string>;
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();
}
@@ -0,0 +1,5 @@
import prefixStorage from "./prefixStorage";
export default function createLocalStorage(): Storage {
return prefixStorage(window.localStorage, "talk");
}
@@ -0,0 +1,5 @@
import prefixStorage from "./prefixStorage";
export default function createSessionStorage(): Storage {
return prefixStorage(window.sessionStorage, "talk");
}
@@ -0,0 +1,3 @@
export { default as createInMemoryStorage } from "./InMemoryStorage";
export { default as createLocalStorage } from "./LocalStorage";
export { default as createSessionStorage } from "./SessionStorage";
@@ -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();
});
@@ -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);
}
@@ -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();
});
@@ -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<void>;
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
);
@@ -0,0 +1,5 @@
export {
withSetAuthTokenMutation,
SetAuthTokenMutation,
SetAuthTokenInput,
} from "./SetAuthTokenMutation";
+1 -1
View File
@@ -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));
}
@@ -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\\"
},
@@ -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");
});
@@ -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);
@@ -21,6 +21,7 @@ type Local {
assetURL: String
commentID: String
authPopup: AuthPopup!
authToken: String
}
extend type Query {
+1
View File
@@ -1,3 +1,4 @@
import "jest-localstorage-mock";
import "./enzyme";
import "./jsdom";