WIP Auth redesign.

This commit is contained in:
Jonathan Bernard 2019-03-07 23:39:24 -06:00
parent 6bc094f515
commit b23d3d36af
18 changed files with 188 additions and 51 deletions

10
web/package-lock.json generated
View File

@ -1004,6 +1004,11 @@
"integrity": "sha512-Q5hTcfdudEL2yOmluA1zaSyPbzWPmJ3XfSWeP3RyoYvS9hnje1ZyagrZOuQ6+1nQC1Gw+7gap3pLNL3xL6UBug==", "integrity": "sha512-Q5hTcfdudEL2yOmluA1zaSyPbzWPmJ3XfSWeP3RyoYvS9hnje1ZyagrZOuQ6+1nQC1Gw+7gap3pLNL3xL6UBug==",
"dev": true "dev": true
}, },
"@types/js-cookie": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.1.tgz",
"integrity": "sha512-VIVurImEhQ95jxtjs8baVU5qCzVfwYfuMrpXwdRykJ5MCI5iY7/jB4cDSgwBVeYqeXrhT7GfJUwoDOmN0OMVCA=="
},
"@types/lodash": { "@types/lodash": {
"version": "4.14.121", "version": "4.14.121",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.121.tgz", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.121.tgz",
@ -9145,6 +9150,11 @@
"nopt": "~4.0.1" "nopt": "~4.0.1"
} }
}, },
"js-cookie": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.0.tgz",
"integrity": "sha1-Gywnmm7s44ChIWi5JIUmWzWx7/s="
},
"js-levenshtein": { "js-levenshtein": {
"version": "1.1.6", "version": "1.1.6",
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",

View File

@ -12,8 +12,10 @@
"@fortawesome/fontawesome-svg-core": "^1.2.15", "@fortawesome/fontawesome-svg-core": "^1.2.15",
"@fortawesome/free-solid-svg-icons": "^5.7.2", "@fortawesome/free-solid-svg-icons": "^5.7.2",
"@fortawesome/vue-fontawesome": "^0.1.5", "@fortawesome/vue-fontawesome": "^0.1.5",
"@types/js-cookie": "^2.2.1",
"@types/lodash.merge": "^4.6.5", "@types/lodash.merge": "^4.6.5",
"axios": "^0.18.0", "axios": "^0.18.0",
"js-cookie": "^2.2.0",
"keen-ui": "^1.1.2", "keen-ui": "^1.1.2",
"lodash.merge": "^4.6.1", "lodash.merge": "^4.6.1",
"register-service-worker": "^1.5.2", "register-service-worker": "^1.5.2",

View File

@ -1,10 +1,11 @@
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 usersStore from '@/store-modules/user'; import authStore from '@/store-modules/auth';
import measureStore from '@/store-modules/measure';
import userStore from '@/store-modules/user';
import { User } from '@/models'; import { User } from '@/models';
@Component({ @Component({
components: { components: {
NavBar NavBar
@ -19,24 +20,30 @@ export default class App extends Vue {
super(); super();
// Setup application logging. // Setup application logging.
// TODO: prod/dev config settings for logging?
this.consoleLogAppender = new ConsoleLogAppender(LogLevel.ALL); this.consoleLogAppender = new ConsoleLogAppender(LogLevel.ALL);
this.apiLogAppender = new ApiLogAppender( this.apiLogAppender = new ApiLogAppender(
process.env.VUE_APP_PM_API_BASE + '/log/batch', '', LogLevel.WARN); process.env.VUE_APP_PM_API_BASE + '/log/batch', '', LogLevel.WARN);
logService.ROOT_LOGGER.appenders.push(this.apiLogAppender, this.consoleLogAppender);
// TODO: prod/dev config settings for logging?
this.apiLogAppender.batchSize = 1; this.apiLogAppender.batchSize = 1;
this.apiLogAppender.minimumTimePassedInSec = 5; this.apiLogAppender.minimumTimePassedInSec = 5;
logService.ROOT_LOGGER.appenders.push(this.apiLogAppender, this.consoleLogAppender); // Check for existing session cookie.
/*tslint:disable:no-empty*/
authStore.findLocalToken().catch(() => {});
} }
private get user(): User | null { private get authToken(): string | undefined {
return usersStore.user; return authStore.authToken;
} }
@Watch('user') @Watch('authToken')
private onUserChanged(val: User | null , oldVal: User | null) { private onAuthTokenChange(val: string | undefined , oldVal: string | undefined) {
if (val) { this.apiLogAppender.authToken = val.authToken; } if (val) {
this.apiLogAppender.authToken = val;
userStore.fetchUser();
measureStore.fetchAllMeasures();
}
} }
} }

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

@ -36,5 +36,4 @@ export interface User {
displayName: string; displayName: string;
email: string; email: string;
isAdmin: boolean; isAdmin: boolean;
authToken?: string;
} }

View File

@ -6,7 +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 authStore from '@/store-modules/auth';
Vue.use(Router); Vue.use(Router);
@ -16,7 +16,7 @@ const router = new Router({
routes: [ routes: [
{ {
path: '/', path: '/',
redirect: '/dashboard' redirect: '/measures'
}, },
{ {
path: '/login', path: '/login',
@ -53,7 +53,7 @@ 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 (!userStore.user || !userStore.user.authToken) { if (!authStore.authToken) {
next({ name: 'login' }); next({ name: 'login' });
// params: { redirect: to.path } }); // params: { redirect: to.path } });
} else { next(); } // if authed } else { next(); } // if authed

View File

@ -2,10 +2,10 @@ 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;
private authToken?: string;
private log: Logger; private log: Logger;
constructor(apiBase: string) { constructor(apiBase: string) {
@ -24,8 +24,6 @@ export class PmApiClient {
this.log.trace('Initialized PmApiClient'); this.log.trace('Initialized PmApiClient');
} }
public setAuthToken(t: string) { this.authToken = t; }
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;
@ -38,12 +36,12 @@ export class PmApiClient {
return resp.data; return resp.data;
} }
public async getUser(authToken: string): Promise<User> { public async getUser(): Promise<User> {
const resp = await this.http.get('/user', const resp = await this.http.get('/user',
{ headers: { Authorization: 'Bearer ' + authToken }}); { headers: this.authHeader() });
return merge(resp.data, { authToken }) ; return merge(resp.data) ;
} }
public async getAllUsers(): Promise<User[]> { public async getAllUsers(): Promise<User[]> {
@ -206,8 +204,8 @@ export class PmApiClient {
} }
private authHeader(): { [key: string]: string } { private authHeader(): { [key: string]: string } {
if (this.authToken) { if (authStore.authToken) {
return { Authorization: 'Bearer ' + this.authToken }; return { Authorization: 'Bearer ' + authStore.authToken };
} else { } else {
throw new Error('no authenticated user'); throw new Error('no authenticated user');
} }

View File

@ -0,0 +1,45 @@
import {
Action,
getModule,
Module,
Mutation,
MutationAction,
VuexModule
} from 'vuex-module-decorators';
import { LoginSubmit } from '@/models';
import store from '@/store';
import api from '@/services/pm-api-client';
import Cookies from 'js-cookie';
const COOKIE_TOKEN = 'pm-api-cookie-token';
@Module({ namespaced: true, name: 'auth', store, dynamic: true })
class AuthStoreModule extends VuexModule {
public authToken: string | undefined = undefined;
@Action({ commit: 'SET_TOKEN', rawError: true })
public async login(creds: LoginSubmit) {
return await api.createAuthToken(creds);
}
@Action({ commit: 'SET_TOKEN', rawError: true })
public async findLocalToken() {
const token = Cookies.get(COOKIE_TOKEN);
if (token) { return token; }
else { throw new Error('No auth token stored as a cookie.'); }
}
@Action({ commit: 'SET_TOKEN' })
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;
}
}
export const authStore = getModule(AuthStoreModule);
export default authStore;

View File

@ -0,0 +1,37 @@
import {
Action,
getModule,
Module,
Mutation,
MutationAction,
VuexModule
} from 'vuex-module-decorators';
import { keyBy } from 'lodash';
import { User, Measure } from '@/models';
import store from '@/store';
import api from '@/services/pm-api-client';
import { logService } from '@/services/logging';
@Module({ namespaced: true, name: 'measure', store, dynamic: true })
class MeasureStoreModule extends VuexModule {
public measures: { [key: string]: Measure } = {};
private log = logService.getLogger('/store-modules/measure');
@MutationAction({ mutate: ['measures'], rawError: true })
public async fetchAllMeasures() {
const measures = await api.getAllMeasures();
return { measures: keyBy(measures, 'slug') };
}
@Action({ commit: 'SET_MEASURE', rawError: true })
public async fetchMeasure(slug: string) {
return await api.getMeasure(slug);
}
@Mutation private SET_MEASURE(measure: Measure) {
this.measures[measure.slug] = measure;
}
}
export default getModule(MeasureStoreModule);

View File

@ -7,8 +7,8 @@ import {
VuexModule VuexModule
} from 'vuex-module-decorators'; } from 'vuex-module-decorators';
import { LoginSubmit, User } from '@/models'; import { LoginSubmit, User } from '@/models';
import store from '@/store';
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';
@Module({ namespaced: true, name: 'user', store, dynamic: true }) @Module({ namespaced: true, name: 'user', store, dynamic: true })
@ -18,20 +18,14 @@ class UserStoreModule extends VuexModule {
private log = logService.getLogger('/store-modules/user'); private log = logService.getLogger('/store-modules/user');
@Action({ commit: 'SET_USER', rawError: true }) @MutationAction({ mutate: ['user']})
public async login(creds: LoginSubmit) { public async fetchUser() {
const authToken = await api.createAuthToken(creds); return { user: await api.getUser() };
const user = await api.getUser(authToken);
this.log.trace('User login successful.');
api.setAuthToken(authToken);
return user;
} }
@MutationAction({ mutate: ['users'] }) @MutationAction({ mutate: ['users'] })
public async fetchAllUsers() { public async fetchAllUsers() {
const users = await api.getAllUsers(); return { users: await api.getAllUsers() };
return { users };
} }
@MutationAction({ mutate: ['users'] }) @MutationAction({ mutate: ['users'] })
@ -45,8 +39,7 @@ class UserStoreModule extends VuexModule {
await api.deleteUser(userId); await api.deleteUser(userId);
return { users: this.users.filter((u) => u.id !== userId) }; return { users: this.users.filter((u) => u.id !== userId) };
} }
@Mutation private SET_USER(user: User) { this.user = user; }
} }
export default getModule(UserStoreModule); export const userStore = getModule(UserStoreModule);
export default userStore;

View File

@ -15,3 +15,13 @@
background-color: darken($color2, 5%); background-color: darken($color2, 5%);
} }
} }
.header-action {
align-items: baseline;
display: flex;
font-size: 1.2rem;
justify-content: space-between;
& > * { display: inline-block; }
& > button { font-size: inherit; }
}

View File

@ -1,6 +1,13 @@
<template> <template>
<div class="measures"> <div id="measures">
<h1>Things You Are Measuring</h1> <div class=header-action>
<h1>Things You Are Measuring</h1>
<button class=btn-action>Add Measure</button>
</div>
<div v-for="(measure, slug) in measures">
<h2>{{measure.name}}</h2>
</div>
</div> </div>
</template> </template>
<script lang="ts" src="./measures.ts"></script> <script lang="ts" src="./measures.ts"></script>
<style lang="scss" src="./measures.scss"></style>

View File

@ -4,13 +4,19 @@
<span class=expanded>Personal Measure</span> <span class=expanded>Personal Measure</span>
<span class=collapsed>PM</span> <span class=collapsed>PM</span>
</h1> </h1>
<router-link to="/dashboard"> <!--<router-link to="/dashboard">
<fa-icon icon=home></fa-icon> <fa-icon icon=home></fa-icon>
<span class=expanded>Dashboard</span> <span class=expanded>Dashboard</span>
</router-link> </router-link>-->
<router-link to="/measures"> <router-link to="/measures">
<fa-icon icon=pencil-ruler></fa-icon> <fa-icon icon=pencil-ruler></fa-icon>
<span class=expanded>Measures</span> <span class=expanded>Measures</span>
<div class="submenu expanded">
<router-link v-for="(measure, slug) in measures" :key="slug"
:to="'/measures/' + slug">
{{measure.name}}
</router-link>
</div>
</router-link> </router-link>
<router-link to="/quick-panels"> <router-link to="/quick-panels">
<fa-icon icon=th-large></fa-icon> <fa-icon icon=th-large></fa-icon>

View File

@ -1,7 +1,7 @@
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 userStore from '@/store-modules/user'; import authStore from '@/store-modules/auth';
@Component({}) @Component({})
export default class Login extends Vue { export default class Login extends Vue {
@ -19,9 +19,10 @@ export default class Login extends Vue {
this.waiting = true; this.waiting = true;
this.flashMessage = ''; this.flashMessage = '';
try { try {
await userStore.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.status === 401) { if (e.response.status === 401) {
this.flashMessage = 'invlid username or password'; this.flashMessage = 'invlid username or password';
} }

View File

@ -0,0 +1,3 @@
#measures {
flex-grow: 1;
}

View File

@ -1,6 +1,9 @@
import { Component, Vue } from 'vue-property-decorator'; import { Component, Vue } from 'vue-property-decorator';
import measureStore from '@/store-modules/measure';
@Component({ @Component({
components: { } components: { }
}) })
export default class Measures extends Vue {} export default class Measures extends Vue {
private get measures() { return measureStore.measures; }
}

View File

@ -21,14 +21,16 @@ nav {
a { a {
color: inherit; color: inherit;
display: block; display: block;
font-size: 1.5rem;
padding: .5rem 0;
text-decoration: none; text-decoration: none;
width: 100%; width: 100%;
&.router-link-active { color: $color4; } &.router-link-active { color: $color4; }
&:hover { color: $color4; } &:hover { color: $color4; }
}
& > a {
font-size: 1.5rem;
padding: .5rem 0;
svg { svg {
display: inline-block; display: inline-block;
@ -40,9 +42,21 @@ nav {
padding: 0 0 1rem; padding: 0 0 1rem;
} }
.collapse-handle { & > .collapse-handle {
cursor: pointer; cursor: pointer;
font-size: 1.5rem; font-size: 1.5rem;
margin-top: auto; margin-top: auto;
} }
.submenu {
margin: .5em 0 0 1.6em;
a {
color: $color3;
font-size: 1.2rem;
&.router-link-active { color: $color4; }
&:hover { color: $color4; }
}
}
} }

View File

@ -1,8 +1,10 @@
import { Component, Vue } from 'vue-property-decorator'; import { Component, Vue } from 'vue-property-decorator';
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 userStore from '@/store-modules/user';
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);
@ -25,7 +27,6 @@ export default class NavBar extends Vue {
return this.collapsed; return this.collapsed;
} }
public get user() { public get user() { return userStore.user; }
return userStore.user; public get measures() { return measureStore.measures; }
}
} }

View File

@ -13,6 +13,7 @@
"indent": [true, "spaces", 2], "indent": [true, "spaces", 2],
"interface-name": false, "interface-name": false,
"ordered-imports": false, "ordered-imports": false,
"one-line": false,
"object-literal-sort-keys": false, "object-literal-sort-keys": false,
"no-consecutive-blank-lines": false, "no-consecutive-blank-lines": false,
"trailing-comma": [true, {"multiline": "never", "singleline": "never" }] "trailing-comma": [true, {"multiline": "never", "singleline": "never" }]