WIP UI implementation: fixed store dependencies, login flow.

This commit is contained in:
Jonathan Bernard 2019-03-08 03:03:47 -06:00
parent 9a9fa7c5d9
commit 12b2e5edca
17 changed files with 145 additions and 161 deletions

View File

@ -1,9 +1,7 @@
import { Component, Vue, Watch } 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 { logService, LogLevel, ApiLogAppender, ConsoleLogAppender } from '@/services/logging';
import authStore from '@/store-modules/auth'; import { authStore } from '@/store';
import measureStore from '@/store-modules/measure';
import userStore from '@/store-modules/user';
import { User } from '@/models'; import { User } from '@/models';
@Component({ @Component({
@ -34,16 +32,12 @@ export default class App extends Vue {
authStore.findLocalToken().catch(() => {}); authStore.findLocalToken().catch(() => {});
} }
private get authToken(): string | undefined { private get authToken(): string | null {
return authStore.authToken; return authStore.authToken;
} }
@Watch('authToken') @Watch('authToken')
private onAuthTokenChange(val: string | undefined , oldVal: string | undefined) { private onAuthTokenChange(val: string | undefined , oldVal: string | undefined) {
if (val) { if (val) { this.apiLogAppender.authToken = val; }
this.apiLogAppender.authToken = val;
userStore.fetchUser();
measureStore.fetchAllMeasures();
}
} }
} }

View File

@ -2,8 +2,8 @@ import './class-component-hooks';
import Vue from 'vue'; import Vue from 'vue';
import App from './App.vue'; import App from './App.vue';
import { store } from './store';
import router from './router'; import router from './router';
import store from './store';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import './registerServiceWorker'; import './registerServiceWorker';

2
web/src/models.d.ts vendored
View File

@ -3,6 +3,8 @@ export interface ApiToken {
userId: string; userId: string;
name: string; name: string;
value?: string; value?: string;
created: Date;
expires?: Date;
} }
export interface LoginSubmit { export interface LoginSubmit {

View File

@ -1,12 +1,11 @@
import Vue from 'vue'; import Vue from 'vue';
import { default as Router, Route } from 'vue-router'; import { default as Router, Route } from 'vue-router';
import Dashboard from '@/views/Dashboard.vue';
import Login from '@/views/Login.vue'; 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 authStore from '@/store-modules/auth'; import { authStore } from '@/store';
Vue.use(Router); Vue.use(Router);
@ -23,12 +22,6 @@ const router = new Router({
name: 'login', name: 'login',
component: Login component: Login
}, },
{
path: '/dashboard',
name: 'dashboard',
component: Dashboard,
meta: { requiresAuth: true }
},
{ {
path: '/measures', path: '/measures',
name: 'measures', name: 'measures',

View File

@ -2,7 +2,6 @@ import { default as Axios, AxiosInstance } from 'axios';
import { ApiToken, LoginSubmit, Measure, Measurement, User } from '@/models'; import { ApiToken, LoginSubmit, Measure, Measurement, User } from '@/models';
import { Logger, logService } from '@/services/logging'; import { Logger, logService } from '@/services/logging';
import merge from 'lodash.merge'; import merge from 'lodash.merge';
import authStore from '@/store-modules/auth';
export class PmApiClient { export class PmApiClient {
private http: AxiosInstance; private http: AxiosInstance;
@ -24,6 +23,15 @@ export class PmApiClient {
this.log.trace('Initialized PmApiClient'); this.log.trace('Initialized PmApiClient');
} }
public setAuthToken(authToken: string) {
/*tslint:disable:no-string-literal*/
this.http.defaults.headers.common['Authorization'] = `Bearer ${authToken}`;
}
public clearAuthToken() {
this.http.defaults.headers.common['Authorization'] = '';
}
public async version(): Promise<string> { public async version(): Promise<string> {
const resp = await this.http.get('/version'); const resp = await this.http.get('/version');
return resp.data; return resp.data;
@ -33,129 +41,97 @@ export class PmApiClient {
: Promise<string> { : Promise<string> {
const resp = await this.http.post('/auth-token', creds); const resp = await this.http.post('/auth-token', creds);
this.setAuthToken(resp.data);
return resp.data; return resp.data;
} }
public async getUser(): Promise<User> { public async getUser(): Promise<User> {
const resp = await this.http.get('/user');
const resp = await this.http.get('/user', return resp.data;
{ headers: this.authHeader() });
return merge(resp.data) ;
} }
public async getAllUsers(): Promise<User[]> { public async getAllUsers(): Promise<User[]> {
const resp = await this.http.get('/users', const resp = await this.http.get('/users');
{ headers: this.authHeader() });
return resp.data; return resp.data;
} }
public async getUserById(reqUserId: string): Promise<User> { public async getUserById(reqUserId: string): Promise<User> {
const resp = await this.http.get(`/users/${reqUserId}`, const resp = await this.http.get(`/users/${reqUserId}`);
{ headers: this.authHeader() });
return resp.data; return resp.data;
} }
public async createUser(newUser: User): Promise<User> { public async createUser(newUser: User): Promise<User> {
const resp = await this.http.post('/users', const resp = await this.http.post('/users', newUser);
newUser, { headers: this.authHeader() });
return resp.data; return resp.data;
} }
public async deleteUser(toDeleteUserId: string) public async deleteUser(toDeleteUserId: string)
: Promise<boolean> { : Promise<boolean> {
await this.http.delete(`/users/${toDeleteUserId}`, await this.http.delete(`/users/${toDeleteUserId}`);
{ headers: this.authHeader() });
return true; return true;
} }
public async changePwd(oldPassword: string, newPassword: string) public async changePwd(oldPassword: string, newPassword: string)
: Promise<boolean> { : Promise<boolean> {
await this.http.post('/change-pwd', await this.http.post('/change-pwd', { oldPassword, newPassword });
{ oldPassword, newPassword }, { headers: this.authHeader() });
return true; return true;
} }
public async changePwdForUser(forUserId: string, newPassword: string) public async changePwdForUser(forUserId: string, newPassword: string)
: Promise<boolean> { : Promise<boolean> {
await this.http.post(`/change-pwd/${forUserId}`, await this.http.post(`/change-pwd/${forUserId}`, { newPassword });
{ newPassword }, { headers: this.authHeader() });
return true; return true;
} }
public async createApiToken(token: ApiToken) public async createApiToken(token: ApiToken)
: Promise<ApiToken[]> { : Promise<ApiToken[]> {
const resp = await this.http.post(`/api-tokens`, const resp = await this.http.post(`/api-tokens`, token);
token, { headers: this.authHeader() });
return resp.data; return resp.data;
} }
public async getAllApiTokens(): Promise<ApiToken[]> { public async getAllApiTokens(): Promise<ApiToken[]> {
const resp = await this.http.get('/api-tokens', const resp = await this.http.get('/api-tokens');
{ headers: this.authHeader() });
return resp.data; return resp.data;
} }
public async getApiToken(tokenId: string): Promise<ApiToken[]> { public async getApiToken(tokenId: string): Promise<ApiToken[]> {
const resp = await this.http.get(`/api-tokens/${tokenId}`, const resp = await this.http.get(`/api-tokens/${tokenId}`);
{ headers: this.authHeader() });
return resp.data; return resp.data;
} }
public async deleteApiToken(tokenId: string): Promise<boolean> { public async deleteApiToken(tokenId: string): Promise<boolean> {
const resp = await this.http.delete(`/api-tokens/${tokenId}`, const resp = await this.http.delete(`/api-tokens/${tokenId}`);
{ headers: this.authHeader() });
return true; return true;
} }
public async getAllMeasures(): Promise<Measure[]> { public async getAllMeasures(): Promise<Measure[]> {
const resp = await this.http.get(`/measures`, const resp = await this.http.get(`/measures`);
{ headers: this.authHeader() });
return resp.data; return resp.data;
} }
public async createMeasure(measure: Measure): Promise<Measure> { public async createMeasure(measure: Measure): Promise<Measure> {
const resp = await this.http.post(`/measures`, const resp = await this.http.post(`/measures`);
measure, { headers: this.authHeader() });
return resp.data; return resp.data;
} }
public async getMeasure(slug: string): Promise<Measure> { public async getMeasure(slug: string): Promise<Measure> {
const resp = await this.http.get(`/measures/${slug}`, const resp = await this.http.get(`/measures/${slug}`);
{ headers: this.authHeader() });
return resp.data; return resp.data;
} }
public async deleteMeasure(slug: string): Promise<boolean> { public async deleteMeasure(slug: string): Promise<boolean> {
const resp = await this.http.delete(`/measures/${slug}`, const resp = await this.http.delete(`/measures/${slug}`);
{ headers: this.authHeader() });
return true; return true;
} }
public async getMeasurements(measureSlug: string) public async getMeasurements(measureSlug: string)
: Promise<Measurement[]> { : Promise<Measurement[]> {
const resp = await this.http.get(`/measure/${measureSlug}`, const resp = await this.http.get(`/measure/${measureSlug}`);
{ headers: this.authHeader() });
return resp.data; return resp.data;
} }
@ -164,9 +140,7 @@ export class PmApiClient {
measurement: Measurement) measurement: Measurement)
: Promise<Measurement> { : Promise<Measurement> {
const resp = await this.http.post(`/measure/${measureSlug}`, const resp = await this.http.post(`/measure/${measureSlug}`, measurement);
measurement, { headers: this.authHeader() });
return resp.data; return resp.data;
} }
@ -175,9 +149,8 @@ export class PmApiClient {
measurementId: string) measurementId: string)
: Promise<Measurement> { : Promise<Measurement> {
const resp = await this.http.get(`/measure/${measureSlug}/${measurementId}`, const resp = await this.http
{ headers: this.authHeader() }); .get(`/measure/${measureSlug}/${measurementId}`);
return resp.data; return resp.data;
} }
@ -186,9 +159,8 @@ export class PmApiClient {
measurement: Measurement) measurement: Measurement)
: Promise<Measurement> { : Promise<Measurement> {
const resp = await this.http.put(`/measure/${measureSlug}/${measurement.id}`, const resp = await this.http
measurement, { headers: this.authHeader() }); .put(`/measure/${measureSlug}/${measurement.id}`, measurement);
return resp.data; return resp.data;
} }
@ -197,19 +169,10 @@ export class PmApiClient {
measurementId: string) measurementId: string)
: Promise<boolean> { : Promise<boolean> {
const resp = await this.http.delete(`/measure/${measureSlug}/${measurementId}`, const resp = await this.http
{headers: this.authHeader() }); .delete(`/measure/${measureSlug}/${measurementId}`);
return true; return true;
} }
private authHeader(): { [key: string]: string } {
if (authStore.authToken) {
return { Authorization: 'Bearer ' + authStore.authToken };
} else {
throw new Error('no authenticated user');
}
}
} }
export const api = new PmApiClient(process.env.VUE_APP_PM_API_BASE); export const api = new PmApiClient(process.env.VUE_APP_PM_API_BASE);

View File

@ -0,0 +1,33 @@
import {
Action,
Module,
Mutation,
MutationAction,
VuexModule
} from 'vuex-module-decorators';
import api from '@/services/pm-api-client';
import { logService } from '@/services/logging';
import { ApiToken } from '@/models';
const log = logService.getLogger('/store-modules/api-tokens');
@Module({ namespaced: true, name: 'apiToken' })
export class ApiTokenStoreModule extends VuexModule {
public apiTokens: ApiToken[] = [];
@MutationAction({ mutate: ['apiTokens'] })
public async fetchAllApiTokens() {
return { apiTokens: await api.getAllApiTokens() };
}
@MutationAction({ mutate: ['apiTokens'] })
public async createApiToken(newToken: ApiToken) {
return { apiTokens: await api.createApiToken(newToken) };
}
@MutationAction({ mutate: ['apiTokens'] })
public async deleteApiToken(tokenId: string) {
api.deleteApiToken(tokenId);
return { apiTokens: this.apiTokens.filter((tok) => tok.id !== tokenId) };
}
}

View File

@ -1,25 +1,31 @@
import { import {
Action, Action,
getModule,
Module, Module,
Mutation, Mutation,
MutationAction,
VuexModule VuexModule
} from 'vuex-module-decorators'; } from 'vuex-module-decorators';
import { LoginSubmit } from '@/models';
import store from '@/store';
import api from '@/services/pm-api-client';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { LoginSubmit } from '@/models';
import api from '@/services/pm-api-client';
import { logService } from '@/services/logging';
import { userStore, measureStore } from '@/store';
const COOKIE_TOKEN = 'pm-api-cookie-token'; const COOKIE_TOKEN = 'pm-api-cookie-token';
@Module({ namespaced: true, name: 'auth', store, dynamic: true }) const log = logService.getLogger('/store-modules/auth');
class AuthStoreModule extends VuexModule {
public authToken: string | undefined = undefined; @Module({ namespaced: true, name: 'auth' })
export class AuthStoreModule extends VuexModule {
public authToken: string | null = null;
@Action({ commit: 'SET_TOKEN', rawError: true }) @Action({ commit: 'SET_TOKEN', rawError: true })
public async login(creds: LoginSubmit) { public async login(creds: LoginSubmit) {
return await api.createAuthToken(creds); const authToken = await api.createAuthToken(creds); // API will cache this token
Cookies.set(COOKIE_TOKEN, authToken, { secure: true });
await userStore.fetchUser();
await measureStore.fetchAllMeasures();
log.trace('User login successful.');
return authToken;
} }
@Action({ commit: 'SET_TOKEN', rawError: true }) @Action({ commit: 'SET_TOKEN', rawError: true })
@ -29,17 +35,7 @@ class AuthStoreModule extends VuexModule {
else { throw new Error('No auth token stored as a cookie.'); } else { throw new Error('No auth token stored as a cookie.'); }
} }
@Action({ commit: 'SET_TOKEN' }) @Mutation private SET_TOKEN(t: string) {
public setToken(t: string) {
// TODO: set the expires based on the JWT token expiry?
Cookies.set(COOKIE_TOKEN, t, { secure: true });
return t;
}
@Mutation public SET_TOKEN(t: string) {
this.authToken = t; this.authToken = t;
} }
} }
export const authStore = getModule(AuthStoreModule);
export default authStore;

View File

@ -8,12 +8,11 @@ import {
} from 'vuex-module-decorators'; } from 'vuex-module-decorators';
import { keyBy } from 'lodash'; import { keyBy } from 'lodash';
import { User, Measure } from '@/models'; import { User, Measure } from '@/models';
import store from '@/store';
import api from '@/services/pm-api-client'; import api from '@/services/pm-api-client';
import { logService } from '@/services/logging'; import { logService } from '@/services/logging';
@Module({ namespaced: true, name: 'measure', store, dynamic: true }) @Module({ namespaced: true, name: 'measure' })
class MeasureStoreModule extends VuexModule { export class MeasureStoreModule extends VuexModule {
public measures: { [key: string]: Measure } = {}; public measures: { [key: string]: Measure } = {};
private log = logService.getLogger('/store-modules/measure'); private log = logService.getLogger('/store-modules/measure');
@ -33,5 +32,3 @@ class MeasureStoreModule extends VuexModule {
this.measures[measure.slug] = measure; this.measures[measure.slug] = measure;
} }
} }
export default getModule(MeasureStoreModule);

