Commit 14e34d43 authored by Nicolas Giard's avatar Nicolas Giard

feat: jwt renew via graphql + users create/authorize UI

parent 3caa842d
......@@ -7,10 +7,11 @@ import VueSimpleBreakpoints from 'vue-simple-breakpoints'
import VeeValidate from 'vee-validate'
import { ApolloClient } from 'apollo-client'
import { createPersistedQueryLink } from 'apollo-link-persisted-queries'
// import { BatchHttpLink } from 'apollo-link-batch-http'
import { split } from 'apollo-link'
import { createHttpLink } from 'apollo-link-http'
import { BatchHttpLink } from 'apollo-link-batch-http'
import { ApolloLink, split } from 'apollo-link'
// import { createHttpLink } from 'apollo-link-http'
import { WebSocketLink } from 'apollo-link-ws'
import { ErrorLink } from 'apollo-link-error'
import { InMemoryCache } from 'apollo-cache-inmemory'
import { getMainDefinition } from 'apollo-utilities'
import VueApollo from 'vue-apollo'
......@@ -54,24 +55,47 @@ moment.locale(siteConfig.lang)
const graphQLEndpoint = window.location.protocol + '//' + window.location.host + '/graphql'
const graphQLWSEndpoint = ((window.location.protocol === 'https:') ? 'wss:' : 'ws:') + '//' + window.location.host + '/graphql-subscriptions'
const graphQLLink = createPersistedQueryLink().concat(
createHttpLink({
const graphQLLink = ApolloLink.from([
new ErrorLink(({ graphQLErrors, networkError }) => {
if (graphQLErrors) {
graphQLErrors.map(({ message, locations, path }) =>
console.error(
`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`
)
)
store.commit('showNotification', {
style: 'red',
message: `An expected error occured.`,
icon: 'warning'
})
}
if (networkError) {
console.error(networkError)
store.commit('showNotification', {
style: 'red',
message: `Network Error: ${networkError.message}`,
icon: 'error'
})
}
}),
createPersistedQueryLink(),
new BatchHttpLink({
includeExtensions: true,
uri: graphQLEndpoint,
credentials: 'include',
fetch: (uri, options) => {
fetch: async (uri, options) => {
// Strip __typename fields from variables
let body = JSON.parse(options.body)
// body = body.map(bd => {
// return ({
// ...bd,
// variables: JSON.parse(JSON.stringify(bd.variables), (key, value) => { return key === '__typename' ? undefined : value })
// })
// })
body = {
...body,
variables: JSON.parse(JSON.stringify(body.variables), (key, value) => { return key === '__typename' ? undefined : value })
}
body = body.map(bd => {
return ({
...bd,
variables: JSON.parse(JSON.stringify(bd.variables), (key, value) => { return key === '__typename' ? undefined : value })
})
})
// body = {
// ...body,
// variables: JSON.parse(JSON.stringify(body.variables), (key, value) => { return key === '__typename' ? undefined : value })
// }
options.body = JSON.stringify(body)
// Inject authentication token
......@@ -80,10 +104,17 @@ const graphQLLink = createPersistedQueryLink().concat(
options.headers.Authorization = `Bearer ${jwtToken}`
}
return fetch(uri, options)
const resp = await fetch(uri, options)
// Handle renewed JWT
const newJWT = resp.headers.get('new-jwt')
if (newJWT) {
Cookies.set('jwt', newJWT, { expires: 365 })
}
return resp
}
})
)
])
const graphQLWSLink = new WebSocketLink({
uri: graphQLWSEndpoint,
......
<template lang="pug">
v-dialog(v-model='isShown', max-width='550')
v-card
.dialog-header.is-short Authorize Social User
v-card-text
v-select.md2(
:items='providers'
item-text='title'
item-value='key'
solo
flat
background-color='grey lighten-4'
prepend-icon='business'
v-model='provider'
label='Provider'
)
v-text-field.md2(
solo
flat
background-color='grey lighten-4'
prepend-icon='email'
v-model='email'
label='Email Address'
ref='emailInput'
)
v-text-field.md2(
solo
flat
background-color='grey lighten-4'
prepend-icon='person'
v-model='name'
label='Name'
)
v-text-field.md2(
solo
flat
background-color='grey lighten-4'
prepend-icon='title'
v-model='jobTitle'
label='Job Title'
counter='255'
hint='Optional'
persistent-hint
)
v-text-field.md2(
solo
flat
background-color='grey lighten-4'
prepend-icon='public'
v-model='location'
label='Location'
counter='255'
hint='Optional'
persistent-hint
)
v-card-chin
v-spacer
v-btn(flat, @click='isShown = false') Cancel
v-btn(color='primary', @click='authorizeUser') Authorize
</template>
<script>
import _ from 'lodash'
import providersQuery from 'gql/admin/users/users-query-strategies.gql'
export default {
props: {
value: {
type: Boolean,
default: false
}
},
data() {
return {
providers: [],
provider: '',
email: '',
name: '',
jobTitle: '',
location: ''
}
},
computed: {
isShown: {
get() { return this.value },
set(val) { this.$emit('input', val) }
}
},
watch: {
value(newValue, oldValue) {
if (newValue) {
this.$nextTick(() => {
this.$refs.emailInput.focus()
})
}
}
},
methods: {
async authorizeUser() {
}
},
apollo: {
providers: {
query: providersQuery,
fetchPolicy: 'network-only',
update: (data) => _.reject(data.authentication.strategies, ['key', 'local']),
watchLoading (isLoading) {
this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-users-strategies-refresh')
}
}
}
}
</script>
<template lang="pug">
v-dialog(v-model='isShown', max-width='550')
v-card
.dialog-header.is-short New Local User
v-card-text
v-text-field.md2(
solo
flat
background-color='grey lighten-4'
prepend-icon='email'
v-model='email'
label='Email Address'
ref='emailInput'
)
v-text-field.md2(
solo
flat
background-color='grey lighten-4'
prepend-icon='person'
v-model='name'
label='Name'
)
v-text-field.md2(
solo
flat
background-color='grey lighten-4'
prepend-icon='lock'
append-icon='casino'
v-model='password'
label='Password'
counter='255'
@click:append='generatePwd'
)
v-text-field.md2(
solo
flat
background-color='grey lighten-4'
prepend-icon='title'
v-model='jobTitle'
label='Job Title'
counter='255'
hint='Optional'
persistent-hint
)
v-text-field.md2(
solo
flat
background-color='grey lighten-4'
prepend-icon='public'
v-model='location'
label='Location'
counter='255'
hint='Optional'
persistent-hint
)
v-card-chin
v-spacer
v-btn(flat, @click='isShown = false') Cancel
v-btn(color='primary', @click='createUser') Create
</template>
<script>
import uuidv4 from 'uuid/v4'
export default {
props: {
value: {
type: Boolean,
default: false
}
},
data() {
return {
email: '',
name: '',
password: '',
jobTitle: '',
location: ''
}
},
computed: {
isShown: {
get() { return this.value },
set(val) { this.$emit('input', val) }
}
},
watch: {
value(newValue, oldValue) {
if (newValue) {
this.$nextTick(() => {
this.$refs.emailInput.focus()
})
}
}
},
methods: {
async createUser() {
},
generatePwd() {
this.password = uuidv4().slice(-12)
}
}
}
</script>
......@@ -8,99 +8,97 @@
.headline.blue--text.text--darken-2 Users
.subheading.grey--text Manage users
v-spacer
v-btn(outline, color='grey', large)
v-btn(outline, color='grey', large, @click='refresh')
v-icon refresh
v-btn(color='primary', large, depressed)
v-btn(color='primary', large, depressed, @click='authorizeUser')
v-icon(left) lock_outline
span Authorize User
v-btn(color='primary', large, depressed)
span Authorize Social User
v-btn(color='primary', large, depressed, @click='createUser')
v-icon(left) add
span New User
span New Local User
v-card.mt-3
v-data-table(
v-model='selected'
:items='items',
:items='users',
:headers='headers',
:search='search',
:pagination.sync='pagination',
:rows-per-page-items='[15]'
select-all,
hide-actions,
disable-initial-sort
)
template(slot='headers', slot-scope='props')
tr
th(width='50')
th.text-xs-right(
width='80'
:class='[`column sortable`, pagination.descending ? `desc` : `asc`, pagination.sortBy === `id` ? `active` : ``]'
@click='changeSort(`id`)'
)
v-icon(small) arrow_upward
| ID
//- th(width='50')
th.text-xs-left(
v-for='header in props.headers'
:key='header.text'
:width='header.width'
:class='[`column sortable`, pagination.descending ? `desc` : `asc`, header.value === pagination.sortBy ? `active` : ``]'
:class='[`column`, header.sortable ? `sortable` : ``, pagination.descending ? `desc` : `asc`, header.value === pagination.sortBy ? `active` : ``]'
@click='changeSort(header.value)'
)
| {{ header.text }}
v-icon(small) arrow_upward
v-icon(small, v-if='header.sortable') arrow_upward
template(slot='items', slot-scope='props')
tr(:active='props.selected')
td
//- td
v-checkbox(hide-details, :input-value='props.selected', color='blue darken-2', @click='props.selected = !props.selected')
td.text-xs-right {{ props.item.id }}
td: strong {{ props.item.name }}
td {{ props.item.email }}
td {{ props.item.name }}
td {{ props.item.provider }}
td {{ props.item.createdOn }}
td {{ props.item.updatedOn }}
td: v-btn(icon): v-icon.grey--text.text--darken-1 more_horiz
td {{ props.item.providerKey }}
td {{ props.item.createdAt | moment('from') }}
td
v-menu(bottom, right, min-width='200')
v-btn(icon, slot='activator'): v-icon.grey--text.text--darken-1 more_horiz
v-list
v-list-tile(@click='')
v-list-tile-action
v-icon(color='primary') edit
v-list-tile-content
v-list-tile-title Edit
v-list-tile(@click='')
v-list-tile-action
v-icon(color='red') block
v-list-tile-content
v-list-tile-title Block
template(slot='no-data')
v-alert(icon='warning', :value='true') No users to display!
.pa-3
v-alert(icon='warning', :value='true', outline) No users to display!
.text-xs-center.py-2
v-pagination(v-model='pagination.page', :length='pages')
user-authorize(v-model='isAuthorizeDialogShown')
user-create(v-model='isCreateDialogShown')
</template>
<script>
import usersQuery from 'gql/admin/users/users-query-list.gql'
import UserAuthorize from './admin-users-authorize.vue'
import UserCreate from './admin-users-create.vue'
export default {
components: {
UserAuthorize,
UserCreate
},
data() {
return {
selected: [],
pagination: {},
items: [
{ id: 1, email: 'user@test.com', name: 'John Doe', provider: 'local' },
{ id: 2, email: 'dude@test.com', name: 'John Doe', provider: 'local' },
{ id: 3, email: 'dude@test.com', name: 'John Doe', provider: 'local' },
{ id: 4, email: 'dude@test.com', name: 'John Doe', provider: 'local' },
{ id: 5, email: 'dude@test.com', name: 'John Doe', provider: 'local' },
{ id: 6, email: 'dude@test.com', name: 'John Doe', provider: 'local' },
{ id: 7, email: 'dude@test.com', name: 'John Doe', provider: 'local' },
{ id: 8, email: 'dude@test.com', name: 'John Doe', provider: 'local' },
{ id: 9, email: 'dude@test.com', name: 'John Doe', provider: 'local' },
{ id: 10, email: 'dude@test.com', name: 'John Doe', provider: 'local' },
{ id: 11, email: 'dude@test.com', name: 'John Doe', provider: 'local' },
{ id: 12, email: 'dude@test.com', name: 'John Doe', provider: 'local' },
{ id: 13, email: 'dude@test.com', name: 'John Doe', provider: 'local' },
{ id: 14, email: 'dude@test.com', name: 'John Doe', provider: 'local' },
{ id: 15, email: 'dude@test.com', name: 'John Doe', provider: 'local' },
{ id: 16, email: 'dude@test.com', name: 'John Doe', provider: 'local' },
{ id: 17, email: 'dude@test.com', name: 'John Doe', provider: 'local' },
{ id: 18, email: 'dude@test.com', name: 'John Doe', provider: 'local' },
{ id: 19, email: 'dude@test.com', name: 'John Doe', provider: 'local' },
{ id: 20, email: 'dude@test.com', name: 'John Doe', provider: 'local' }
],
users: [],
headers: [
{ text: 'Email', value: 'email' },
{ text: 'Name', value: 'name' },
{ text: 'Provider', value: 'provider' },
{ text: 'Created On', value: 'createdOn' },
{ text: 'Updated On', value: 'updatedOn' },
{ text: 'ID', value: 'id', width: 80, sortable: true },
{ text: 'Name', value: 'name', sortable: true },
{ text: 'Email', value: 'email', sortable: true },
{ text: 'Provider', value: 'provider', sortable: true },
{ text: 'Created', value: 'createdAt', sortable: true },
{ text: '', value: 'actions', sortable: false, width: 50 }
],
search: ''
search: '',
isAuthorizeDialogShown: false,
isCreateDialogShown: false
}
},
computed: {
......@@ -113,6 +111,20 @@ export default {
}
},
methods: {
authorizeUser() {
this.isAuthorizeDialogShown = true
},
createUser() {
this.isCreateDialogShown = true
},
async refresh() {
await this.$apollo.queries.users.refetch()
this.$store.commit('showNotification', {
message: 'Users list has been refreshed.',
style: 'success',
icon: 'cached'
})
},
changeSort (column) {
if (this.pagination.sortBy === column) {
this.pagination.descending = !this.pagination.descending
......@@ -128,6 +140,16 @@ export default {
this.selected = this.items.slice()
}
}
},
apollo: {
users: {
query: usersQuery,
fetchPolicy: 'network-only',
update: (data) => data.users.list,
watchLoading (isLoading) {
this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-users-refresh')
}
}
}
}
</script>
......
......@@ -5,9 +5,7 @@ query {
name
email
providerKey
role
createdAt
updatedAt
}
}
}
query {
authentication {
strategies(
isEnabled: true
) {
key
title
icon
color
}
}
}
......@@ -63,26 +63,26 @@
"diff2html": "2.4.0",
"dotize": "^0.2.0",
"execa": "1.0.0",
"express": "4.16.3",
"express": "4.16.4",
"express-brute": "1.0.1",
"express-brute-redis": "0.0.1",
"express-session": "1.15.6",
"file-type": "9.0.0",
"file-type": "10.0.0",
"filesize.js": "1.0.2",
"follow-redirects": "1.5.8",
"follow-redirects": "1.5.9",
"fs-extra": "7.0.0",
"getos": "3.1.0",
"graphql": "14.0.2",
"graphql-list-fields": "2.0.2",
"graphql-subscriptions": "1.0.0",
"graphql-tools": "4.0.0",
"highlight.js": "9.12.0",
"i18next": "11.9.0",
"i18next-express-middleware": "1.4.0",
"graphql-tools": "4.0.1",
"highlight.js": "9.13.0",
"i18next": "11.9.1",
"i18next-express-middleware": "1.4.1",
"i18next-localstorage-cache": "1.1.1",
"i18next-node-fs-backend": "2.1.0",
"image-size": "0.6.3",
"ioredis": "4.0.0",
"ioredis": "4.0.2",
"js-binary": "1.2.0",
"js-yaml": "3.12.0",
"jsonwebtoken": "8.3.0",
......@@ -107,9 +107,9 @@
"mime-types": "2.1.20",
"moment": "2.22.2",
"moment-timezone": "0.5.21",
"mongodb": "3.1.6",
"mongodb": "3.1.8",
"mssql": "4.2.1",
"multer": "1.4.0",
"multer": "1.4.1",
"mysql2": "1.6.1",
"node-2fa": "1.1.2",
"node-cache": "4.2.0",
......@@ -135,9 +135,9 @@
"passport-slack": "0.0.7",
"passport-twitch": "1.0.3",
"passport-windowslive": "1.0.2",
"pg": "7.4.3",
"pg": "7.5.0",
"pg-hstore": "2.3.2",
"pm2": "3.1.3",
"pm2": "3.2.2",
"pug": "2.0.3",
"qr-image": "3.2.0",
"raven": "2.6.4",
......@@ -146,7 +146,7 @@
"request": "2.88.0",
"request-promise": "4.2.2",
"scim-query-filter-parser": "1.1.0",
"semver": "5.5.1",
"semver": "5.6.0",
"serve-favicon": "2.5.0",
"sqlite3": "4.0.2",
"subscriptions-transport-ws": "0.9.15",
......@@ -172,9 +172,9 @@
"@babel/polyfill": "^7.0.0",
"@babel/preset-env": "^7.1.0",
"@panter/vue-i18next": "0.13.0",
"@vue/cli": "3.0.4",
"@vue/cli": "3.0.5",
"animated-number-vue": "0.1.3",
"apollo-cache-inmemory": "1.3.0",
"apollo-cache-inmemory": "1.3.5",
"apollo-client": "2.4.2",
"apollo-fetch": "0.7.0",
"apollo-link": "1.2.3",
......@@ -195,12 +195,12 @@
"cache-loader": "1.2.2",
"chart.js": "2.7.2",
"clean-webpack-plugin": "0.1.19",
"copy-webpack-plugin": "4.5.2",
"copy-webpack-plugin": "4.5.3",
"css-loader": "1.0.0",
"cssnano": "4.1.4",
"duplicate-package-checker-webpack-plugin": "3.0.0",
"epic-spinners": "1.0.3",
"eslint": "5.6.1",
"eslint": "5.7.0",
"eslint-config-requarks": "1.0.7",
"eslint-config-standard": "12.0.0",
"eslint-plugin-import": "2.14.0",
......@@ -212,15 +212,15 @@
"grapesjs": "0.14.33",
"graphiql": "0.12.0",
"graphql-persisted-document-loader": "1.0.1",
"graphql-tag": "^2.9.2",
"graphql-voyager": "1.0.0-rc.25",
"graphql-tag": "^2.10.0",
"graphql-voyager": "1.0.0-rc.26",
"hammerjs": "2.0.8",
"html-webpack-plugin": "3.2.0",
"html-webpack-pug-plugin": "0.3.0",
"i18next-xhr-backend": "1.5.1",
"ignore-loader": "0.1.2",
"js-cookie": "2.2.0",
"mini-css-extract-plugin": "0.4.3",
"mini-css-extract-plugin": "0.4.4",
"node-sass": "4.9.3",
"offline-plugin": "5.0.5",
"optimize-css-assets-webpack-plugin": "5.0.1",
......@@ -229,7 +229,7 @@
"postcss-flexibility": "2.0.0",
"postcss-import": "12.0.0",
"postcss-loader": "3.0.0",
"postcss-preset-env": "6.0.7",
"postcss-preset-env": "6.1.1",
"postcss-selector-parser": "5.0.0-rc.3",
"pug-lint": "2.5.0",
"pug-loader": "2.4.0",
......@@ -242,12 +242,12 @@
"sass-resources-loader": "1.3.3",
"script-ext-html-webpack-plugin": "2.0.1",
"simple-progress-webpack-plugin": "1.1.2",
"style-loader": "0.23.0",
"style-loader": "0.23.1",
"stylus": "0.54.5",
"stylus-loader": "3.0.2",
"twemoji-awesome": "1.0.6",
"url-loader": "1.1.1",
"vee-validate": "2.1.0-beta.9",
"url-loader": "1.1.2",
"vee-validate": "2.1.0-beta.11",
"velocity-animate": "1.5.2",
"viz.js": "2.0.0",
"vue": "2.5.17",
......@@ -257,7 +257,7 @@
"vue-codemirror": "4.0.5",
"vue-hot-reload-api": "2.3.1",
"vue-loader": "15.4.2",
"vue-material-design-icons": "2.1.1",
"vue-material-design-icons": "2.3.0",
"vue-moment": "4.0.0",
"vue-router": "3.0.1",
"vue-simple-breakpoints": "1.0.3",
......@@ -267,7 +267,7 @@
"vue-tree-navigation": "3.0.1",
"vue2-animate": "2.1.0",
"vuedraggable": "2.16.0",
"vuetify": "1.2.5",
"vuetify": "1.2.9",
"vuex": "3.0.1",
"vuex-pathify": "1.1.3",
"vuex-persistedstate": "2.5.4",
......@@ -275,12 +275,12 @@
"webpack-bundle-analyzer": "3.0.2",
"webpack-cli": "3.1.2",
"webpack-dev-middleware": "3.4.0",
"webpack-hot-middleware": "2.24.2",
"webpack-hot-middleware": "2.24.3",
"webpack-merge": "4.1.4",
"webpack-subresource-integrity": "1.1.0-rc.6",
"webpack-subresource-integrity": "1.2.0",
"whatwg-fetch": "3.0.0",
"write-file-webpack-plugin": "4.4.1",
"xterm": "3.7.0"
"xterm": "3.8.0"
},
"browserslist": [
"> 1%",
......
......@@ -11,14 +11,14 @@ module.exports = {
UserQuery: {
async list(obj, args, context, info) {
return WIKI.models.users.query()
.select('id', 'email', 'name', 'providerKey', 'role', 'createdAt', 'updatedAt')
.select('id', 'email', 'name', 'providerKey', 'createdAt')
},
async search(obj, args, context, info) {
return WIKI.models.users.query()
.where('email', 'like', `%${args.query}%`)
.orWhere('name', 'like', `%${args.query}%`)
.limit(10)
.select('id', 'email', 'name', 'providerKey', 'role', 'createdAt', 'updatedAt')
.select('id', 'email', 'name', 'providerKey', 'createdAt')
},
async single(obj, args, context, info) {
let usr = await WIKI.models.users.query().findById(args.id)
......
......@@ -86,6 +86,7 @@ type UserMinimal {
name: String!
email: String!
providerKey: String!
createdAt: Date!
}
type User {
......
......@@ -22,7 +22,7 @@ module.exports = {
// Try headers, otherwise cookies for response
if (req.get('content-type') === 'application/json') {
res.headers('new-jwt', newToken.token)
res.set('new-jwt', newToken.token)
} else {
res.cookie('jwt', newToken.token, { expires: moment().add(365, 'days').toDate() })
}
......
This diff was suppressed by a .gitattributes entry.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment