[next] Admin auth + design (#2056)

* feat: extract jwt information

* feat: login status dependent auto redirect

* feat: Sign Out button

* feat: add a 404 page

* feat: improve loading state and use auth token info

* feat: redirect to previous destination

* feat: implement new design

* fix: change asset to story

* feat: add translations

* feat: more compact design

* test: add unit tests

* chore: refactor NavigationLink

* test: add integration tests

* chore: refactor replaceHistoryLocation

* fix: typo

* fix: property name typo
This commit is contained in:
Kiwi
2018-10-31 23:11:32 +00:00
committed by Wyatt Johnson
parent 1dc400ba58
commit bc5db7b599
123 changed files with 4014 additions and 461 deletions
+30
View File
@@ -0,0 +1,30 @@
export interface JWT {
header: {
alg: string;
typ: string;
};
payload: {
iat?: number;
exp?: number;
iss?: string;
sub?: string;
jti?: string;
};
expired: boolean;
}
export function parseJWT(token: string, skewTolerance = 300): JWT {
const [headerBase64, payloadBase64] = token.split(".");
if (!headerBase64 && !payloadBase64) {
throw new Error("invalid jwt token");
}
const header = JSON.parse(atob(headerBase64));
const payload = JSON.parse(atob(payloadBase64));
return {
header,
payload,
get expired() {
return Date.now() - skewTolerance < payload.exp;
},
};
}
@@ -16,3 +16,4 @@ export { graphql } from "react-relay";
export {
default as commitLocalUpdatePromisified,
} from "./commitLocalUpdatePromisified";
export { initLocalBaseState, setAuthTokenInLocalState } from "./localState";
@@ -0,0 +1,68 @@
import {
commitLocalUpdate,
Environment,
RecordSourceProxy,
} from "relay-runtime";
import { TalkContext } from "talk-framework/lib/bootstrap";
import { parseJWT } from "talk-framework/lib/jwt";
import { createAndRetain } from "talk-framework/lib/relay";
/**
* The Root Record of Client-Side Schema Extension must be of this type.
*/
export const LOCAL_TYPE = "Local";
/**
* The Root Record of Client-Side Schema Extension must have this id.
*/
export const LOCAL_ID = "client:root.local";
export const NETWORK_TYPE = "Network";
export const NETWORK_ID = "client:root.local.network";
export function setAuthTokenInLocalState(
authToken: string | null,
source: RecordSourceProxy
) {
const localRecord = source.get(LOCAL_ID)!;
localRecord.setValue(authToken || "", "authToken");
if (authToken) {
const { payload, expired } = parseJWT(authToken);
localRecord.setValue(payload.exp, "authExp");
localRecord.setValue(payload.jti, "authJTI");
localRecord.setValue(!expired, "loggedIn");
} else {
localRecord.setValue(null, "authExp");
localRecord.setValue(null, "authJTI");
localRecord.setValue(false, "loggedIn");
}
}
export async function initLocalBaseState(
environment: Environment,
{ localStorage }: TalkContext
) {
const authToken = await localStorage!.getItem("authToken");
commitLocalUpdate(environment, s => {
const root = s.getRoot();
// Create the Local Record which is the Root for the client states.
const localRecord = createAndRetain(environment, s, LOCAL_ID, LOCAL_TYPE);
root.setLinkedRecord(localRecord, "local");
// Set auth token
setAuthTokenInLocalState(authToken, s);
// Create network Record
const networkRecord = createAndRetain(
environment,
s,
NETWORK_ID,
NETWORK_TYPE
);
networkRecord.setValue(false, "isOffline");
localRecord.setLinkedRecord(networkRecord, "network");
});
}
@@ -17,13 +17,24 @@ beforeAll(() => {
});
});
const authToken = `${btoa(
JSON.stringify({
alg: "HS256",
typ: "JWT",
})
)}.${btoa(
JSON.stringify({
exp: 1540503165,
jti: "31b26591-4e9a-4388-a7ff-e1bdc5d97cce",
})
)}`;
it("Sets auth token to localStorage", async () => {
const clearSessionStub = sinon.stub();
const context: Partial<TalkContext> = {
localStorage: createPromisifiedStorage(),
clearSession: clearSessionStub,
};
const authToken = "auth token";
await commit(environment, { authToken }, context as any);
expect(source.get(LOCAL_ID)!.authToken).toEqual(authToken);
await expect(context.localStorage!.getItem("authToken")).resolves.toEqual(
@@ -38,7 +49,7 @@ it("Removes auth token from localStorage", async () => {
localStorage: createPromisifiedStorage(),
clearSession: clearSessionStub,
};
localStorage.setItem("authToken", "tmp");
localStorage.setItem("authToken", authToken);
await commit(environment, { authToken: null }, context as any);
await expect(context.localStorage!.getItem("authToken")).resolves.toBeNull();
expect(clearSessionStub.calledOnce).toBe(true);
@@ -4,8 +4,8 @@ import { TalkContext } from "talk-framework/lib/bootstrap";
import {
commitLocalUpdatePromisified,
createMutationContainer,
setAuthTokenInLocalState,
} from "talk-framework/lib/relay";
import { LOCAL_ID } from "talk-framework/lib/relay/withLocalStateContainer";
export interface SetAuthTokenInput {
authToken: string | null;
@@ -19,8 +19,7 @@ export async function commit(
{ localStorage, clearSession }: TalkContext
) {
return await commitLocalUpdatePromisified(environment, async store => {
const record = store.get(LOCAL_ID)!;
record.setValue(input.authToken, "authToken");
setAuthTokenInLocalState(input.authToken, store);
if (input.authToken) {
await localStorage.setItem("authToken", input.authToken);
} else {
@@ -0,0 +1,25 @@
import { Environment, RecordSource } from "relay-runtime";
import { createRelayEnvironment } from "talk-framework/testHelpers";
import { NETWORK_ID, NETWORK_TYPE } from "../lib/relay/localState";
import { commit } from "./SetNetworkStatusMutation";
let environment: Environment;
const source: RecordSource = new RecordSource();
beforeAll(() => {
environment = createRelayEnvironment({
source,
initLocalState: (localRecord, sourceProxy) => {
const networkRecord = sourceProxy.create(NETWORK_ID, NETWORK_TYPE);
networkRecord.setValue(false, "isOffline");
localRecord.setLinkedRecord(networkRecord, "network");
},
});
});
it("Sets comment id", () => {
commit(environment, { isOffline: true });
expect(source.get(NETWORK_ID)!.isOffline).toEqual(true);
});
@@ -0,0 +1,28 @@
import { commitLocalUpdate, Environment } from "relay-runtime";
import { createMutationContainer } from "talk-framework/lib/relay";
import { NETWORK_ID } from "../lib/relay/localState";
export interface SetNetworkStatusInput {
isOffline: boolean;
}
export type SetNetworkStatusMutation = (
input: SetNetworkStatusInput
) => Promise<void>;
export async function commit(
environment: Environment,
input: SetNetworkStatusInput
) {
return commitLocalUpdate(environment, store => {
const record = store.get(NETWORK_ID)!;
record.setValue(input.isOffline, "isOffline");
});
}
export const withSetNetworkStatusMutation = createMutationContainer(
"setNetworkStatus",
commit
);
@@ -4,3 +4,8 @@ export {
SetAuthTokenInput,
} from "./SetAuthTokenMutation";
export { withSignOutMutation, SignOutMutation } from "./SignOutMutation";
export {
withSetNetworkStatusMutation,
SetNetworkStatusMutation,
SetNetworkStatusInput,
} from "./SetNetworkStatusMutation";
@@ -10,3 +10,4 @@ export {
} from "./removeFragmentRefs";
export { default as createUUIDGenerator } from "./createUUIDGenerator";
export * from "./denormalize";
export { default as replaceHistoryLocation } from "./replaceHistoryLocation";
@@ -0,0 +1,15 @@
type RestoreHistoryFunction = () => void;
export default function replaceHistoryLocation(
location: string
): RestoreHistoryFunction {
const previousState = window.history.state;
const previousLocation = location.toString();
window.history.replaceState(previousState, document.title, location);
return () =>
window.history.replaceState(
previousState,
document.title,
previousLocation
);
}