View File

@ -1,24 +1,21 @@
import { import {
Action,
getModule,
Module, Module,
Mutation,
MutationAction, MutationAction,
VuexModule VuexModule
} from 'vuex-module-decorators'; } from 'vuex-module-decorators';
import { LoginSubmit, User } from '@/models'; import { LoginSubmit, User } from '@/models';
import api from '@/services/pm-api-client'; import api from '@/services/pm-api-client';
import store from '@/store';
import { logService } from '@/services/logging'; import { logService } from '@/services/logging';
import { authStore } from '@/store';
@Module({ namespaced: true, name: 'user', store, dynamic: true }) const log = logService.getLogger('/store-modules/user');
class UserStoreModule extends VuexModule {
@Module({ namespaced: true, name: 'user' })
export class UserStoreModule extends VuexModule {
public user: User | null = null; public user: User | null = null;
public users: User[] = []; public users: User[] = [];
private log = logService.getLogger('/store-modules/user'); @MutationAction({ mutate: ['user'], rawError: true })
@MutationAction({ mutate: ['user']})
public async fetchUser() { public async fetchUser() {
return { user: await api.getUser() }; return { user: await api.getUser() };
} }
@ -40,6 +37,3 @@ class UserStoreModule extends VuexModule {
return { users: this.users.filter((u) => u.id !== userId) }; return { users: this.users.filter((u) => u.id !== userId) };
} }
} }
export const userStore = getModule(UserStoreModule);
export default userStore;

