New users can now be created.
- Note: Login is buggy with a brand new account
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1 +1,14 @@
|
||||
<p>admin works!</p>
|
||||
<h2>Create new user</h2>
|
||||
<form (ngSubmit)="createUser()" #form="ngForm">
|
||||
<input name="username" [(ngModel)]="newUsername" placeholder="Username"><br>
|
||||
<input name="password" [(ngModel)]="newPassword" type="password" placeholder="Password"><br>
|
||||
<select name="type" [(ngModel)]="newType">
|
||||
<option value="guest">Guest</option>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select><br>
|
||||
<button class="btn btn-success" type="submit">Create user</button>
|
||||
<p *ngIf="this.invitationCode !== undefined">Send the following URL to the new owner of this account in order to setup the new account:
|
||||
<a [href]="'http://localhost:4200/login&setup=' + invitationCode">http://localhost:4200/login&setup={{invitationCode}}</a>
|
||||
</p>
|
||||
</form>
|
||||
@@ -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<void> {
|
||||
this.invitationCode = await this.api.createUser(this.newUsername, this.newPassword, this.newType);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<ILogin> {
|
||||
return new Promise<ILogin>(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<string> {
|
||||
return new Promise<string>((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<IBeat[]> {
|
||||
return new Promise<IBeat[]>((resolve, reject) => {
|
||||
if (this.token === undefined) { reject([]); }
|
||||
@@ -174,7 +211,6 @@ export class APIService {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getBeatStats(): Promise<IBeatStats> {
|
||||
return new Promise<IBeatStats>((resolve, reject) => {
|
||||
if (this.token === undefined) { reject([]); }
|
||||
@@ -192,19 +228,46 @@ export class APIService {
|
||||
});
|
||||
}
|
||||
|
||||
async getUserInfo(): Promise<IUser> {
|
||||
return new Promise<IUser>((resolve, reject) => {
|
||||
/**
|
||||
* Fetch last beat of provided phone.
|
||||
* @param forPhone Object id of wanted phone (not android id)
|
||||
*/
|
||||
async getFetchLastBeat(forPhone: string): Promise<IBeat> {
|
||||
return new Promise<IBeat>((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<IUser | null> {
|
||||
return new Promise<IUser | null>((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];
|
||||
|
||||
@@ -25,7 +25,7 @@ const routes: Routes = [
|
||||
component: MapComponent
|
||||
},
|
||||
{
|
||||
path: 'user',
|
||||
path: 'user/:id',
|
||||
component: UserComponent
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<ul class="navbar">
|
||||
<li><a [routerLink]="['/dashboard']" routerLinkActive="router-link-active" >Dashboard</a></li>
|
||||
<li><a [routerLink]="['/map']" routerLinkActive="router-link-active" >Map</a></li>
|
||||
<li class="navbar-right"><a [routerLink]="['/user']" routerLinkActive="router-link-active" >{{this.api.username}}</a></li>
|
||||
<li class="navbar-right"><a [routerLink]="['/user', this.api.user._id]" routerLinkActive="router-link-active" >{{this.api.username}}</a></li>
|
||||
<li class="navbar-right"><a [routerLink]="['/admin']" routerLinkActive="router-link-active" *ngIf="this.api.user.type == 'admin'">Admin settings</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
<div id="user">
|
||||
<h1>{{this.api.user.name}}
|
||||
<span class="adminState" *ngIf="this.api.user.type == 'admin'">Admin B50;</span>
|
||||
<div id="user" *ngIf="show">
|
||||
<h1>{{this.userInfo.name}}
|
||||
<span class="adminState" *ngIf="this.userInfo.type == 'admin'">Admin B50;</span>
|
||||
</h1>
|
||||
<p class="lastLogin">Last login was {{lastLogin}}</p><br>
|
||||
|
||||
<h2>Devices</h2>
|
||||
<ul class="phoneListing">
|
||||
<h2 *ngIf="showDevices">Devices</h2>
|
||||
<ul class="phoneListing" *ngIf="showDevices">
|
||||
<li *ngFor="let phone of this.api.phones">
|
||||
<h2>{{phone.displayName}}</h2>
|
||||
<h2>{{phone.displayName}} <span class="lastBeat">last beat was {{ this.lastBeats.get(phone._id) }}</span></h2>
|
||||
<p>{{phone.modelName}}</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div *ngIf="userNotFound" id="userNotFound">
|
||||
<h1>This user doesn't exist!</h1>
|
||||
<p>This user may have never existed</p>
|
||||
</div>
|
||||
@@ -7,6 +7,10 @@
|
||||
margin-right: 20rem;
|
||||
}
|
||||
|
||||
#userNotFound {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.adminState {
|
||||
padding-left: 1rem;
|
||||
font-weight: bolder;
|
||||
@@ -20,3 +24,8 @@
|
||||
border-radius: 10px;
|
||||
background-color: $darker;
|
||||
}
|
||||
|
||||
.lastBeat {
|
||||
font-weight: lighter;
|
||||
font-size: 10pt;
|
||||
}
|
||||
@@ -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,14 +11,50 @@ import * as moment from 'moment';
|
||||
export class UserComponent implements AfterContentInit, OnDestroy {
|
||||
|
||||
lastLogin: string;
|
||||
lastBeats: Map<string, string> = new Map<string, string>();
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;700;900&display=swap" rel="stylesheet">
|
||||
<meta name="theme-color" content="#1976d2">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user