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
@@ -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";