diff --git a/frontend/src/Auth/Login.tsx b/frontend/src/Auth/Login.tsx
index 240f5cc..df22373 100644
--- a/frontend/src/Auth/Login.tsx
+++ b/frontend/src/Auth/Login.tsx
@@ -2,26 +2,96 @@ import "./Auth.scss";
import { Button, Card, FormGroup, H2, InputGroup } from "@blueprintjs/core";
import * as React from "react";
+import { connect } from "react-redux";
+import { Dispatch } from "redux";
+import { authStart } from "~redux/auth/actions";
+import { IAppState } from "~redux/reducers";
-export function Login() {
- return (
- <>
-
-
-
- >
- );
+interface ILoginComponentProps {
+ inProgress: boolean;
+ error: string;
+ login: (username: string, password: string) => void;
}
+
+interface ILoginComponentState {
+ username: string;
+ password: string;
+}
+
+export class LoginComponent extends React.PureComponent<
+ ILoginComponentProps,
+ ILoginComponentState
+> {
+ constructor(props: ILoginComponentProps) {
+ super(props);
+ this.submit = this.submit.bind(this);
+ this.updateFields = this.updateFields.bind(this);
+ this.state = { username: "", password: "" };
+ }
+
+ public submit() {
+ const { username, password } = this.state;
+ this.props.login(username, password);
+ }
+
+ public updateFields(e: React.FormEvent) {
+ const { value, name } = e.currentTarget;
+ this.setState({ ...this.state, [name]: value });
+ }
+
+ public render() {
+ return (
+ <>
+
+
+
+ >
+ );
+ }
+}
+
+function mapStateToProps(state: IAppState) {
+ return { inProgress: state.auth.inProgress, error: state.auth.error };
+}
+
+function mapDispatchToProps(dispatch: Dispatch) {
+ return {
+ login: (username: string, password: string) =>
+ dispatch(authStart(username, password)),
+ };
+}
+
+export const Login = connect(
+ mapStateToProps,
+ mapDispatchToProps,
+)(LoginComponent);
diff --git a/frontend/src/redux/api/auth/index.ts b/frontend/src/redux/api/auth/index.ts
new file mode 100644
index 0000000..97e2b3b
--- /dev/null
+++ b/frontend/src/redux/api/auth/index.ts
@@ -0,0 +1,8 @@
+import { fetchJSON } from "../utils";
+
+export async function login(username: string, password: string) {
+ return fetchJSON("/users/login", "POST", {
+ username,
+ password,
+ });
+}
diff --git a/frontend/src/redux/api/utils.ts b/frontend/src/redux/api/utils.ts
new file mode 100644
index 0000000..546551c
--- /dev/null
+++ b/frontend/src/redux/api/utils.ts
@@ -0,0 +1,50 @@
+let token: string | null;
+
+export function setToken(_token: string) {
+ token = _token;
+}
+
+export function getToken() {
+ return token;
+}
+
+export function deleteToken(_token: string) {
+ token = null;
+}
+
+const root = "http://localhost:3000";
+
+export async function fetchJSON(
+ path: string,
+ method: string,
+ body: string | object,
+ headers?: Record,
+) {
+ if (typeof body === "object") {
+ body = JSON.stringify(body);
+ }
+ const response = await fetch(root + path, {
+ method,
+ body,
+ headers: {
+ ...headers,
+ "Content-Type": "application/json",
+ },
+ });
+ const json = await response.json();
+ return json;
+}
+
+export async function fetchJSONAuth(
+ path: string,
+ method: string,
+ body: string | object,
+ headers?: object,
+) {
+ if (token) {
+ return fetchJSON(path, method, body, {
+ ...headers,
+ Authorization: `Bearer ${token}`,
+ });
+ }
+}
diff --git a/frontend/src/redux/auth/actions.ts b/frontend/src/redux/auth/actions.ts
index e5ab2b8..aeb31fd 100644
--- a/frontend/src/redux/auth/actions.ts
+++ b/frontend/src/redux/auth/actions.ts
@@ -1,10 +1,49 @@
import { Action } from "redux";
-export const AUTH_SUCCESS = "AUTH_SUCCESS";
-
-class AuthSuccessAction implements Action {
- public readonly type = AUTH_SUCCESS;
- constructor(public jwt: string) {}
+export enum AuthTypes {
+ AUTH_START = "AUTH_START",
+ AUTH_SUCCESS = "AUTH_SUCCESS",
+ AUTH_FAIL = "AUTH_FAIL",
}
-export type AuthAction = AuthSuccessAction;
+export interface IAuthStartActionAction extends Action {
+ type: AuthTypes.AUTH_START;
+ payload: {
+ username: string;
+ password: string;
+ };
+}
+
+export interface IAuthSuccessActionAction extends Action {
+ type: AuthTypes.AUTH_SUCCESS;
+ payload: {
+ jwt: string;
+ };
+}
+
+export interface IAuthFailureActionAction extends Action {
+ type: AuthTypes.AUTH_FAIL;
+ payload: {
+ error: string;
+ };
+}
+
+export function authStart(
+ username: string,
+ password: string,
+): IAuthStartActionAction {
+ return { type: AuthTypes.AUTH_START, payload: { username, password } };
+}
+
+export function authSuccess(jwt: string): IAuthSuccessActionAction {
+ return { type: AuthTypes.AUTH_SUCCESS, payload: { jwt } };
+}
+
+export function authFail(error: string): IAuthFailureActionAction {
+ return { type: AuthTypes.AUTH_FAIL, payload: { error } };
+}
+
+export type AuthAction =
+ | IAuthStartActionAction
+ | IAuthSuccessActionAction
+ | IAuthFailureActionAction;
diff --git a/frontend/src/redux/auth/reducer.ts b/frontend/src/redux/auth/reducer.ts
index 868085d..452f7e9 100644
--- a/frontend/src/redux/auth/reducer.ts
+++ b/frontend/src/redux/auth/reducer.ts
@@ -1,15 +1,17 @@
import { Reducer } from "react";
-import { AUTH_SUCCESS, AuthAction } from "./actions";
+import { AuthAction, AuthTypes } from "./actions";
export interface IAuthState {
jwt: string | null;
inProgress: boolean;
+ error: string | null;
}
const defaultAuthState: IAuthState = {
jwt: null,
inProgress: false,
+ error: null,
};
export const auth: Reducer = (
@@ -17,8 +19,14 @@ export const auth: Reducer = (
action: AuthAction,
) => {
switch (action.type) {
- case AUTH_SUCCESS:
- return { ...state, jwt: action.jwt, inProgress: false };
+ case AuthTypes.AUTH_START:
+ return { ...state, inProgress: true };
+ break;
+ case AuthTypes.AUTH_SUCCESS:
+ return { ...state, jwt: action.payload.jwt, inProgress: false };
+ break;
+ case AuthTypes.AUTH_FAIL:
+ return { ...defaultAuthState, error: action.payload.error };
break;
default:
return state;
diff --git a/frontend/src/redux/auth/sagas.ts b/frontend/src/redux/auth/sagas.ts
new file mode 100644
index 0000000..16a3899
--- /dev/null
+++ b/frontend/src/redux/auth/sagas.ts
@@ -0,0 +1,38 @@
+import { delay } from "redux-saga";
+import { call, put, race, takeLatest } from "redux-saga/effects";
+import { login } from "~redux/api/auth";
+import { setToken } from "~redux/api/utils";
+
+import {
+ authFail,
+ authSuccess,
+ AuthTypes,
+ IAuthStartActionAction,
+} from "./actions";
+
+function* authStart(action: IAuthStartActionAction) {
+ const { username, password } = action.payload;
+ try {
+ const { response, timeout } = yield race({
+ response: call(login, username, password),
+ timeout: call(delay, 1000),
+ });
+
+ if (timeout) {
+ return put(authFail("Timeout"));
+ }
+ if (response.data) {
+ const user = response.data;
+ yield call(setToken, user.jwt);
+ yield put(authSuccess(user.jwt));
+ } else {
+ yield put(authFail(response.error));
+ }
+ } catch (e) {
+ yield put(authFail(e.toString()));
+ }
+}
+
+export function* authSaga() {
+ yield takeLatest(AuthTypes.AUTH_START, authStart);
+}
diff --git a/frontend/src/redux/reducers.ts b/frontend/src/redux/reducers.ts
index 7ec4c33..4f7d9fb 100644
--- a/frontend/src/redux/reducers.ts
+++ b/frontend/src/redux/reducers.ts
@@ -1,4 +1,8 @@
import { combineReducers } from "redux";
-import { auth } from "~redux/auth/reducer";
+import { auth, IAuthState } from "~redux/auth/reducer";
+
+export interface IAppState {
+ auth: IAuthState;
+}
export const rootReducer = combineReducers({ auth });
diff --git a/frontend/src/redux/store.ts b/frontend/src/redux/store.ts
index 9008c1d..2117006 100644
--- a/frontend/src/redux/store.ts
+++ b/frontend/src/redux/store.ts
@@ -1,4 +1,11 @@
-import { createStore } from "redux";
+import { applyMiddleware, createStore } from "redux";
+import createSagaMiddlware from "redux-saga";
import { rootReducer } from "~redux/reducers";
-export const store = createStore(rootReducer);
+import { authSaga } from "./auth/sagas";
+
+const sagaMiddleware = createSagaMiddlware();
+
+export const store = createStore(rootReducer, applyMiddleware(sagaMiddleware));
+
+sagaMiddleware.run(authSaga);
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index 5167cd6..bcb05dd 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -5,7 +5,7 @@
"dom"
],
"jsx": "react",
- "target": "es6",
+ "target": "es5",
"module": "commonjs",
"moduleResolution": "node",
"outDir": "./dist",
diff --git a/package-lock.json b/package-lock.json
index da0d178..1b337e3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -68,6 +68,14 @@
}
}
},
+ "@koa/cors": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/@koa/cors/-/cors-2.2.3.tgz",
+ "integrity": "sha512-tCVVXa39ETsit5kGBtEWWimjLn1sDaeu8+0phgb8kT3GmBDZOykkI3ZO8nMjV2p3MGkJI4K5P+bxR8Ztq0bwsA==",
+ "requires": {
+ "vary": "^1.1.2"
+ }
+ },
"@types/accepts": {
"version": "1.3.5",
"resolved": "http://registry.npmjs.org/@types/accepts/-/accepts-1.3.5.tgz",
@@ -151,8 +159,7 @@
"@types/events": {
"version": "1.2.0",
"resolved": "http://registry.npmjs.org/@types/events/-/events-1.2.0.tgz",
- "integrity": "sha512-KEIlhXnIutzKwRbQkGWb/I4HFqBuUykAdHgDED6xqwXJfONCjF5VoE0cXEiurh3XauygxzeDzgtXUqvLkxFzzA==",
- "dev": true
+ "integrity": "sha512-KEIlhXnIutzKwRbQkGWb/I4HFqBuUykAdHgDED6xqwXJfONCjF5VoE0cXEiurh3XauygxzeDzgtXUqvLkxFzzA=="
},
"@types/express": {
"version": "4.16.0",
@@ -176,6 +183,15 @@
"@types/range-parser": "*"
}
},
+ "@types/formidable": {
+ "version": "1.0.31",
+ "resolved": "https://registry.npmjs.org/@types/formidable/-/formidable-1.0.31.tgz",
+ "integrity": "sha512-dIhM5t8lRP0oWe2HF8MuPvdd1TpPTjhDMAqemcq6oIZQCBQTovhBAdTQ5L5veJB4pdQChadmHuxtB0YzqvfU3Q==",
+ "requires": {
+ "@types/events": "*",
+ "@types/node": "*"
+ }
+ },
"@types/http-assert": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.4.0.tgz",
@@ -223,15 +239,6 @@
"@types/node": "*"
}
},
- "@types/koa-bodyparser": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/@types/koa-bodyparser/-/koa-bodyparser-4.2.1.tgz",
- "integrity": "sha512-dd6mVT30OmGYIOmNRF3269Bv+IJ68AVrvYcPViB7bYnzxk7nZyfeAsUx96lvXmaTpOGF4XZ7WDCuSOd7Npi6pw==",
- "dev": true,
- "requires": {
- "@types/koa": "*"
- }
- },
"@types/koa-compose": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/koa-compose/-/koa-compose-3.2.2.tgz",
@@ -256,6 +263,15 @@
"@types/koa": "*"
}
},
+ "@types/koa__cors": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/@types/koa__cors/-/koa__cors-2.2.3.tgz",
+ "integrity": "sha512-RfG2EuSc+nv/E+xbDSLW8KCoeri/3AkqwVPuENfF/DctllRoXhooboO//Sw7yFtkLvj7nG7O1H3JcZmoTQz8nQ==",
+ "dev": true,
+ "requires": {
+ "@types/koa": "*"
+ }
+ },
"@types/lodash": {
"version": "4.14.119",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.119.tgz",
@@ -1230,14 +1246,14 @@
"integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ="
},
"co-body": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/co-body/-/co-body-6.0.0.tgz",
- "integrity": "sha512-9ZIcixguuuKIptnY8yemEOuhb71L/lLf+Rl5JfJEUiDNJk0e02MBt7BPxR2GEh5mw8dPthQYR4jPI/BnS1MQgw==",
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/co-body/-/co-body-5.2.0.tgz",
+ "integrity": "sha512-sX/LQ7LqUhgyaxzbe7IqwPeTr2yfpfUIQ/dgpKo6ZI4y4lpQA0YxAomWIY+7I7rHWcG02PG+OuPREzMW/5tszQ==",
"requires": {
"inflation": "^2.0.0",
- "qs": "^6.5.2",
- "raw-body": "^2.3.3",
- "type-is": "^1.6.16"
+ "qs": "^6.4.0",
+ "raw-body": "^2.2.0",
+ "type-is": "^1.6.14"
}
},
"code-point-at": {
@@ -1544,11 +1560,6 @@
"keygrip": "~1.0.3"
}
},
- "copy-to": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/copy-to/-/copy-to-2.0.1.tgz",
- "integrity": "sha1-JoD7uAaKSNCGVrYJgJK9r8kG9KU="
- },
"core-util-is": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
@@ -2313,8 +2324,7 @@
"formidable": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.1.tgz",
- "integrity": "sha512-Fs9VRguL0gqGHkXS5GQiMCr1VhZBxz0JnJs4JmMp/2jL18Fmbzvv7vOFRU+U8TBkHEE/CX1qDXzJplVULgsLeg==",
- "dev": true
+ "integrity": "sha512-Fs9VRguL0gqGHkXS5GQiMCr1VhZBxz0JnJs4JmMp/2jL18Fmbzvv7vOFRU+U8TBkHEE/CX1qDXzJplVULgsLeg=="
},
"fresh": {
"version": "0.5.2",
@@ -2811,13 +2821,14 @@
}
}
},
- "koa-bodyparser": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/koa-bodyparser/-/koa-bodyparser-4.2.1.tgz",
- "integrity": "sha512-UIjPAlMZfNYDDe+4zBaOAUKYqkwAGcIU6r2ARf1UOXPAlfennQys5IiShaVeNf7KkVBlf88f2LeLvBFvKylttw==",
+ "koa-body": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/koa-body/-/koa-body-4.0.6.tgz",
+ "integrity": "sha512-k03au8eI5FL48fA1YqnpDT9lcRaarHDEGUOes/ppaEbG6vhbU1xvegxP9QHvJz+UgQq75Vw8z3busd484I/PAA==",
"requires": {
- "co-body": "^6.0.0",
- "copy-to": "^2.0.1"
+ "@types/formidable": "^1.0.31",
+ "co-body": "^5.1.1",
+ "formidable": "^1.1.1"
}
},
"koa-compose": {
diff --git a/package.json b/package.json
index 2dce531..64b3471 100644
--- a/package.json
+++ b/package.json
@@ -8,9 +8,9 @@
"@types/eslint-plugin-prettier": "^2.2.0",
"@types/jsonwebtoken": "^8.3.0",
"@types/koa": "^2.0.48",
- "@types/koa-bodyparser": "^4.2.1",
"@types/koa-logger": "^3.1.1",
"@types/koa-router": "^7.0.35",
+ "@types/koa__cors": "^2.2.3",
"@types/lodash": "^4.14.119",
"@types/mocha": "^5.2.5",
"@types/mysql": "^2.15.5",
@@ -40,10 +40,11 @@
"typescript": "3.2.2"
},
"dependencies": {
+ "@koa/cors": "^2.2.3",
"bcrypt": "^3.0.3",
"jsonwebtoken": "^8.4.0",
"koa": "^2.6.2",
- "koa-bodyparser": "^4.2.1",
+ "koa-body": "^4.0.6",
"koa-jwt": "^3.5.1",
"koa-logger": "^3.2.0",
"koa-router": "^7.4.0",
diff --git a/src/app.ts b/src/app.ts
index 4a8aae0..8ea7086 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -1,7 +1,8 @@
import "reflect-metadata";
+import * as cors from "@koa/cors";
import * as Koa from "koa";
-import * as bodyParser from "koa-bodyparser";
+import * as bodyParser from "koa-body";
import * as jwt from "koa-jwt";
import * as logger from "koa-logger";
import { config } from "~config";
@@ -9,6 +10,7 @@ import { userRouter } from "~routes/users";
export const app = new Koa();
+app.use(cors());
app.use(logger());
app.use(bodyParser());
app.use(
diff --git a/src/routes/users.ts b/src/routes/users.ts
index 0ddf73a..1cd25d8 100644
--- a/src/routes/users.ts
+++ b/src/routes/users.ts
@@ -15,14 +15,17 @@ userRouter.get("/users/user", async ctx => {
ctx.body = { errors: false, data: user.toAuthJSON() };
});
-userRouter.get("/users/login", async ctx => {
- if (!ctx.request.body) {
+userRouter.post("/users/login", async ctx => {
+ const request = ctx.request as any;
+
+ if (!request.body) {
ctx.throw(400);
}
- const { username, password } = ctx.request.body as {
+ const { username, password } = request.body as {
username: string | null;
password: string | null;
};
+
if (!(username && password)) {
ctx.throw(400);
}
@@ -35,12 +38,14 @@ userRouter.get("/users/login", async ctx => {
ctx.body = { errors: false, data: user.toAuthJSON() };
});
-userRouter.get("/users/signup", async ctx => {
- if (!ctx.request.body) {
+userRouter.post("/users/signup", async ctx => {
+ const request = ctx.request as any;
+
+ if (!request.body) {
ctx.throw(400);
}
- const { username, password } = ctx.request.body as {
+ const { username, password } = request.body as {
username: string | null;
password: string | null;
};
diff --git a/tests/integration/users.test.ts b/tests/integration/users.test.ts
index 8b767f7..6bc1ce2 100644
--- a/tests/integration/users.test.ts
+++ b/tests/integration/users.test.ts
@@ -27,7 +27,10 @@ describe("users", () => {
it("should get user", async () => {
const response = await request(callback)
.get("/users/user")
- .set({ Authorization: `Bearer ${seed.user1.toJWT()}` })
+ .set({
+ Authorization: `Bearer ${seed.user1.toJWT()}`,
+ "Content-Type": "application/json",
+ })
.expect("Content-Type", /json/)
.expect(200);
@@ -40,7 +43,8 @@ describe("users", () => {
it("should login user", async () => {
const response = await request(callback)
- .get("/users/login")
+ .post("/users/login")
+ .set({ "Content-Type": "application/json" })
.send({ username: "User1", password: "User1" })
.expect("Content-Type", /json/)
.expect(200);
@@ -54,7 +58,8 @@ describe("users", () => {
it("should not login user with wrong password", async () => {
const response = await request(callback)
- .get("/users/login")
+ .post("/users/login")
+ .set({ "Content-Type": "application/json" })
.send({ username: "User1", password: "asdf" })
.expect(404);
@@ -63,7 +68,8 @@ describe("users", () => {
it("should signup user", async () => {
const response = await request(callback)
- .get("/users/signup")
+ .post("/users/signup")
+ .set({ "Content-Type": "application/json" })
.send({ username: "NUser1", password: "NUser1" })
.expect("Content-Type", /json/)
.expect(200);
@@ -79,7 +85,8 @@ describe("users", () => {
it("should not signup user with duplicate username", async () => {
const response = await request(callback)
- .get("/users/signup")
+ .post("/users/signup")
+ .set({ "Content-Type": "application/json" })
.send({ username: "User1", password: "NUser1" })
.expect(400);
diff --git a/tslint.json b/tslint.json
index c4a23ce..3d1f1a0 100644
--- a/tslint.json
+++ b/tslint.json
@@ -10,6 +10,7 @@
"object-literal-sort-keys": false,
"no-implicit-dependencies": false,
"no-submodule-imports": false,
- "no-this-assignment": false
+ "no-this-assignment": false,
+ "max-classes-per-file": false
}
}
\ No newline at end of file