mirror of
https://github.com/wassname/talk.git
synced 2026-07-03 11:46:08 +08:00
Support authentication
This commit is contained in:
Generated
+6
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
@@ -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,3 +1,4 @@
|
||||
import "jest-localstorage-mock";
|
||||
import "./enzyme";
|
||||
import "./jsdom";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user