View File

@ -1,11 +1,26 @@
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import { getModule } from 'vuex-module-decorators';
import { ApiTokenStoreModule } from './store-modules/api-token';
import { AuthStoreModule } from './store-modules/auth';
import { MeasureStoreModule } from './store-modules/measure';
import { UserStoreModule } from './store-modules/user';
Vue.use(Vuex); Vue.use(Vuex);
export default new Vuex.Store({ export const store = new Vuex.Store({
state: {}, state: {},
mutations: {}, mutations: {},
actions: {}, actions: {},
modules: { } modules: {
apiToken: ApiTokenStoreModule,
auth: AuthStoreModule,
measure: MeasureStoreModule,
user: UserStoreModule
}
}); });
export const apiTokenStore = getModule(ApiTokenStoreModule, store);
export const authStore = getModule(AuthStoreModule, store);
export const measureStore = getModule(MeasureStoreModule, store);
export const userStore = getModule(UserStoreModule, store);

View File

@ -1,6 +0,0 @@
<template>
<div class="dashboard">
<h1>Dashboard</h1>
</div>
</template>
<script lang="ts" src="./dashboard.ts"></script>

View File

@ -1,9 +1,13 @@
<template> <template>
<div class=user-account> <div class=user-account>
<h1>Your Account</h1> <h1>Your Account</h1>
<section class=user-info> <fieldset>
<h2>About You</h2> <legend>About You</legend>
</section> <label for=name>Name: </label>
<input name=name type=text :value="user.displayName"></input>
<label for=name>Email Address: </label>
<input name=name type=text :value="user.email"></input>
</fieldset>
<section class=api-tokens> <section class=api-tokens>
<h2>API Tokens</h2> <h2>API Tokens</h2>
</section> </section>

