diff --git a/backend/app.ts b/backend/app.ts index 6565929..0991ddf 100644 --- a/backend/app.ts +++ b/backend/app.ts @@ -11,7 +11,7 @@ import * as winston from 'winston'; import { config } from './config'; import { GetBeat, GetBeatStats } from './endpoints/beat'; import { GetPhone, PostPhone } from './endpoints/phone'; -import { DeleteUser, GetUser, LoginRabbitUser, LoginUser, MW_User, PatchUser, Resource, Topic, VHost } from './endpoints/user'; +import { DeleteUser, GetUser, LoginRabbitUser, LoginUser, MW_User, PatchUser, PostUser, Resource, Topic, VHost } from './endpoints/user'; import { hashPassword, randomPepper, randomString } from './lib/crypto'; import { RabbitMQ } from './lib/rabbit'; import { UserType } from './models/user/user.interface'; @@ -147,6 +147,7 @@ async function run() { // Basic user actions app.get('/user/', MW_User, (req, res) => GetUser(req, res)); + app.post('/user/', MW_User, (req, res) => PostUser(req, res)); app.get('/user/:id', MW_User, (req, res) => GetUser(req, res)); app.patch('/user/:id', MW_User, (req, res) => PatchUser(req, res)); app.delete('/user/:id', MW_User, (req, res) => DeleteUser(req, res)); diff --git a/backend/endpoints/beat.ts b/backend/endpoints/beat.ts index 798341e..c9e25dd 100644 --- a/backend/endpoints/beat.ts +++ b/backend/endpoints/beat.ts @@ -22,8 +22,11 @@ export async function GetBeatStats(req: LivebeatRequest, res: Response) { export async function GetBeat(req: LivebeatRequest, res: Response) { const from: number = Number(req.query.from); const to: number = Number(req.query.to); + const limit: number = Number(req.query.limit || 10000); + const sort: number = Number(req.query.sort || 1); // Either -1 or 1 const phoneId = req.query.phoneId; + // Grab default phone if non was provided. const phone = req.query.phone === undefined ? await Phone.findOne({ user: req.user?._id }) : await Phone.findOne({ _id: phoneId, user: req.user?._id }); let beats: IBeat[] = [] @@ -35,7 +38,7 @@ export async function GetBeat(req: LivebeatRequest, res: Response) { $gte: new Date((from | 0) * 1000), $lte: new Date((to | Date.now() /1000) * 1000) } - }).sort({ _id: 1 }); + }).sort({ _id: sort }).limit(limit); res.status(200).send(beats); } else { res.status(404).send({ message: 'Phone not found' }); diff --git a/backend/endpoints/user.ts b/backend/endpoints/user.ts index 892a9c9..bf831d1 100644 --- a/backend/endpoints/user.ts +++ b/backend/endpoints/user.ts @@ -1,15 +1,22 @@ -import { Request, Response } from "express"; -import { verifyPassword } from "../lib/crypto"; -import { User } from "../models/user/user.model"; -import { sign, decode, verify } from 'jsonwebtoken'; -import { JWT_SECRET, logger, RABBITMQ_URI } from "../app"; +import { Request, Response } from 'express'; +import { decode, sign, verify } from 'jsonwebtoken'; + +import { JWT_SECRET, logger, RABBITMQ_URI } from '../app'; +import * as jwt from 'jsonwebtoken'; +import { config } from '../config'; +import { hashPassword, randomPepper, randomString, verifyPassword } from '../lib/crypto'; import { LivebeatRequest } from '../lib/request'; -import { SchemaTypes } from "mongoose"; -import { Phone } from "../models/phone/phone.model"; -import { UserType } from "../models/user/user.interface"; +import { UserType } from '../models/user/user.interface'; +import { User } from '../models/user/user.model'; export async function GetUser(req: LivebeatRequest, res: Response) { - let user: any = req.user; + let user: any = req.params.id === undefined ? req.user : await User.findById(req.params.id); + + if (user === null) { + res.status(404).send(); + return; + } + user.password = undefined; user.salt = undefined; user.__v = undefined; @@ -17,6 +24,54 @@ export async function GetUser(req: LivebeatRequest, res: Response) { res.status(200).send(user); } +export async function PostUser(req: LivebeatRequest, res: Response) { + // Only admin can create new users + if (req.user?.type !== UserType.ADMIN) { + res.status(401).send({ message: 'Only admins can create new users.' }); + return; + } + + const name = req.body.name; + const password = req.body.password; + const type = req.body.type; + + if (name === undefined || password === undefined || type === undefined) { + res.status(400).send(); + return; + } + + if (!Object.values(UserType).includes(type)) { + res.status(400).send({ message: 'The user type can only be \'guest\', \'user\', \'admin\'.' }); + return; + } + + if (await User.countDocuments({ name }) === 1) { + res.status(409).send(); + return; + } + + const salt = randomString(config.authentification.salt_length); + const brokerToken = randomString(16); + const hashedPassword = await hashPassword(password + salt + randomPepper()).catch(error => { + res.status(400).send({ message: 'Provided password is too weak and cannot be used.' }); + return; + }) as string; + + const newUser = await User.create({ + name, + password: hashedPassword, + salt, + brokerToken, + type, + lastLogin: new Date(0) + }); + + // Create setup token that the new user can use to change his password. + const setupToken = jwt.sign({ setupForUser: newUser._id }, JWT_SECRET, { expiresIn: '1d' }); + + res.status(200).send({ setupToken }); +} + export async function DeleteUser(req: Request, res: Response) { } @@ -72,15 +127,16 @@ export async function LoginUser(req: Request, res: Response) { export async function LoginRabbitUser(req: Request, res: Response) { const username = req.query.username; const password = req.query.password; + res.status(200); if (username === undefined || password === undefined) { - res.status(200).send('deny'); + res.send('deny'); return; } // Check if request comes from backend. Basicly, we permitting ourself to connect with RabbitMQ. if (username === "backend" && password === RABBITMQ_URI.split(':')[2].split('@')[0]) { - res.status(200).send('allow administrator'); + res.send('allow administrator'); return; } @@ -89,22 +145,22 @@ export async function LoginRabbitUser(req: Request, res: Response) { // If we are here, it means we have a non-admin user. if (user === null) { - res.status(200).send('deny'); + res.send('deny'); return; } // Auth token for message broker is stored in plain text since it's randomly generated and only grants access to the broker. if (user.brokerToken === password.toString()) { if (user.type === UserType.ADMIN) { - res.status(200).send('allow administrator'); + res.send('allow administrator'); } else { // Not an admin, grant user privilieges - res.status(200).send('allow user') + res.send('allow user') } return; } - res.status(200).send('deny'); + res.send('deny'); } /** diff --git a/frontend/src/app/admin/admin.component.html b/frontend/src/app/admin/admin.component.html index 49659db..73c4ef2 100644 --- a/frontend/src/app/admin/admin.component.html +++ b/frontend/src/app/admin/admin.component.html @@ -1 +1,14 @@ -

admin works!

+

Create new user

+
+
+
+
+ +

Send the following URL to the new owner of this account in order to setup the new account: + http://localhost:4200/login&setup={{invitationCode}} +

+
\ No newline at end of file diff --git a/frontend/src/app/admin/admin.component.ts b/frontend/src/app/admin/admin.component.ts index 38af3f2..43e559c 100644 --- a/frontend/src/app/admin/admin.component.ts +++ b/frontend/src/app/admin/admin.component.ts @@ -1,5 +1,5 @@ import { AfterContentInit, Component, OnDestroy, OnInit } from '@angular/core'; -import { APIService } from '../api.service'; +import { APIService, UserType } from '../api.service'; @Component({ selector: 'app-admin', @@ -8,6 +8,12 @@ import { APIService } from '../api.service'; }) export class AdminComponent implements AfterContentInit, OnDestroy { + // New user form + newUsername = ''; + newPassword = ''; + newType: UserType; + invitationCode: string; + constructor(public api: APIService) { } ngAfterContentInit(): void { @@ -19,4 +25,8 @@ export class AdminComponent implements AfterContentInit, OnDestroy { this.api.showFilter = true; } + async createUser(): Promise { + this.invitationCode = await this.api.createUser(this.newUsername, this.newPassword, this.newType); + } + } diff --git a/frontend/src/app/api.service.ts b/frontend/src/app/api.service.ts index 218c714..042a159 100644 --- a/frontend/src/app/api.service.ts +++ b/frontend/src/app/api.service.ts @@ -1,8 +1,9 @@ -import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; +import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { MqttService } from 'ngx-mqtt'; import { BehaviorSubject } from 'rxjs'; import * as moment from 'moment'; +import { error } from 'protractor'; export interface ILogin { token: string; @@ -37,6 +38,7 @@ export enum UserType { } export interface IUser { + _id: string; name: string; brokerToken: string; type: UserType; @@ -46,6 +48,7 @@ export interface IUser { } export interface IPhone { + _id: string; androidId: string; displayName: string; modelName: string; @@ -87,6 +90,7 @@ export class APIService { }; phones: IPhone[]; user: IUser = { + _id: '', name: '', brokerToken: '', lastLogin: new Date(2020, 3, 1), @@ -126,6 +130,9 @@ export class APIService { }); } + /* + USER + */ async login(username: string, password: string): Promise { return new Promise(async (resolve, reject) => { this.httpClient.post(this.API_ENDPOINT + '/user/login', { username, password }, { responseType: 'json' }) @@ -147,6 +154,36 @@ export class APIService { }); } + /** + * This function will create a new user. + * @returns Invitation code that must be send to the new accounts user. + */ + async createUser(name: string, password: string, type: UserType): Promise { + return new Promise((resolve, reject) => { + if (this.user.type !== UserType.ADMIN) { + reject('This function can only be executed by admins! You\'re not an admin.'); + return; + } + + if (this.token === undefined) { reject('Login is required to execute this function.'); } + + const headers = new HttpHeaders({ token: this.token }); + + this.fetchingDataEvent.next(true); + + this.httpClient.post(this.API_ENDPOINT + '/user', { name, password, type }, { responseType: 'json', headers }) + .subscribe((res: any) => { + this.fetchingDataEvent.next(false); + console.log('Response:', res); + + resolve(res.setupToken); + }); + }); + } + + /* + BEATS + */ async getBeats(): Promise { return new Promise((resolve, reject) => { if (this.token === undefined) { reject([]); } @@ -174,7 +211,6 @@ export class APIService { }); }); } - async getBeatStats(): Promise { return new Promise((resolve, reject) => { if (this.token === undefined) { reject([]); } @@ -192,19 +228,46 @@ export class APIService { }); } - async getUserInfo(): Promise { - return new Promise((resolve, reject) => { + /** + * Fetch last beat of provided phone. + * @param forPhone Object id of wanted phone (not android id) + */ + async getFetchLastBeat(forPhone: string): Promise { + return new Promise((resolve, reject) => { + if (this.token === undefined) { reject([]); } + + const headers = new HttpHeaders({ token: this.token }); + let params = new HttpParams() + .set('limit', '1') + .set('sort', '-1'); + + if (forPhone !== undefined) { + params = params.set('phoneId', forPhone); + } + + this.httpClient.get(this.API_ENDPOINT + '/beat', { responseType: 'json', headers, params }) + .subscribe(beat => { + resolve(beat[0] as IBeat); + }); + }); + } + + async getUserInfo(userId: string = ''): Promise { + return new Promise((resolve, reject) => { if (this.token === undefined) { reject([]); } const headers = new HttpHeaders({ token: this.token }); this.fetchingDataEvent.next(true); - this.httpClient.get(this.API_ENDPOINT + '/user', { responseType: 'json', headers }) + this.httpClient.get(this.API_ENDPOINT + '/user/' + userId, { responseType: 'json', headers }) .subscribe(user => { this.user = user as IUser; this.fetchingDataEvent.next(false); resolve(user as IUser); + }, () => { + this.fetchingDataEvent.next(false); + resolve(null); }); }); } @@ -271,7 +334,7 @@ export class APIService { /** * Short form for `this.api.beats[this.api.beats.length - 1]` * - * **Notice:** This does not fetch new beats, instead use cached `this.beats` + * **Notice:** This does not fetch new beats, instead uses cached `this.beats`. Use `getFetchLastBeat()` instead. */ getLastBeat(): IBeat { return this.beats[this.beats.length - 1]; diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 9c78870..843accc 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -25,7 +25,7 @@ const routes: Routes = [ component: MapComponent }, { - path: 'user', + path: 'user/:id', component: UserComponent }, { diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index 0d21483..56a392f 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -2,7 +2,7 @@ diff --git a/frontend/src/app/dashboard/dashboard.component.ts b/frontend/src/app/dashboard/dashboard.component.ts index 20cbca1..f18c5c1 100644 --- a/frontend/src/app/dashboard/dashboard.component.ts +++ b/frontend/src/app/dashboard/dashboard.component.ts @@ -75,6 +75,7 @@ export class DashboardComponent implements AfterViewInit { for (let i = 0; i < beats.length; i++) { if (i >= beats.length || (i + 1) >= beats.length) { break; } + if (beats[i].accuracy > this.api.maxAccuracy.value) { continue; } const dist1 = beats[i].coordinate; const dist2 = beats[i + 1].coordinate; tDistance += this.api.distanceInKmBetweenEarthCoordinates(dist1[0], dist1[1], dist2[0], dist2[1]); diff --git a/frontend/src/app/user/user.component.html b/frontend/src/app/user/user.component.html index 7665903..c7b5454 100644 --- a/frontend/src/app/user/user.component.html +++ b/frontend/src/app/user/user.component.html @@ -1,14 +1,18 @@ -
-

{{this.api.user.name}} - Admin B50; +
+

{{this.userInfo.name}} + Admin B50;

Last login was {{lastLogin}}


-

Devices

-
    +

    Devices

    +
    • -

      {{phone.displayName}}

      +

      {{phone.displayName}} last beat was {{ this.lastBeats.get(phone._id) }}

      {{phone.modelName}}

    +
+
+

This user doesn't exist!

+

This user may have never existed

\ No newline at end of file diff --git a/frontend/src/app/user/user.component.scss b/frontend/src/app/user/user.component.scss index d0ad411..e6a7b18 100644 --- a/frontend/src/app/user/user.component.scss +++ b/frontend/src/app/user/user.component.scss @@ -7,6 +7,10 @@ margin-right: 20rem; } +#userNotFound { + text-align: center; +} + .adminState { padding-left: 1rem; font-weight: bolder; @@ -19,4 +23,9 @@ padding: 1.5rem; border-radius: 10px; background-color: $darker; +} + +.lastBeat { + font-weight: lighter; + font-size: 10pt; } \ No newline at end of file diff --git a/frontend/src/app/user/user.component.ts b/frontend/src/app/user/user.component.ts index d0eb4a2..8dd65a9 100644 --- a/frontend/src/app/user/user.component.ts +++ b/frontend/src/app/user/user.component.ts @@ -1,6 +1,7 @@ import { AfterContentInit, Component, OnDestroy, OnInit } from '@angular/core'; -import { APIService } from '../api.service'; +import { APIService, IBeat, IUser } from '../api.service'; import * as moment from 'moment'; +import { ActivatedRoute } from '@angular/router'; @Component({ selector: 'app-user', @@ -10,15 +11,51 @@ import * as moment from 'moment'; export class UserComponent implements AfterContentInit, OnDestroy { lastLogin: string; + lastBeats: Map = new Map(); + show = false; + showDevices = false; + userNotFound = false; - constructor(public api: APIService) { + // User data of selected user. + userInfo: IUser; + + constructor(public api: APIService, private route: ActivatedRoute) { this.api.loginEvent.subscribe(status => { if (status) { this.lastLogin = moment(this.api.user.lastLogin).fromNow(); - console.log(this.lastLogin); + this.route.params.subscribe(async params => { + // Check if selected user is the current logged in user. + if (params.id === this.api.user._id) { + this.userInfo = this.api.user; + this.userNotFound = false; + this.showDevices = true; + } else { + this.userInfo = await this.api.getUserInfo(params.id); + + if (this.userInfo === null) { + this.userNotFound = true; + } else { + this.userNotFound = false; + } + + // Don't show devices if it's not us since we don't have the permissions. + this.showDevices = false; + } + this.show = true; + }); } }); + this.api.phoneEvent.subscribe(async phones => { + // Get last beats for all phone + // tslint:disable-next-line: prefer-for-of + for (let i = 0; i < phones.length; i++) { + const phone = phones[i]; + const beat = await this.api.getFetchLastBeat(phone._id); + this.lastBeats.set(phone._id, moment(beat.createdAt).fromNow()); + } + this.showDevices = true; + }); } ngAfterContentInit(): void { diff --git a/frontend/src/index.html b/frontend/src/index.html index 4a56e41..766f79d 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -7,6 +7,7 @@ + diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 27fda58..f2abef5 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -1,7 +1,5 @@ @import 'themes'; -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;700;900&display=swap'); - /* Color palette */ $primary-color: #1d1d1d; $secondary-color: #2d2d2d; @@ -18,8 +16,57 @@ body { margin: 0; padding: 0; } -/* You can add global styles to this file, and also import other style files */ +/** + * Public styles + */ +form { + & input { + background-color: $darker; + border: none; + border-bottom: 2px solid $foreground-color; + color: $foreground-color; + line-height: 1.4rem; + padding: 0.7rem; + margin-top: 0.4rem; + margin-bottom: 0.4rem; + transition: 0.25s ease; + } + & input:focus { + outline: none; + border-top-left-radius: 5px; + border-top-right-radius: 5px; + background-color: $secondary-color; + } + & button[type=submit] { + margin-top: 2rem; + } +} + +.btn { + border-radius: 5px; + border: 1px solid $foreground-color; + color: $foreground-color; + padding: 0.8rem; + background-color: transparent; + transition: .2s ease; +} + +.btn:hover { + background-color: $primary-color; +} + +.btn-success { + background-color: rgba(0, 195, 0, 0.2) !important; + border-color: rgba(0, 195, 0, 0.6) !important; +} + +.btn-success:hover { + background-color: rgba(0, 195, 0, 0.3) !important; + border-color: rgba(0, 195, 0, 1) !important; +} + +// Controls on the map .mapboxgl-ctrl-top-right { margin-top: 3rem; } \ No newline at end of file