WIP Progress on skeleton of the Vue app.

This commit is contained in:
Jonathan Bernard 2019-03-04 18:46:54 -06:00
parent bcde5fbfc0
commit 1b94245078
37 changed files with 536 additions and 256 deletions

5
web/package-lock.json generated
View File

@ -14670,6 +14670,11 @@
"resolved": "https://registry.npmjs.org/vuex/-/vuex-3.1.0.tgz", "resolved": "https://registry.npmjs.org/vuex/-/vuex-3.1.0.tgz",
"integrity": "sha512-mdHeHT/7u4BncpUZMlxNaIdcN/HIt1GsGG5LKByArvYG/v6DvHcOxvDCts+7SRdCoIRGllK8IMZvQtQXLppDYg==" "integrity": "sha512-mdHeHT/7u4BncpUZMlxNaIdcN/HIt1GsGG5LKByArvYG/v6DvHcOxvDCts+7SRdCoIRGllK8IMZvQtQXLppDYg=="
}, },
"vuex-module-decorators": {
"version": "0.9.8",
"resolved": "https://registry.npmjs.org/vuex-module-decorators/-/vuex-module-decorators-0.9.8.tgz",
"integrity": "sha512-yyh9+0mO7NYZxw5BlXWNA/lHioVOUL0muDpJPL9ssAvje2PHQfFSOCSridK4vA3HasjyaGRtTPJKH7+7UCcpwg=="
},
"w3c-hr-time": { "w3c-hr-time": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz",

View File

@ -21,7 +21,8 @@
"vue-class-component": "^6.0.0", "vue-class-component": "^6.0.0",
"vue-property-decorator": "^7.0.0", "vue-property-decorator": "^7.0.0",
"vue-router": "^3.0.1", "vue-router": "^3.0.1",
"vuex": "^3.0.1" "vuex": "^3.0.1",
"vuex-module-decorators": "^0.9.8"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^23.1.4", "@types/jest": "^23.1.4",

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

View File

@ -1,5 +1,6 @@
@import '~@/styles/vars'; @import '~@/styles/vars';
@import '~@/styles/reset'; @import '~@/styles/reset';
@import '~@/styles/ui-common';
@import url('https://fonts.googleapis.com/css?family=Dosis|Exo+2:600|Exo:700|Josefin+Sans:300|Montserrat:300|Raleway|Teko:500|Titillium+Web:200'); @import url('https://fonts.googleapis.com/css?family=Dosis|Exo+2:600|Exo:700|Josefin+Sans:300|Montserrat:300|Raleway|Teko:500|Titillium+Web:200');
body { body {

View File

@ -1,9 +1,42 @@
import { Component, Vue } from 'vue-property-decorator'; import { Component, Vue, Watch } from 'vue-property-decorator';
import NavBar from '@/views/NavBar.vue'; import NavBar from '@/views/NavBar.vue';
import { logService, LogLevel, ApiLogAppender, ConsoleLogAppender } from '@/services/logging';
import usersStore from '@/store-modules/user';
import { User } from '@/models';
@Component({ @Component({
components: { components: {
NavBar NavBar
} }
}) })
export default class App extends Vue {} export default class App extends Vue {
private consoleLogAppender: ConsoleLogAppender;
private apiLogAppender: ApiLogAppender;
constructor() {
super();
// Setup application logging.
// TODO: prod/dev config settings for logging?
this.consoleLogAppender = new ConsoleLogAppender(LogLevel.ALL);
this.apiLogAppender = new ApiLogAppender(
process.env.VUE_APP_PM_API_BASE + '/log/batch', '', LogLevel.WARN);
this.apiLogAppender.batchSize = 1;
this.apiLogAppender.minimumTimePassedInSec = 5;
logService.ROOT_LOGGER.appenders.push(this.apiLogAppender, this.consoleLogAppender);
}
private get user(): User | null {
return usersStore.user;
}
@Watch('user')
private onUserChanged(val: User | null , oldVal: User | null) {
if (val) { this.apiLogAppender.authToken = val.authToken; }
}
}

View File

@ -0,0 +1,7 @@
import Component from 'vue-class-component';
Component.registerHooks([
'beforeRouteEnter',
'beforeRouteLeave',
'beforeRouteUpdate'
]);

View File

@ -1,18 +1,14 @@
import './class-component-hooks';
import Vue from 'vue'; import Vue from 'vue';
import App from './App.vue'; import App from './App.vue';
import router from './router'; import router from './router';
import store from './store'; import store from './store';
import './registerServiceWorker';
import { LogService, LogLevel, ApiAppender, ConsoleAppender } from './services/logging';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
Vue.component('fa-icon', FontAwesomeIcon); import './registerServiceWorker';
LogService.getRootLogger().appenders.push( Vue.component('fa-icon', FontAwesomeIcon);
// TODO: prod/dev config settings for logging?
new ConsoleAppender(LogLevel.DEBUG),
new ApiAppender(process.env.VUE_APP_PM_API_BASE + '/log/batch', '', LogLevel.WARN)
);
new Vue({ new Vue({
router, router,

40
web/src/models.d.ts vendored Normal file
View File

@ -0,0 +1,40 @@
export interface ApiToken {
id: string;
userId: string;
name: string;
value?: string;
}
export interface LoginSubmit {
email: string;
password: string;
}
export interface Measure {
id: string;
userId: string;
slug: string;
name: string;
description: string;
domainSource?: string;
domainUnites: string;
rangeSource?: string;
rangeUnits: string;
analysis: string[];
}
export interface Measurement {
id: string;
measureId: string;
value: number;
timestamp: Date;
extData: object;
}
export interface User {
id: string;
displayName: string;
email: string;
isAdmin: boolean;
authToken?: string;
}

View File

@ -1,6 +0,0 @@
export default interface ApiToken {
id: string;
userId: string;
name: string;
value?: string;
}

View File

@ -1,6 +0,0 @@
import ApiToken from './api-token';
import Measure from './measure';
import Measurement from './measurement';
import User from './user';
export { ApiToken, Measure, Measurement, User };

View File

@ -1,12 +0,0 @@
export default interface Measure {
id: string;
userId: string;
slug: string;
name: string;
description: string;
domainSource?: string;
domainUnites: string;
rangeSource?: string;
rangeUnits: string;
analysis: string[];
}

View File

@ -1,7 +0,0 @@
export default interface Measurement {
id: string;
measureId: string;
value: number;
timestamp: Date;
extData: object;
}

View File

@ -1,7 +0,0 @@
export default interface User {
id: string;
displayName: string;
email: string;
isAdmin: boolean;
authToken?: string;
}

View File

@ -6,8 +6,7 @@ import Login from '@/views/Login.vue';
import Measures from '@/views/Measures.vue'; import Measures from '@/views/Measures.vue';
import UserAccount from '@/views/UserAccount.vue'; import UserAccount from '@/views/UserAccount.vue';
import QuickPanels from '@/views/QuickPanels.vue'; import QuickPanels from '@/views/QuickPanels.vue';
import userStore from '@/store-modules/user';
import userService from '@/services/user';
Vue.use(Router); Vue.use(Router);
@ -54,10 +53,9 @@ const router = new Router({
// Auth filter // Auth filter
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
if (to.matched.some((record) => record.meta.requiresAuth)) { if (to.matched.some((record) => record.meta.requiresAuth)) {
if (!userService.isAuthed()) { if (!userStore.user || !userStore.user.authToken) {
next({ next({ name: 'login' });
path: '/login', // params: { redirect: to.path } });
params: { nextUrl: to.fullPath } });
} else { next(); } // if authed } else { next(); } // if authed
} else { next(); } // if auth required } else { next(); } // if auth required
}); });

View File

@ -11,7 +11,7 @@ interface ApiMessage {
stacktrace: string; stacktrace: string;
timestamp: string; timestamp: string;
} }
export class ApiAppender implements LogAppender { export class ApiLogAppender implements LogAppender {
public batchSize = 10; public batchSize = 10;
public minimumTimePassedInSec = 60; public minimumTimePassedInSec = 60;
public maximumTimePassedInSec = 120; public maximumTimePassedInSec = 120;
@ -20,17 +20,17 @@ export class ApiAppender implements LogAppender {
private msgBuffer: ApiMessage[] = []; private msgBuffer: ApiMessage[] = [];
private lastSent = 0; private lastSent = 0;
constructor(public readonly apiEndpoint: string, public authToken: string, public threshold?: LogLevel) { constructor(public readonly apiEndpoint: string, public authToken?: string, public threshold?: LogLevel) {
setInterval(this.checkPost, 1000); setInterval(this.checkPost, 1000);
} }
public appendMessage(logger: Logger, msg: LogMessage): void { public appendMessage(msg: LogMessage): void {
if (this.threshold && msg.level < this.threshold) { return; } if (this.threshold && msg.level < this.threshold) { return; }
this.msgBuffer.push({ this.msgBuffer.push({
level: LogLevel[msg.level], level: LogLevel[msg.level],
message: msg.message, message: msg.message,
scope: logger.name, scope: msg.scope,
stacktrace: msg.stacktrace, stacktrace: msg.stacktrace,
timestamp: msg.timestamp.toISOString() timestamp: msg.timestamp.toISOString()
}); });
@ -49,17 +49,18 @@ export class ApiAppender implements LogAppender {
} }
private doPost() { private doPost() {
if (this.msgBuffer.length === 0) { return; } if (this.msgBuffer.length > 0 && this.authToken) {
this.http.post(this.apiEndpoint, this.msgBuffer, this.http.post(this.apiEndpoint, this.msgBuffer,
{ headers: { { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${this.authToken}` 'Authorization': `Bearer ${this.authToken}`
}}); }});
this.lastSent = Date.now(); this.lastSent = Date.now();
this.msgBuffer = []; this.msgBuffer = [];
}
} }
} }
export default ApiAppender; export default ApiLogAppender;

View File

@ -3,16 +3,17 @@ import { LogMessage, LogLevel} from './log-message';
import Logger from './logger'; import Logger from './logger';
import LogAppender from './log-appender'; import LogAppender from './log-appender';
export class ConsoleAppender implements LogAppender { export class ConsoleLogAppender implements LogAppender {
constructor(public threshold?: LogLevel) {} constructor(public threshold?: LogLevel) {}
public appendMessage(logger: Logger, msg: LogMessage): void { public appendMessage(msg: LogMessage): void {
if (this.threshold && msg.level < this.threshold) { return; } if (this.threshold && msg.level < this.threshold) { return; }
let logMethod = console.log; let logMethod = console.log;
switch (msg.level) { switch (msg.level) {
case LogLevel.ALL: logMethod = console.trace; break; case LogLevel.ALL: logMethod = console.log; break;
case LogLevel.DEBUG: logMethod = console.debug; break; case LogLevel.DEBUG: logMethod = console.debug; break;
case LogLevel.LOG: logMethod = console.log; break;
case LogLevel.INFO: logMethod = console.info; break; case LogLevel.INFO: logMethod = console.info; break;
case LogLevel.WARN: logMethod = console.warn; break; case LogLevel.WARN: logMethod = console.warn; break;
case LogLevel.ERROR: case LogLevel.ERROR:
@ -20,11 +21,11 @@ export class ConsoleAppender implements LogAppender {
} }
if (msg.error) { if (msg.error) {
logMethod(logger.name, msg.message, msg.error); logMethod(`[${msg.scope}]:`, msg.message, msg.error);
} else { } else {
logMethod(logger.name, msg.message, msg.stacktrace); logMethod(`[${msg.scope}]:`, msg.message, msg.stacktrace);
} }
} }
} }
export default ConsoleAppender; export default ConsoleLogAppender;

View File

@ -1,5 +1,6 @@
export * from './log-message'; export * from './log-message';
export * from './log-appender'; export * from './log-appender';
export * from './log-service'; export * from './log-service';
export * from './console-appender'; export * from './console-log-appender';
export * from './api-appender'; export * from './api-log-appender';
export * from './logger';

View File

@ -1,5 +1,5 @@
import { LogLevel, LogMessage } from './log-message'; import { LogLevel, LogMessage } from './log-message';
import Logger from './logger'; import Logger from './logger';
export default interface LogAppender { export default interface LogAppender {
appendMessage(logger: Logger, message: LogMessage): void; appendMessage(message: LogMessage): void;
} }

View File

@ -1,6 +1,7 @@
export enum LogLevel { ALL = 0, DEBUG, INFO, WARN, ERROR, FATAL } export enum LogLevel { ALL = 0, DEBUG, LOG, INFO, WARN, ERROR, FATAL }
export interface LogMessage { export interface LogMessage {
scope: string;
level: LogLevel; level: LogLevel;
message: string; message: string;
stacktrace: string; stacktrace: string;

View File

@ -2,14 +2,24 @@ import { LogLevel } from './log-message';
import Logger from './logger'; import Logger from './logger';
import { default as Axios, AxiosInstance } from 'axios'; import { default as Axios, AxiosInstance } from 'axios';
const ROOT_LOGGER_NAME = 'ROOT';
/* tslint:disable:max-classes-per-file*/ /* tslint:disable:max-classes-per-file*/
export class LogService { export class LogService {
public static getRootLogger(): Logger { return Logger.ROOT_LOGGER; } private loggers: { [key: string]: Logger };
private loggers: { [key: string]: Logger } = { };
private http: AxiosInstance = Axios.create(); private http: AxiosInstance = Axios.create();
public get ROOT_LOGGER() {
return this.loggers[ROOT_LOGGER_NAME];
}
public constructor() {
this.loggers = {};
this.loggers[ROOT_LOGGER_NAME] =
new Logger(ROOT_LOGGER_NAME, undefined, LogLevel.ALL);
}
public getLogger(name: string, threshold?: LogLevel): Logger { public getLogger(name: string, threshold?: LogLevel): Logger {
if (this.loggers.hasOwnProperty(name)) { return this.loggers[name]; } if (this.loggers.hasOwnProperty(name)) { return this.loggers[name]; }
@ -17,12 +27,12 @@ export class LogService {
const parentLoggerName = Object.keys(this.loggers) const parentLoggerName = Object.keys(this.loggers)
.filter((n: string) => name.startsWith(n)) .filter((n: string) => name.startsWith(n))
.reduce((acc: string, cur: string) => acc.length > cur.length ? acc : cur); .reduce((acc: string, cur: string) => acc.length > cur.length ? acc : cur, '');
if (parentLoggerName) { if (parentLoggerName) {
parentLogger = this.loggers[parentLoggerName]; parentLogger = this.loggers[parentLoggerName];
} else { } else {
parentLogger = Logger.ROOT_LOGGER; parentLogger = this.ROOT_LOGGER;
} }
this.loggers[name] = parentLogger.createChildLogger(name, threshold); this.loggers[name] = parentLogger.createChildLogger(name, threshold);
@ -30,4 +40,5 @@ export class LogService {
} }
} }
export default new LogService(); export const logService = new LogService();
export default logService;

View File

@ -1,23 +1,29 @@
import { LogMessage, LogLevel } from './log-message'; import { LogMessage, LogLevel } from './log-message';
import LogAppender from './log-appender'; import LogAppender from './log-appender';
export default class Logger { export class Logger {
public static readonly ROOT_LOGGER = new Logger('ROOT', undefined, LogLevel.ALL);
public appenders: LogAppender[] = []; public appenders: LogAppender[] = [];
protected constructor(public readonly name: string, private parentLogger?: Logger, public threshold?: LogLevel) { } public constructor(
public readonly name: string,
private parentLogger?: Logger,
public threshold?: LogLevel) { }
public createChildLogger(name: string, threshold?: LogLevel): Logger { public createChildLogger(name: string, threshold?: LogLevel): Logger {
return new Logger(name, this, threshold); return new Logger(name, this, threshold);
} }
public log(level: LogLevel, message: (Error | string), stacktrace?: string): void { public doLog(level: LogLevel, message: (Error | string), stacktrace?: string): void {
if (level < this.getEffectiveThreshold()) { return; } if (level < this.getEffectiveThreshold()) { return; }
const logMsg: LogMessage = { level, message: '', stacktrace: '', timestamp: new Date() }; const logMsg: LogMessage = {
scope: this.name,
level,
message: '',
stacktrace: '',
timestamp: new Date() };
if (typeof message === 'string') { if (typeof message === 'string') {
logMsg.message = message; logMsg.message = message;
@ -28,29 +34,43 @@ export default class Logger {
logMsg.stacktrace = message.stack == null ? '' : message.stack; logMsg.stacktrace = message.stack == null ? '' : message.stack;
} }
this.appenders.forEach((app) => { this.sendToAppenders(logMsg);
app.appendMessage(this, logMsg); }
});
public trace(message: (Error | string), stacktrace?: string): void {
this.doLog(LogLevel.ALL, message, stacktrace);
} }
public debug(message: (Error | string), stacktrace?: string): void { public debug(message: (Error | string), stacktrace?: string): void {
this.log(LogLevel.DEBUG, message, stacktrace); this.doLog(LogLevel.DEBUG, message, stacktrace);
}
public log(message: string, stacktrace?: string): void {
this.doLog(LogLevel.LOG, message, stacktrace);
} }
public info(message: string, stacktrace?: string): void { public info(message: string, stacktrace?: string): void {
this.log(LogLevel.INFO, message, stacktrace); this.doLog(LogLevel.INFO, message, stacktrace);
} }
public warn(message: string, stacktrace?: string): void { public warn(message: string, stacktrace?: string): void {
this.log(LogLevel.WARN, message, stacktrace); this.doLog(LogLevel.WARN, message, stacktrace);
} }
public error(message: string, stacktrace?: string): void { public error(message: string, stacktrace?: string): void {
this.log(LogLevel.ERROR, message, stacktrace); this.doLog(LogLevel.ERROR, message, stacktrace);
} }
public fatal(message: string, stacktrace?: string): void { public fatal(message: string, stacktrace?: string): void {
this.log(LogLevel.FATAL, message, stacktrace); this.doLog(LogLevel.FATAL, message, stacktrace);
}
protected sendToAppenders(logMsg: LogMessage) {
this.appenders.forEach((app) => {
app.appendMessage(logMsg);
});
if (this.parentLogger) { this.parentLogger.sendToAppenders(logMsg); }
} }
protected getEffectiveThreshold(): LogLevel { protected getEffectiveThreshold(): LogLevel {
@ -63,4 +83,4 @@ export default class Logger {
} }
export default Logger;

View File

@ -1,9 +1,12 @@
import { default as Axios, AxiosInstance } from 'axios'; import { default as Axios, AxiosInstance } from 'axios';
import { ApiToken, Measure, Measurement, User } from '@/models'; import { ApiToken, LoginSubmit, Measure, Measurement, User } from '@/models';
import { Logger, logService } from '@/services/logging';
import merge from 'lodash.merge'; import merge from 'lodash.merge';
export class PmApiClient { export class PmApiClient {
private http: AxiosInstance; private http: AxiosInstance;
private authToken?: string;
private log: Logger;
constructor(apiBase: string) { constructor(apiBase: string) {
this.http = Axios.create({ this.http = Axios.create({
@ -16,161 +19,200 @@ export class PmApiClient {
} }
*/ */
}); });
this.log = logService.getLogger('services/pm-api-client');
this.log.trace('Initialized PmApiClient');
} }
public version(): Promise<any> { public setAuthToken(t: string) { this.authToken = t; }
return this.http
.get('/version') public async version(): Promise<string> {
.then((resp) => resp.data); const resp = await this.http.get('/version');
return resp.data;
} }
public createAuthToken(email: string, password: string): Promise<string> { public async createAuthToken(creds: LoginSubmit)
return this.http : Promise<string> {
.post('/auth-token', { email, password })
.then((resp) => resp.data); const resp = await this.http.post('/auth-token', creds);
return resp.data;
} }
public getUser(authToken: string): Promise<User> { public async getUser(authToken: string): Promise<User> {
return this.http
.get('/user', { headers: { Authorization: 'Bearer ' + authToken }}) const resp = await this.http.get('/user',
.then((resp) => merge(resp.data, { authToken }) ); { headers: { Authorization: 'Bearer ' + authToken }});
return merge(resp.data, { authToken }) ;
} }
public getAllUsers(user: User): Promise<User[]> { public async getAllUsers(): Promise<User[]> {
return this.http const resp = await this.http.get('/users',
.get('/users', { headers: this.authHeader(user) }) { headers: this.authHeader() });
.then((resp) => resp.data);
return resp.data;
} }
public getUserById(curUser: User, reqUserId: string): Promise<User> { public async getUserById(reqUserId: string): Promise<User> {
return this.http const resp = await this.http.get(`/users/${reqUserId}`,
.get(`/users/${reqUserId}`, { headers: this.authHeader(curUser) }) { headers: this.authHeader() });
.then((resp) => resp.data);
return resp.data;
} }
public createUser(curUser: User, newUser: User): Promise<User> { public async createUser(newUser: User): Promise<User> {
return this.http const resp = await this.http.post('/users',
.post('/users', newUser, { headers: this.authHeader(curUser) }) newUser, { headers: this.authHeader() });
.then((resp) => resp.data);
return resp.data;
} }
public deleteUser(curUser: User, toDeleteUserId: string): Promise<boolean> { public async deleteUser(toDeleteUserId: string)
return this.http : Promise<boolean> {
.delete(`/users/${toDeleteUserId}`, { headers: this.authHeader(curUser) })
.then((resp) => true); await this.http.delete(`/users/${toDeleteUserId}`,
{ headers: this.authHeader() });
return true;
} }
public changePwd(user: User, oldPassword: string, newPassword: string): Promise<boolean> { public async changePwd(oldPassword: string, newPassword: string)
return this.http : Promise<boolean> {
.post(
'/change-pwd', await this.http.post('/change-pwd',
{ oldPassword, newPassword }, { oldPassword, newPassword }, { headers: this.authHeader() });
{ headers: this.authHeader(user) })
.then((resp) => true); return true;
} }
public changePwdForUser(user: User, forUserId: string, newPassword: string): Promise<boolean> { public async changePwdForUser(forUserId: string, newPassword: string)
return this.http : Promise<boolean> {
.post(
`/change-pwd/${forUserId}`, await this.http.post(`/change-pwd/${forUserId}`,
{ newPassword }, { newPassword }, { headers: this.authHeader() });
{ headers: this.authHeader(user) })
.then((resp) => true); return true;
} }
public createApiToken(user: User, token: ApiToken): Promise<ApiToken[]> { public async createApiToken(token: ApiToken)
return this.http : Promise<ApiToken[]> {
.post(`/api-tokens`, token, { headers: this.authHeader(user) })
.then((resp) => resp.data); const resp = await this.http.post(`/api-tokens`,
token, { headers: this.authHeader() });
return resp.data;
} }
public getAllApiTokens(user: User): Promise<ApiToken[]> { public async getAllApiTokens(): Promise<ApiToken[]> {
return this.http const resp = await this.http.get('/api-tokens',
.get('/api-tokens', { headers: this.authHeader(user) }) { headers: this.authHeader() });
.then((resp) => resp.data);
return resp.data;
} }
public getApiToken(user: User, tokenId: string): Promise<ApiToken[]> { public async getApiToken(tokenId: string): Promise<ApiToken[]> {
return this.http const resp = await this.http.get(`/api-tokens/${tokenId}`,
.get(`/api-tokens/${tokenId}`, { headers: this.authHeader(user) }) { headers: this.authHeader() });
.then((resp) => resp.data);
return resp.data;
} }
public deleteApiToken(user: User, tokenId: string): Promise<boolean> { public async deleteApiToken(tokenId: string): Promise<boolean> {
return this.http const resp = await this.http.delete(`/api-tokens/${tokenId}`,
.delete(`/api-tokens/${tokenId}`, { headers: this.authHeader(user) }) { headers: this.authHeader() });
.then((resp) => true);
return true;
} }
public getAllMeasures(user: User): Promise<Measure[]> { public async getAllMeasures(): Promise<Measure[]> {
return this.http const resp = await this.http.get(`/measures`,
.get(`/measures`, { headers: this.authHeader(user) }) { headers: this.authHeader() });
.then((resp) => resp.data);
return resp.data;
} }
public createMeasure(user: User, measure: Measure): Promise<Measure> { public async createMeasure(measure: Measure): Promise<Measure> {
return this.http const resp = await this.http.post(`/measures`,
.post(`/measures`, measure, { headers: this.authHeader(user) }) measure, { headers: this.authHeader() });
.then((resp) => resp.data);
return resp.data;
} }
public getMeasure(user: User, slug: string): Promise<Measure> { public async getMeasure(slug: string): Promise<Measure> {
return this.http const resp = await this.http.get(`/measures/${slug}`,
.get(`/measures/${slug}`, { headers: this.authHeader(user) }) { headers: this.authHeader() });
.then((resp) => resp.data);
return resp.data;
} }
public deleteMeasure(user: User, slug: string): Promise<boolean> { public async deleteMeasure(slug: string): Promise<boolean> {
return this.http const resp = await this.http.delete(`/measures/${slug}`,
.delete(`/measures/${slug}`, { headers: this.authHeader(user) }) { headers: this.authHeader() });
.then((resp) => true);
return true;
} }
public getMeasurements(user: User, measureSlug: string): public async getMeasurements(measureSlug: string)
Promise<Measurement[]> { : Promise<Measurement[]> {
return this.http const resp = await this.http.get(`/measure/${measureSlug}`,
.get(`/measure/${measureSlug}`, { headers: this.authHeader(user) }) { headers: this.authHeader() });
.then((resp) => resp.data);
return resp.data;
} }
public createMeasurement(user: User, measureSlug: string, public async createMeasurement(
measurement: Measurement): Promise<Measurement> { measureSlug: string,
return this.http measurement: Measurement)
.post(`/measure/${measureSlug}`, : Promise<Measurement> {
measurement,
{ headers: this.authHeader(user) }) const resp = await this.http.post(`/measure/${measureSlug}`,
.then((resp) => resp.data); measurement, { headers: this.authHeader() });
return resp.data;
} }
public getMeasurement(user: User, measureSlug: string, measurementId: string): public async getMeasurement(
Promise<Measurement> { measureSlug: string,
measurementId: string)
: Promise<Measurement> {
return this.http const resp = await this.http.get(`/measure/${measureSlug}/${measurementId}`,
.get(`/measure/${measureSlug}/${measurementId}`, { headers: this.authHeader() });
{ headers: this.authHeader(user) })
.then((resp) => resp.data); return resp.data;
} }
public updateMeasurement(user: User, measureSlug: string, public async updateMeasurement(
measurement: Measurement): Promise<Measurement> { measureSlug: string,
return this.http measurement: Measurement)
.put(`/measure/${measureSlug}/${measurement.id}`, : Promise<Measurement> {
measurement,
{ headers: this.authHeader(user) }) const resp = await this.http.put(`/measure/${measureSlug}/${measurement.id}`,
.then((resp) => resp.data) ; measurement, { headers: this.authHeader() });
return resp.data;
} }
public deleteMeasurement(user: User, measureSlug: string, measurementId: string): Promise<boolean> { public async deleteMeasurement(
return this.http measureSlug: string,
.delete(`/measure/${measureSlug}/${measurementId}`, measurementId: string)
{headers: this.authHeader(user) }) : Promise<boolean> {
.then((resp) => true);
const resp = await this.http.delete(`/measure/${measureSlug}/${measurementId}`,
{headers: this.authHeader() });
return true;
} }
private authHeader(user: User): { [key: string]: string } { private authHeader(): { [key: string]: string } {
return { Authorization: 'Bearer ' + user.authToken }; if (this.authToken) {
return { Authorization: 'Bearer ' + this.authToken };
} else {
throw new Error('no authenticated user');
}
} }
} }
export default new PmApiClient(process.env.VUE_APP_PM_API_BASE); export const api = new PmApiClient(process.env.VUE_APP_PM_API_BASE);
export default api;

View File

@ -1,29 +0,0 @@
import User from '@/models/user.ts';
import { default as apiClient, PmApiClient } from '@/services/pm-api-client.ts';
export class UserService {
private user?: User = undefined;
constructor(private api: PmApiClient) { }
public isAuthed(): boolean {
return this.user != null && this.user.authToken != null;
}
public getUser(): User | undefined { return this.user; }
public authUser(email: string, password: string): Promise<User> {
return this.api.createAuthToken(email, password)
.then((token) => this.api.getUser(token));
}
public getUserById(reqUserId: string): Promise<User> {
if (this.user == null) {
return Promise.reject(new Error('no currently authenticated user'));
} else {
return this.api.getUserById(this.user, reqUserId);
}
}
}
export default new UserService(apiClient);

View File

@ -0,0 +1,52 @@
import {
Action,
getModule,
Module,
Mutation,
MutationAction,
VuexModule
} from 'vuex-module-decorators';
import { LoginSubmit, User } from '@/models';
import store from '@/store';
import api from '@/services/pm-api-client';
import { logService } from '@/services/logging';
@Module({ namespaced: true, name: 'user', store, dynamic: true })
class UserStoreModule extends VuexModule {
public user: User | null = null;
public users: User[] = [];
private log = logService.getLogger('/store-modules/user');
@Action({ commit: 'SET_USER', rawError: true })
public async login(creds: LoginSubmit) {
const authToken = await api.createAuthToken(creds);
const user = await api.getUser(authToken);
this.log.trace('User login successful.');
api.setAuthToken(authToken);
return user;
}
@MutationAction({ mutate: ['users'] })
public async fetchAllUsers() {
const users = await api.getAllUsers();
return { users };
}
@MutationAction({ mutate: ['users'] })
public async createUser(user: User) {
const newUser = await api.createUser(user);
return { users: this.users.concat([user]) };
}
@MutationAction({ mutate: ['users'] })
public async deleteUser(userId: string) {
await api.deleteUser(userId);
return { users: this.users.filter((u) => u.id !== userId) };
}
@Mutation private SET_USER(user: User) { this.user = user; }
}
export default getModule(UserStoreModule);

View File

@ -4,13 +4,8 @@ import Vuex from 'vuex';
Vue.use(Vuex); Vue.use(Vuex);
export default new Vuex.Store({ export default new Vuex.Store({
state: { state: {},
mutations: {},
}, actions: {},
mutations: { modules: { }
},
actions: {
}
}); });

View File

@ -0,0 +1,16 @@
.btn,
.btn-action {
border: 0;
border-radius: .25em;
cursor: pointer;
padding: .5em 1em;
}
.btn-action {
background-color: $color2;
position: relative;
&:hover {
background-color: darken($color2, 5%);
}
}

View File

@ -14,8 +14,8 @@ $color3: rgba(255, 253, 130, 1);
$color4: rgba(255, 155, 113, 1); $color4: rgba(255, 155, 113, 1);
$color5: rgba(232, 72, 85, 1); $color5: rgba(232, 72, 85, 1);
$bg-primary: #333; $fg-primary: #222;
$fg-primary: #d8d8e0; $bg-primary: #f0f0f0;
$screen-x-small: 320px; $screen-x-small: 320px;
$screen-small: 640px; $screen-small: 640px;

1
web/src/types/index.d.ts vendored Normal file
View File

@ -0,0 +1 @@
export * from './nav-next';

4
web/src/types/nav-next.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
import { Vue } from 'vue-property-decorator';
import { RawLocation } from 'vue-router';
export type NavNext = (to?: RawLocation | false | ((vm: Vue) => any) | void) => void;

25
web/src/views/Login.vue Normal file
View File

@ -0,0 +1,25 @@
<template>
<div class="login">
<h1>Personal Measure</h1>
<form @submit.prevent=login() class=login-form v-show="!waiting">
<input type=text
name=email
v-model="loginForm.email"
placeholder="email address">
</input>
<input type=password
name=password
v-model="loginForm.password"
placeholder="password">
</input>
<button class="btn-action">Login</button>
<div class=flash>{{flashMessage}}</div>
</form>
<div class=loading v-show="waiting">
<img src="/img/mickey-open-door.gif">
<div>logging you in...</div>
</div>
</div>
</template>
<script lang="ts" src="./login.ts"></script>
<style lang="scss" src="./login.scss"></style>

View File

@ -1,5 +1,5 @@
<template> <template>
<nav v-bind:class='collapsed ? "collapsed" : "expanded"'> <nav v-if='user' v-bind:class='collapsed ? "collapsed" : "expanded"'>
<h1 class=logo> <h1 class=logo>
<span class=expanded>Personal Measure</span> <span class=expanded>Personal Measure</span>
<span class=collapsed>PM</span> <span class=collapsed>PM</span>

View File

@ -1,6 +1,17 @@
import { Component, Vue } from 'vue-property-decorator'; import { Component, Vue } from 'vue-property-decorator';
import { Route, RawLocation } from 'vue-router';
import userStore from '@/store-modules/user';
import { NavNext } from '@/types';
@Component({ @Component({})
components: { } export default class Dashboard extends Vue {
})
export default class Home extends Vue {} public get user() {
return userStore.user;
}
public beforeRouteEnter<V extends Vue>( to: Route, from: Route, next: NavNext): void {
next();
}
}

View File

@ -1,4 +0,0 @@
import { Component, Vue } from 'vue-property-decorator';
@Component({})
export default class Dashboard extends Vue {}

36
web/src/views/login.scss Normal file
View File

@ -0,0 +1,36 @@
@import '~@/styles/vars';
.login {
margin: 4rem auto;
text-align: center;
width: 36rem;
.login-form {
align-items: center;
display: flex;
flex-direction: column;
margin-top: 2em;
input {
border: solid thin lighten($fg-primary, 25%);
border-radius: .25em;
font-size: 150%;
margin-bottom: 1em;
padding: .5em;
}
.btn-action {
font-size: 150%;
}
}
.loading {
margin: 1em 0;
img {
border-radius: 10em;
width: 32em;
}
}
}

40
web/src/views/login.ts Normal file
View File

@ -0,0 +1,40 @@
import { Route } from 'vue-router';
import { Component, Prop, Vue, Watch } from 'vue-property-decorator';
import { LoginSubmit } from '@/models';
import userStore from '@/store-modules/user';
@Component({})
export default class Login extends Vue {
private loginForm: LoginSubmit = {
email: '',
password: ''
};
private waiting = false;
private flashMessage = '';
private redirect: string | undefined = undefined;
public async login() {
this.waiting = true;
this.flashMessage = '';
try {
await userStore.login(this.loginForm);
this.$router.push({ path: this.redirect || '/' });
} catch (e) {
if (e.response.status === 401) {
this.flashMessage = 'invlid username or password';
}
}
this.waiting = false;
}
/*
@Watch('$route', { immediate: true })
private onRouteChange(route: Route) {
this.redirect = route.query && route.query.redirect as string;
}
*/
}
// TODO: styling of flash message

View File

@ -2,6 +2,7 @@ import { Component, Vue } from 'vue-property-decorator';
import { library } from '@fortawesome/fontawesome-svg-core'; import { library } from '@fortawesome/fontawesome-svg-core';
import { faAngleDoubleLeft, faAngleDoubleRight, import { faAngleDoubleLeft, faAngleDoubleRight,
faHome, faPencilRuler, faThLarge, faUser } from '@fortawesome/free-solid-svg-icons'; faHome, faPencilRuler, faThLarge, faUser } from '@fortawesome/free-solid-svg-icons';
import userStore from '@/store-modules/user';
// import UiIconButton from 'keen-ui/src/UiIconButton.vue'; // import UiIconButton from 'keen-ui/src/UiIconButton.vue';
library.add(faAngleDoubleLeft, faAngleDoubleRight, faHome, faPencilRuler, faThLarge, faUser); library.add(faAngleDoubleLeft, faAngleDoubleRight, faHome, faPencilRuler, faThLarge, faUser);
@ -23,4 +24,8 @@ export default class NavBar extends Vue {
this.collapsed = !this.collapsed; this.collapsed = !this.collapsed;
return this.collapsed; return this.collapsed;
} }
public get user() {
return userStore.user;
}
} }

8
web/vue.config.js Normal file
View File

@ -0,0 +1,8 @@
module.exports = {
devServer: {
proxy: {
'/api': { target: 'http://localhost:8081' }
},
disableHostCheck: true
}
};