View File

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

View File

@ -1,7 +1,10 @@
import { Route } from 'vue-router'; import { Route } from 'vue-router';
import { Component, Prop, Vue, Watch } from 'vue-property-decorator'; import { Component, Prop, Vue, Watch } from 'vue-property-decorator';
import { LoginSubmit } from '@/models'; import { LoginSubmit } from '@/models';
import authStore from '@/store-modules/auth'; import { authStore } from '@/store';
import { logService } from '@/services/logging';
const log = logService.getLogger('/views/login');
@Component({}) @Component({})
export default class Login extends Vue { export default class Login extends Vue {
@ -22,9 +25,11 @@ export default class Login extends Vue {
await authStore.login(this.loginForm); await authStore.login(this.loginForm);
this.$router.push({ path: this.redirect || '/' }); this.$router.push({ path: this.redirect || '/' });
} catch (e) { } catch (e) {
console.log(e); if (e.response && e.response.status === 401) {
if (e.response.status === 401) {
this.flashMessage = 'invlid username or password'; this.flashMessage = 'invlid username or password';
} else {
this.flashMessage = 'unable to log you in';
log.error(e);
} }
} }
this.waiting = false; this.waiting = false;

View File

@ -1,5 +1,5 @@
import { Component, Vue } from 'vue-property-decorator'; import { Component, Vue } from 'vue-property-decorator';
import measureStore from '@/store-modules/measure'; import { measureStore } from '@/store';
@Component({ @Component({
components: { } components: { }

View File

@ -3,8 +3,7 @@ import { Route, RawLocation } from 'vue-router';
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 { measureStore, userStore } from '@/store';
import measureStore from '@/store-modules/measure';
// 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);

View File

@ -1,4 +1,16 @@
import { Component, Vue } from 'vue-property-decorator'; import { Component, Vue } from 'vue-property-decorator';
import { apiTokenStore, userStore } from '@/store';
@Component({}) @Component({})
export default class UserAccount extends Vue {} export default class UserAccount extends Vue {
private get user() { return userStore.user; }
private get apiTokens() { return apiTokenStore.apiTokens; }
private created() {
if (apiTokenStore.apiTokens.length === 0) {
apiTokenStore.fetchAllApiTokens();
}
}
}