[next] Start a clean session when user logs in / out (#1853)

* Clear user session after login / logout

* Filename cases

* Improve type checking

* Apply suggestions
This commit is contained in:
Kiwi
2018-09-12 18:04:54 +02:00
committed by Wyatt Johnson
parent 26b59fc17c
commit 8b09b52be8
30 changed files with 485 additions and 331 deletions
+2 -5
View File
@@ -3,12 +3,9 @@ import { Environment, ROOT_ID } from "relay-runtime";
export default function getMe(environment: Environment) {
const source = environment.getStore().getSource();
const root = source.get(ROOT_ID)!;
const meKey = Object.keys(root)
.reverse()
.find(s => s.startsWith("me("))!;
if (!root[meKey]) {
if (!root.me) {
return null;
}
const meID = root[meKey].__ref;
const meID = root.me.__ref;
return source.get(meID)!;
}
@@ -1,3 +1,4 @@
import { EventEmitter2 } from "eventemitter2";
import { LocalizationProvider } from "fluent-react/compat";
import { FluentBundle } from "fluent/compat";
import { Child as PymChild } from "pym.js";
@@ -52,6 +53,12 @@ export interface TalkContext {
/** Generates uuids. */
uuidGenerator: () => string;
/** A event emitter */
eventEmitter: EventEmitter2;
/** Clear session data. */
clearSession: () => void;
}
const { Provider, Consumer } = React.createContext<TalkContext>({} as any);
@@ -1,147 +0,0 @@
import { EventEmitter2 } from "eventemitter2";
import { Localized } from "fluent-react/compat";
import { noop } from "lodash";
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 uuid from "uuid/v4";
import { getBrowserInfo } from "talk-framework/lib/browserInfo";
import { LOCAL_ID } from "talk-framework/lib/relay";
import {
createLocalStorage,
createPromisifiedStorage,
createPymStorage,
createSessionStorage,
} from "talk-framework/lib/storage";
import { RestClient } from "talk-framework/lib/rest";
import { ClickFarAwayRegister } from "talk-ui/components/ClickOutside";
import { generateBundles, LocalesData, negotiateLanguages } from "../i18n";
import { createFetch, TokenGetter } from "../network";
import { PostMessageService } from "../postMessage";
import { TalkContext } from "./TalkContext";
interface CreateContextArguments {
/** Locales that the user accepts, usually `navigator.languages`. */
userLocales: ReadonlyArray<string>;
/** Locales data that is returned by our `locales-loader`. */
localesData: LocalesData;
/** Init will be called after the context has been created. */
init?: ((context: TalkContext) => void | Promise<void>);
/** A pym child that interacts with the pym parent. */
pym?: PymChild;
/** Supports emitting and listening to events. */
eventEmitter?: EventEmitter2;
}
/**
* timeagoFormatter integrates timeago into our translation
* framework. It gets injected into the UIContext.
*/
export const timeagoFormatter: Formatter = (value, unit, suffix) => {
// We use 'in' instead of 'from now' for language consistency
const ourSuffix = suffix === "from now" ? "in" : suffix;
return (
<Localized
id="framework-timeago"
$value={value}
$unit={unit}
$suffix={ourSuffix}
>
<span>now</span>
</Localized>
);
};
function areWeInIframe() {
try {
return window.self !== window.top;
} catch (e) {
return true;
}
}
/**
* `createContext` manages the dependencies of our framework
* and returns a `TalkContext` that can be passed to the
* `TalkContextProvider`.
*/
export default async function createContext({
init = noop,
userLocales,
localesData,
pym,
eventEmitter = new EventEmitter2({ wildcard: true }),
}: CreateContextArguments): Promise<TalkContext> {
const inIframe = areWeInIframe();
// 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(createFetch(tokenGetter)),
store: new Store(source),
});
// Listen for outside clicks.
let registerClickFarAway: ClickFarAwayRegister | undefined;
if (pym) {
registerClickFarAway = cb => {
pym.onMessage("click", cb);
// Return unlisten callback.
return () => {
const index = pym.messageHandlers.click.indexOf(cb);
if (index > -1) {
pym.messageHandlers.click.splice(index, 1);
}
};
};
}
// Initialize i18n.
const locales = negotiateLanguages(userLocales, localesData);
if (process.env.NODE_ENV !== "production") {
// tslint:disable:next-line: no-console
console.log(`Negotiated locales ${JSON.stringify(locales)}`);
}
const localeBundles = await generateBundles(locales, localesData);
// Assemble context.
const context = {
relayEnvironment,
localeBundles,
timeagoFormatter,
pym,
eventEmitter,
registerClickFarAway,
rest: new RestClient("/api", tokenGetter),
postMessage: new PostMessageService(),
localStorage:
(pym && inIframe && createPymStorage(pym, "localStorage")) ||
createPromisifiedStorage(createLocalStorage()),
sessionStorage:
(pym && inIframe && createPymStorage(pym, "sessionStorage")) ||
createPromisifiedStorage(createSessionStorage()),
browserInfo: getBrowserInfo(),
uuidGenerator: uuid,
};
// Run custom initializations.
await init(context);
return context;
}
@@ -0,0 +1,252 @@
import { EventEmitter2 } from "eventemitter2";
import { Localized } from "fluent-react/compat";
import { noop } from "lodash";
import { Child as PymChild } from "pym.js";
import React, { Component, ComponentType } from "react";
import { Formatter } from "react-timeago";
import { Environment, Network, RecordSource, Store } from "relay-runtime";
import uuid from "uuid/v4";
import { getBrowserInfo } from "talk-framework/lib/browserInfo";
import { LOCAL_ID } from "talk-framework/lib/relay";
import {
createLocalStorage,
createPromisifiedStorage,
createPymStorage,
createSessionStorage,
PromisifiedStorage,
} from "talk-framework/lib/storage";
import { RestClient } from "talk-framework/lib/rest";
import { ClickFarAwayRegister } from "talk-ui/components/ClickOutside";
import { generateBundles, LocalesData, negotiateLanguages } from "../i18n";
import { createFetch, TokenGetter } from "../network";
import { PostMessageService } from "../postMessage";
import { TalkContext, TalkContextProvider } from "./TalkContext";
export type InitLocalState = ((
environment: Environment,
context: TalkContext
) => void | Promise<void>);
interface CreateContextArguments {
/** Locales that the user accepts, usually `navigator.languages`. */
userLocales: ReadonlyArray<string>;
/** Locales data that is returned by our `locales-loader`. */
localesData: LocalesData;
/** Init will be called after the context has been created. */
initLocalState?: InitLocalState;
/** A pym child that interacts with the pym parent. */
pym?: PymChild;
/** Supports emitting and listening to events. */
eventEmitter?: EventEmitter2;
}
/**
* timeagoFormatter integrates timeago into our translation
* framework. It gets injected into the UIContext.
*/
export const timeagoFormatter: Formatter = (value, unit, suffix) => {
// We use 'in' instead of 'from now' for language consistency
const ourSuffix = suffix === "from now" ? "in" : suffix;
return (
<Localized
id="framework-timeago"
$value={value}
$unit={unit}
$suffix={ourSuffix}
>
<span>now</span>
</Localized>
);
};
/**
* Returns true if we are in an iframe.
*/
function areWeInIframe() {
try {
return window.self !== window.top;
} catch (e) {
return true;
}
}
function createRelayEnvironment() {
// Initialize Relay.
const source = new RecordSource();
const tokenGetter: TokenGetter = () => {
const localState = source.get(LOCAL_ID);
if (localState) {
return localState.authToken || "";
}
return "";
};
const environment = new Environment({
network: Network.create(createFetch(tokenGetter)),
store: new Store(source),
});
return { environment, tokenGetter };
}
function createRestAPI(tokenGetter: (() => string)) {
return new RestClient("/api", tokenGetter);
}
/**
* Returns a managed TalkContextProvider, that includes given context
* and handles context changes, e.g. when a user session changes.
*/
function createMangedTalkContextProvider(
context: TalkContext,
initLocalState: InitLocalState
) {
const ManagedTalkContextProvider = class extends Component<
{},
{ context: TalkContext }
> {
constructor(props: {}) {
super(props);
this.state = {
context: {
...context,
clearSession: this.clearSession,
},
};
}
// This is called every time a user session starts or ends.
private clearSession = async () => {
// Clear session storage.
this.state.context.sessionStorage.clear();
// Create a new context with a new Relay Environment.
const {
environment: newEnvironment,
tokenGetter: newTokenGetter,
} = createRelayEnvironment();
const newContext = {
...this.state.context,
relayEnvironment: newEnvironment,
rest: createRestAPI(newTokenGetter),
};
// Initialize local state.
await initLocalState(newContext.relayEnvironment, newContext);
// Propagate new context.
this.setState({
context: newContext,
});
};
public render() {
return (
<TalkContextProvider value={this.state.context}>
{this.props.children}
</TalkContextProvider>
);
}
};
return ManagedTalkContextProvider;
}
/**
* resolveLocalStorage decides which local storage to use in the context
*/
function resolveLocalStorage(pym?: PymChild): PromisifiedStorage {
if (pym && areWeInIframe()) {
// Use local storage over pym when we have pym and are in an iframe.
return createPymStorage(pym, "localStorage");
}
// Use promisified, prefixed local storage.
return createPromisifiedStorage(createLocalStorage());
}
/**
* resolveSessionStorage decides which session storage to use in the context
*/
function resolveSessionStorage(pym?: PymChild): PromisifiedStorage {
if (pym && areWeInIframe()) {
// Use session storage over pym when we have pym and are in an iframe.
return createPymStorage(pym, "sessionStorage");
}
// Use promisified, prefixed session storage.
return createPromisifiedStorage(createSessionStorage());
}
/**
* `createManaged` establishes the dependencies of our framework
* and returns a `ManagedTalkContextProvider` that provides the context
* to the rest of the application.
*/
export default async function createManaged({
initLocalState = noop,
userLocales,
localesData,
pym,
eventEmitter = new EventEmitter2({ wildcard: true, maxListeners: 20 }),
}: CreateContextArguments): Promise<ComponentType> {
// Listen for outside clicks.
let registerClickFarAway: ClickFarAwayRegister | undefined;
if (pym) {
registerClickFarAway = cb => {
pym.onMessage("click", cb);
// Return unlisten callback.
return () => {
const index = pym.messageHandlers.click.indexOf(cb);
if (index > -1) {
pym.messageHandlers.click.splice(index, 1);
}
};
};
}
// Initialize i18n.
const locales = negotiateLanguages(userLocales, localesData);
if (process.env.NODE_ENV !== "production") {
// tslint:disable:next-line: no-console
console.log(`Negotiated locales ${JSON.stringify(locales)}`);
}
const localeBundles = await generateBundles(locales, localesData);
const localStorage = resolveLocalStorage(pym);
const sessionStorage = resolveSessionStorage(pym);
const { environment, tokenGetter } = createRelayEnvironment();
// Assemble context.
const context: TalkContext = {
relayEnvironment: environment,
localeBundles,
timeagoFormatter,
pym,
eventEmitter,
registerClickFarAway,
rest: createRestAPI(tokenGetter),
postMessage: new PostMessageService(),
localStorage,
sessionStorage,
browserInfo: getBrowserInfo(),
uuidGenerator: uuid,
// Noop, this is later replaced by the
// managed TalkContextProvider.
clearSession: noop,
};
// Initialize local state.
await initLocalState(context.relayEnvironment, context);
// Returns a managed TalkContextProvider, that includes the above
// context and handles context changes, e.g. when a user session changes.
return createMangedTalkContextProvider(context, initLocalState);
}
@@ -1,3 +1,7 @@
export * from "./TalkContext";
export { default as createContext } from "./createContext";
export {
TalkContext,
TalkContextConsumer,
TalkContextProvider,
} from "./TalkContext";
export { default as createManaged } from "./createManaged";
export { default as withContext } from "./withContext";
@@ -0,0 +1,18 @@
import {
commitLocalUpdate,
Environment,
RecordSourceProxy,
} from "relay-runtime";
export default function commitLocalUpdatePromisified(
environment: Environment,
updater: (store: RecordSourceProxy) => Promise<void>
) {
return new Promise((resolve, reject) => {
commitLocalUpdate(environment, store => {
updater(store)
.then(() => resolve())
.catch(err => reject(err));
});
});
}
@@ -13,3 +13,6 @@ export {
commitMutationPromiseNormalized,
} from "./commitMutationPromise";
export { graphql } from "react-relay";
export {
default as commitLocalUpdatePromisified,
} from "./commitLocalUpdatePromisified";
@@ -1,7 +1,9 @@
import { Environment, RecordSource } from "relay-runtime";
import sinon from "sinon";
import { TalkContext } from "talk-framework/lib/bootstrap";
import { LOCAL_ID } from "talk-framework/lib/relay";
import { createInMemoryStorage } from "talk-framework/lib/storage";
import { createPromisifiedStorage } from "talk-framework/lib/storage";
import { createRelayEnvironment } from "talk-framework/testHelpers";
import { commit } from "./SetAuthTokenMutation";
@@ -15,21 +17,29 @@ beforeAll(() => {
});
});
it("Sets auth token to localStorage", () => {
const context = {
localStorage: createInMemoryStorage(),
it("Sets auth token to localStorage", async () => {
const clearSessionStub = sinon.stub();
const context: Partial<TalkContext> = {
localStorage: createPromisifiedStorage(),
clearSession: clearSessionStub,
};
const authToken = "auth token";
commit(environment, { authToken }, context as any);
await commit(environment, { authToken }, context as any);
expect(source.get(LOCAL_ID)!.authToken).toEqual(authToken);
expect(context.localStorage.getItem("authToken")).toEqual(authToken);
await expect(context.localStorage!.getItem("authToken")).resolves.toEqual(
authToken
);
expect(clearSessionStub.calledOnce).toBe(true);
});
it("Removes auth token from localStorage", () => {
const context = {
localStorage: createInMemoryStorage(),
it("Removes auth token from localStorage", async () => {
const clearSessionStub = sinon.stub();
const context: Partial<TalkContext> = {
localStorage: createPromisifiedStorage(),
clearSession: clearSessionStub,
};
localStorage.setItem("authToken", "tmp");
commit(environment, { authToken: null }, context as any);
expect(context.localStorage.getItem("authToken")).toBeNull();
await commit(environment, { authToken: null }, context as any);
await expect(context.localStorage!.getItem("authToken")).resolves.toBeNull();
expect(clearSessionStub.calledOnce).toBe(true);
});
@@ -1,7 +1,10 @@
import { commitLocalUpdate, Environment } from "relay-runtime";
import { Environment } from "relay-runtime";
import { TalkContext } from "talk-framework/lib/bootstrap";
import { createMutationContainer } from "talk-framework/lib/relay";
import {
commitLocalUpdatePromisified,
createMutationContainer,
} from "talk-framework/lib/relay";
import { LOCAL_ID } from "talk-framework/lib/relay/withLocalStateContainer";
export interface SetAuthTokenInput {
@@ -13,27 +16,18 @@ export type SetAuthTokenMutation = (input: SetAuthTokenInput) => Promise<void>;
export async function commit(
environment: Environment,
input: SetAuthTokenInput,
{ localStorage }: TalkContext
{ localStorage, clearSession }: TalkContext
) {
return commitLocalUpdate(environment, store => {
return await commitLocalUpdatePromisified(environment, async store => {
const record = store.get(LOCAL_ID)!;
record.setValue(input.authToken, "authToken");
if (input.authToken) {
localStorage.setItem("authToken", input.authToken);
await localStorage.setItem("authToken", input.authToken);
} else {
localStorage.removeItem("authToken");
await localStorage.removeItem("authToken");
}
// Increment auth revision to indicate a change in auth state.
record.setValue(record.getValue("authRevision") + 1, "authRevision");
// Force gc to trigger.
environment
.retain({
dataID: "tmp",
node: { selections: [] },
variables: {},
})
.dispose();
// Clear current session, as we are starting a new one.
clearSession();
});
}