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
+
\ 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