mirror of
https://github.com/wassname/talk.git
synced 2026-09-11 12:51:33 +08:00
[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:
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user