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 ( - <> - -
-

Login

- - - - - - -
- -
-
-
- - ); +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 ( + <> + +
+

Login

+ + + + + + +
+
{this.props.error}
+ +
+
+
+ + ); + } +} + +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