From b23d3d36afb9d2d5a753629f42068ce8acf46169 Mon Sep 17 00:00:00 2001 From: Jonathan Bernard Date: Thu, 7 Mar 2019 23:39:24 -0600 Subject: [PATCH] WIP Auth redesign. --- web/package-lock.json | 10 +++++++ web/package.json | 2 ++ web/src/app.ts | 29 ++++++++++++-------- web/src/models.d.ts | 1 - web/src/router.ts | 6 ++--- web/src/services/pm-api-client.ts | 14 +++++----- web/src/store-modules/auth.ts | 45 +++++++++++++++++++++++++++++++ web/src/store-modules/measure.ts | 37 +++++++++++++++++++++++++ web/src/store-modules/user.ts | 21 +++++---------- web/src/styles/ui-common.scss | 10 +++++++ web/src/views/Measures.vue | 11 ++++++-- web/src/views/NavBar.vue | 10 +++++-- web/src/views/login.ts | 5 ++-- web/src/views/measures.scss | 3 +++ web/src/views/measures.ts | 5 +++- web/src/views/nav-bar.scss | 22 ++++++++++++--- web/src/views/nav-bar.ts | 7 ++--- web/tslint.json | 1 + 18 files changed, 188 insertions(+), 51 deletions(-) create mode 100644 web/src/store-modules/auth.ts create mode 100644 web/src/store-modules/measure.ts create mode 100644 web/src/views/measures.scss diff --git a/web/package-lock.json b/web/package-lock.json index ba57f74..f2b8cff 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1004,6 +1004,11 @@ "integrity": "sha512-Q5hTcfdudEL2yOmluA1zaSyPbzWPmJ3XfSWeP3RyoYvS9hnje1ZyagrZOuQ6+1nQC1Gw+7gap3pLNL3xL6UBug==", "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": { "version": "4.14.121", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.121.tgz", @@ -9145,6 +9150,11 @@ "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": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", diff --git a/web/package.json b/web/package.json index 05301d2..ddbc17a 100644 --- a/web/package.json +++ b/web/package.json @@ -12,8 +12,10 @@ "@fortawesome/fontawesome-svg-core": "^1.2.15", "@fortawesome/free-solid-svg-icons": "^5.7.2", "@fortawesome/vue-fontawesome": "^0.1.5", + "@types/js-cookie": "^2.2.1", "@types/lodash.merge": "^4.6.5", "axios": "^0.18.0", + "js-cookie": "^2.2.0", "keen-ui": "^1.1.2", "lodash.merge": "^4.6.1", "register-service-worker": "^1.5.2", diff --git a/web/src/app.ts b/web/src/app.ts index 872a3d7..a5587cf 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -1,10 +1,11 @@ import { Component, Vue, Watch } from 'vue-property-decorator'; import NavBar from '@/views/NavBar.vue'; 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'; - @Component({ components: { NavBar @@ -19,24 +20,30 @@ export default class App extends Vue { 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); + logService.ROOT_LOGGER.appenders.push(this.apiLogAppender, this.consoleLogAppender); + + // TODO: prod/dev config settings for logging? this.apiLogAppender.batchSize = 1; 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 { - return usersStore.user; + private get authToken(): string | undefined { + return authStore.authToken; } - @Watch('user') - private onUserChanged(val: User | null , oldVal: User | null) { - if (val) { this.apiLogAppender.authToken = val.authToken; } + @Watch('authToken') + private onAuthTokenChange(val: string | undefined , oldVal: string | undefined) { + if (val) { + this.apiLogAppender.authToken = val; + userStore.fetchUser(); + measureStore.fetchAllMeasures(); + } } } diff --git a/web/src/models.d.ts b/web/src/models.d.ts index ef65476..8a70c04 100644 --- a/web/src/models.d.ts +++ b/web/src/models.d.ts @@ -36,5 +36,4 @@ export interface User { displayName: string; email: string; isAdmin: boolean; - authToken?: string; } diff --git a/web/src/router.ts b/web/src/router.ts index 7eb2205..c9e5df5 100644 --- a/web/src/router.ts +++ b/web/src/router.ts @@ -6,7 +6,7 @@ import Login from '@/views/Login.vue'; import Measures from '@/views/Measures.vue'; import UserAccount from '@/views/UserAccount.vue'; import QuickPanels from '@/views/QuickPanels.vue'; -import userStore from '@/store-modules/user'; +import authStore from '@/store-modules/auth'; Vue.use(Router); @@ -16,7 +16,7 @@ const router = new Router({ routes: [ { path: '/', - redirect: '/dashboard' + redirect: '/measures' }, { path: '/login', @@ -53,7 +53,7 @@ const router = new Router({ // Auth filter router.beforeEach((to, from, next) => { if (to.matched.some((record) => record.meta.requiresAuth)) { - if (!userStore.user || !userStore.user.authToken) { + if (!authStore.authToken) { next({ name: 'login' }); // params: { redirect: to.path } }); } else { next(); } // if authed diff --git a/web/src/services/pm-api-client.ts b/web/src/services/pm-api-client.ts index f82c08d..b6ddb78 100644 --- a/web/src/services/pm-api-client.ts +++ b/web/src/services/pm-api-client.ts @@ -2,10 +2,10 @@ import { default as Axios, AxiosInstance } from 'axios'; import { ApiToken, LoginSubmit, Measure, Measurement, User } from '@/models'; import { Logger, logService } from '@/services/logging'; import merge from 'lodash.merge'; +import authStore from '@/store-modules/auth'; export class PmApiClient { private http: AxiosInstance; - private authToken?: string; private log: Logger; constructor(apiBase: string) { @@ -24,8 +24,6 @@ export class PmApiClient { this.log.trace('Initialized PmApiClient'); } - public setAuthToken(t: string) { this.authToken = t; } - public async version(): Promise { const resp = await this.http.get('/version'); return resp.data; @@ -38,12 +36,12 @@ export class PmApiClient { return resp.data; } - public async getUser(authToken: string): Promise { + public async getUser(): Promise { 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 { @@ -206,8 +204,8 @@ export class PmApiClient { } private authHeader(): { [key: string]: string } { - if (this.authToken) { - return { Authorization: 'Bearer ' + this.authToken }; + if (authStore.authToken) { + return { Authorization: 'Bearer ' + authStore.authToken }; } else { throw new Error('no authenticated user'); } diff --git a/web/src/store-modules/auth.ts b/web/src/store-modules/auth.ts new file mode 100644 index 0000000..3e3ec59 --- /dev/null +++ b/web/src/store-modules/auth.ts @@ -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; diff --git a/web/src/store-modules/measure.ts b/web/src/store-modules/measure.ts new file mode 100644 index 0000000..e04f9b9 --- /dev/null +++ b/web/src/store-modules/measure.ts @@ -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); diff --git a/web/src/store-modules/user.ts b/web/src/store-modules/user.ts index 6312f9b..9e57640 100644 --- a/web/src/store-modules/user.ts +++ b/web/src/store-modules/user.ts @@ -7,8 +7,8 @@ import { VuexModule } from 'vuex-module-decorators'; import { LoginSubmit, User } from '@/models'; -import store from '@/store'; import api from '@/services/pm-api-client'; +import store from '@/store'; import { logService } from '@/services/logging'; @Module({ namespaced: true, name: 'user', store, dynamic: true }) @@ -18,20 +18,14 @@ class UserStoreModule extends VuexModule { 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: ['user']}) + public async fetchUser() { + return { user: await api.getUser() }; } @MutationAction({ mutate: ['users'] }) public async fetchAllUsers() { - const users = await api.getAllUsers(); - return { users }; + return { users: await api.getAllUsers() }; } @MutationAction({ mutate: ['users'] }) @@ -45,8 +39,7 @@ class UserStoreModule extends VuexModule { 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); +export const userStore = getModule(UserStoreModule); +export default userStore; diff --git a/web/src/styles/ui-common.scss b/web/src/styles/ui-common.scss index 8d6fd62..2ae12a6 100644 --- a/web/src/styles/ui-common.scss +++ b/web/src/styles/ui-common.scss @@ -15,3 +15,13 @@ 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; } +} diff --git a/web/src/views/Measures.vue b/web/src/views/Measures.vue index 99b9d78..64a9918 100644 --- a/web/src/views/Measures.vue +++ b/web/src/views/Measures.vue @@ -1,6 +1,13 @@ + diff --git a/web/src/views/NavBar.vue b/web/src/views/NavBar.vue index 14263af..89422a5 100644 --- a/web/src/views/NavBar.vue +++ b/web/src/views/NavBar.vue @@ -4,13 +4,19 @@ Personal Measure - + Measures + diff --git a/web/src/views/login.ts b/web/src/views/login.ts index f7f88e7..724fdb4 100644 --- a/web/src/views/login.ts +++ b/web/src/views/login.ts @@ -1,7 +1,7 @@ import { Route } from 'vue-router'; import { Component, Prop, Vue, Watch } from 'vue-property-decorator'; import { LoginSubmit } from '@/models'; -import userStore from '@/store-modules/user'; +import authStore from '@/store-modules/auth'; @Component({}) export default class Login extends Vue { @@ -19,9 +19,10 @@ export default class Login extends Vue { this.waiting = true; this.flashMessage = ''; try { - await userStore.login(this.loginForm); + await authStore.login(this.loginForm); this.$router.push({ path: this.redirect || '/' }); } catch (e) { + console.log(e); if (e.response.status === 401) { this.flashMessage = 'invlid username or password'; } diff --git a/web/src/views/measures.scss b/web/src/views/measures.scss new file mode 100644 index 0000000..c647f19 --- /dev/null +++ b/web/src/views/measures.scss @@ -0,0 +1,3 @@ +#measures { + flex-grow: 1; +} diff --git a/web/src/views/measures.ts b/web/src/views/measures.ts index 432aaa8..0d33dfd 100644 --- a/web/src/views/measures.ts +++ b/web/src/views/measures.ts @@ -1,6 +1,9 @@ import { Component, Vue } from 'vue-property-decorator'; +import measureStore from '@/store-modules/measure'; @Component({ components: { } }) -export default class Measures extends Vue {} +export default class Measures extends Vue { + private get measures() { return measureStore.measures; } +} diff --git a/web/src/views/nav-bar.scss b/web/src/views/nav-bar.scss index e9e4c23..1dcd00e 100644 --- a/web/src/views/nav-bar.scss +++ b/web/src/views/nav-bar.scss @@ -21,14 +21,16 @@ nav { a { color: inherit; display: block; - font-size: 1.5rem; - padding: .5rem 0; text-decoration: none; width: 100%; &.router-link-active { color: $color4; } - &:hover { color: $color4; } + } + + & > a { + font-size: 1.5rem; + padding: .5rem 0; svg { display: inline-block; @@ -40,9 +42,21 @@ nav { padding: 0 0 1rem; } - .collapse-handle { + & > .collapse-handle { cursor: pointer; font-size: 1.5rem; 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; } + } + } } diff --git a/web/src/views/nav-bar.ts b/web/src/views/nav-bar.ts index 6903a6a..cb0dad1 100644 --- a/web/src/views/nav-bar.ts +++ b/web/src/views/nav-bar.ts @@ -1,8 +1,10 @@ import { Component, Vue } from 'vue-property-decorator'; +import { Route, RawLocation } from 'vue-router'; import { library } from '@fortawesome/fontawesome-svg-core'; import { faAngleDoubleLeft, faAngleDoubleRight, faHome, faPencilRuler, faThLarge, faUser } from '@fortawesome/free-solid-svg-icons'; import userStore from '@/store-modules/user'; +import measureStore from '@/store-modules/measure'; // import UiIconButton from 'keen-ui/src/UiIconButton.vue'; library.add(faAngleDoubleLeft, faAngleDoubleRight, faHome, faPencilRuler, faThLarge, faUser); @@ -25,7 +27,6 @@ export default class NavBar extends Vue { return this.collapsed; } - public get user() { - return userStore.user; - } + public get user() { return userStore.user; } + public get measures() { return measureStore.measures; } } diff --git a/web/tslint.json b/web/tslint.json index d96540b..c8c1460 100644 --- a/web/tslint.json +++ b/web/tslint.json @@ -13,6 +13,7 @@ "indent": [true, "spaces", 2], "interface-name": false, "ordered-imports": false, + "one-line": false, "object-literal-sort-keys": false, "no-consecutive-blank-lines": false, "trailing-comma": [true, {"multiline": "never", "singleline": "never" }]