Front-end auth improvements

pull/144/head
Henrique Dias 2017-07-03 10:40:24 +01:00
parent 6312c60b2e
commit 85e01a67c3
No known key found for this signature in database
GPG Key ID: 936F5EB68D786730
11 changed files with 570 additions and 304 deletions

View File

@ -1,295 +1,17 @@
<template>
<div id="app" :class="{ multiple }">
<header>
<div>
<img src="./assets/logo.svg" alt="File Manager">
<search></search>
</div>
<div>
<rename-button v-show="showRenameButton()"></rename-button>
<move-button v-show="showMoveButton()"></move-button>
<delete-button v-show="showDeleteButton()"></delete-button>
<switch-button v-show="req.kind !== 'editor'"></switch-button>
<download-button></download-button>
<upload-button v-show="showUpload()"></upload-button>
<info-button></info-button>
<button v-show="req.kind === 'listing'" @click="$store.commit('multiple', true)" aria-label="Select multiple" class="action">
<i class="material-icons">check_circle</i>
<span>Select</span>
</button>
</div>
</header>
<nav>
<a class="action" :href="baseURL + '/'">
<i class="material-icons">folder</i>
<span>My Files</span>
</a>
<div v-if="user.allowNew">
<button @click="$store.commit('showNewDir', true)" aria-label="New directory" title="New directory" class="action">
<i class="material-icons">create_new_folder</i>
<span>New folder</span>
</button>
<button @click="$store.commit('showNewFile', true)" aria-label="New file" title="New file" class="action">
<i class="material-icons">note_add</i>
<span>New file</span>
</button>
</div>
<div v-for="plugin in plugins">
<button v-for="action in plugin.sidebar" @click="action.click" :aria-label="action.name" :title="action.name" class="action">
<i class="material-icons">{{ action.icon }}</i>
<span>{{ action.name }}</span>
</button>
</div>
<button class="action" id="logout" tabindex="0" role="button" aria-label="Log out">
<i class="material-icons" title="Logout">exit_to_app</i>
<span>Logout</span>
</button>
</nav>
<main>
<editor v-if="req.kind === 'editor'"></editor>
<listing v-if="req.kind === 'listing'"></listing>
<preview v-if="req.kind === 'preview'"></preview>
</main>
<download-prompt v-if="showDownload" :class="{ active: showDownload }"></download-prompt>
<new-file-prompt v-if="showNewFile" :class="{ active: showNewFile }"></new-file-prompt>
<new-dir-prompt v-if="showNewDir" :class="{ active: showNewDir }"></new-dir-prompt>
<rename-prompt v-if="showRename" :class="{ active: showRename }"></rename-prompt>
<delete-prompt v-if="showDelete" :class="{ active: showDelete }"></delete-prompt>
<info-prompt v-if="showInfo" :class="{ active: showInfo }"></info-prompt>
<move-prompt v-if="showMove" :class="{ active: showMove }"></move-prompt>
<help v-show="showHelp" :class="{ active: showHelp }"></help>
<div v-show="$store.getters.showOverlay" @click="resetPrompts" class="overlay" :class="{ active: $store.getters.showOverlay }"></div>
<footer>Served with <a rel="noopener noreferrer" href="https://github.com/hacdias/caddy-filemanager">File Manager</a>.</footer>
</div>
<router-view></router-view>
</template>
<script>
import Search from './components/Search'
import Help from './components/Help'
import Preview from './components/Preview'
import Listing from './components/Listing'
import Editor from './components/Editor'
import InfoButton from './components/InfoButton'
import InfoPrompt from './components/InfoPrompt'
import DeleteButton from './components/DeleteButton'
import DeletePrompt from './components/DeletePrompt'
import RenameButton from './components/RenameButton'
import RenamePrompt from './components/RenamePrompt'
import UploadButton from './components/UploadButton'
import DownloadButton from './components/DownloadButton'
import DownloadPrompt from './components/DownloadPrompt'
import SwitchButton from './components/SwitchViewButton'
import MoveButton from './components/MoveButton'
import MovePrompt from './components/MovePrompt'
import NewFilePrompt from './components/NewFilePrompt'
import NewDirPrompt from './components/NewDirPrompt'
import css from './utils/css'
import {mapGetters, mapState} from 'vuex'
function updateColumnSizes () {
let columns = Math.floor(document.querySelector('main').offsetWidth / 300)
let items = css(['#listing.mosaic .item', '.mosaic#listing .item'])
if (columns === 0) columns = 1
items.style.width = `calc(${100 / columns}% - 1em)`
}
export default {
name: 'app',
components: {
Search,
Preview,
Listing,
Editor,
InfoButton,
InfoPrompt,
Help,
DeleteButton,
DeletePrompt,
RenameButton,
RenamePrompt,
DownloadButton,
DownloadPrompt,
UploadButton,
SwitchButton,
MoveButton,
MovePrompt,
NewFilePrompt,
NewDirPrompt
},
computed: {
...mapGetters(['selectedCount']),
...mapState([
'req',
'user',
'baseURL',
'multiple',
'showInfo',
'showHelp',
'showDelete',
'showRename',
'showMove',
'showNewFile',
'showNewDir',
'showDownload'
])
},
data: function () {
return {
plugins: []
}
},
mounted: function () {
updateColumnSizes()
window.addEventListener('resize', updateColumnSizes)
if (window.plugins !== undefined || window.plugins !== null) {
this.plugins = window.plugins
}
document.title = this.req.data.name
window.history.replaceState({
url: window.location.pathname,
name: document.title
}, document.title, window.location.pathname)
window.addEventListener('popstate', (event) => {
event.preventDefault()
event.stopPropagation()
this.$store.commit('multiple', false)
this.$store.commit('resetSelected')
this.$store.commit('resetPrompts')
let request = new window.XMLHttpRequest()
request.open('GET', event.state.url, true)
request.setRequestHeader('Accept', 'application/json')
request.onload = () => {
if (request.status === 200) {
let req = JSON.parse(request.responseText)
this.$store.commit('updateRequest', req)
document.title = event.state.name
} else {
console.log(request.responseText)
}
}
request.onerror = (error) => { console.log(error) }
request.send()
})
window.addEventListener('keydown', (event) => {
// Esc!
if (event.keyCode === 27) {
this.$store.commit('resetPrompts')
// Unselect all files and folders.
if (this.req.kind === 'listing') {
let items = document.getElementsByClassName('item')
Array.from(items).forEach(link => {
link.setAttribute('aria-selected', false)
})
this.$store.commit('resetSelected')
}
return
}
// Del!
if (event.keyCode === 46) {
if (this.showDeleteButton()) {
this.$store.commit('showDelete', true)
}
}
// F1!
if (event.keyCode === 112) {
event.preventDefault()
this.$store.commit('showHelp', true)
}
// F2!
if (event.keyCode === 113) {
if (this.showRenameButton()) {
this.$store.commit('showRename', true)
}
}
// CTRL + S
if (event.ctrlKey || event.metaKey) {
switch (String.fromCharCode(event.which).toLowerCase()) {
case 's':
event.preventDefault()
if (this.req.kind !== 'editor') {
window.location = '?download=true'
return
}
// TODO: save file on editor!
}
}
})
let loading = document.getElementById('loading')
loading.classList.add('done')
setTimeout(function () {
loading.parentNode.removeChild(loading)
}, 200)
},
methods: {
showUpload: function () {
if (this.req.kind === 'editor') return false
return this.user.allowNew
},
showDeleteButton: function () {
if (this.req.kind === 'listing') {
if (this.selectedCount === 0) {
return false
}
return this.user.allowEdit
}
return this.user.allowEdit
},
showRenameButton: function () {
if (this.req.kind === 'listing') {
if (this.selectedCount === 1) {
return this.user.allowEdit
}
return false
}
return this.user.allowEdit
},
showMoveButton: function () {
if (this.req.kind !== 'listing') {
return false
}
if (this.selectedCount > 0) {
return this.user.allowEdit
}
return false
},
resetPrompts: function () {
this.$store.commit('resetPrompts')
}
}
}
</script>

View File

@ -0,0 +1,9 @@
<template>
<div>Files</div>
</template>
<script>
export default {
name: 'files'
}
</script>

View File

@ -0,0 +1,117 @@
<template>
<div id="login">
<form @submit="submit">
<img src="../assets/logo.svg" alt="File Manager">
<h1>File Manager</h1>
<div v-if="wrong" class="wrong">Wrong credentials</div>
<input type="text" v-model="username" placeholder="Username">
<input type="password" v-model="password" placeholder="Password">
<input type="submit" value="Login">
</form>
</div>
</template>
<script>
import auth from '@/utils/auth'
export default {
name: 'login',
data: function () {
return {
wrong: false,
username: '',
password: ''
}
},
methods: {
submit: function (event) {
event.preventDefault()
event.stopPropagation()
let redirect = this.$route.query.redirect
if (redirect === '') {
redirect = this.$store.state.baseURL + '/files/'
}
auth.login(this.username, this.password)
.then(() => {
this.$router.push({ path: redirect })
})
.catch(e => {
console.log(e)
this.wrong = true
})
}
}
}
</script>
<style>
#login {
background: #fff;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
#login img {
width: 4em;
height: 4em;
margin: 0 auto;
display: block;
}
#login h1 {
text-align: center;
font-size: 2.5em;
margin: .4em 0 .67em;
}
#login form {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: 16em;
width: 90%;
}
#login input {
width: 100%;
width: 100%;
margin: .5em 0 0;
}
#login .wrong {
background: #F44336;
color: #fff;
padding: .5em;
text-align: center;
animation: .2s opac forwards;
}
@keyframes opac {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
#login input[type="text"],
#login input[type="password"] {
padding: .5em 1em;
border: 1px solid #e9e9e9;
transition: .2s ease border;
color: #333;
}
#login input[type="text"]:hover,
#login input[type="password"]:hover {
border-color: #9f9f9f;
}
</style>

View File

@ -0,0 +1,290 @@
<template>
<div :class="{ multiple }">
<header>
<div>
<img src="../assets/logo.svg" alt="File Manager">
<search></search>
</div>
<div>
<rename-button v-show="showRenameButton()"></rename-button>
<move-button v-show="showMoveButton()"></move-button>
<delete-button v-show="showDeleteButton()"></delete-button>
<switch-button v-show="req.kind !== 'editor'"></switch-button>
<download-button></download-button>
<upload-button v-show="showUpload()"></upload-button>
<info-button></info-button>
<button v-show="req.kind === 'listing'" @click="$store.commit('multiple', true)" aria-label="Select multiple" class="action">
<i class="material-icons">check_circle</i>
<span>Select</span>
</button>
</div>
</header>
<nav>
<a class="action" :href="baseURL + '/'">
<i class="material-icons">folder</i>
<span>My Files</span>
</a>
<div v-if="user.allowNew">
<button @click="$store.commit('showNewDir', true)" aria-label="New directory" title="New directory" class="action">
<i class="material-icons">create_new_folder</i>
<span>New folder</span>
</button>
<button @click="$store.commit('showNewFile', true)" aria-label="New file" title="New file" class="action">
<i class="material-icons">note_add</i>
<span>New file</span>
</button>
</div>
<div v-for="plugin in plugins">
<button v-for="action in plugin.sidebar" @click="action.click" :aria-label="action.name" :title="action.name" class="action">
<i class="material-icons">{{ action.icon }}</i>
<span>{{ action.name }}</span>
</button>
</div>
<button @click="logout" class="action" id="logout" tabindex="0" role="button" aria-label="Log out">
<i class="material-icons" title="Logout">exit_to_app</i>
<span>Logout</span>
</button>
</nav>
<main>
<editor v-if="req.kind === 'editor'"></editor>
<listing v-if="req.kind === 'listing'"></listing>
<preview v-if="req.kind === 'preview'"></preview>
</main>
<download-prompt v-if="showDownload" :class="{ active: showDownload }"></download-prompt>
<new-file-prompt v-if="showNewFile" :class="{ active: showNewFile }"></new-file-prompt>
<new-dir-prompt v-if="showNewDir" :class="{ active: showNewDir }"></new-dir-prompt>
<rename-prompt v-if="showRename" :class="{ active: showRename }"></rename-prompt>
<delete-prompt v-if="showDelete" :class="{ active: showDelete }"></delete-prompt>
<info-prompt v-if="showInfo" :class="{ active: showInfo }"></info-prompt>
<move-prompt v-if="showMove" :class="{ active: showMove }"></move-prompt>
<help v-show="showHelp" :class="{ active: showHelp }"></help>
<div v-show="$store.getters.showOverlay" @click="resetPrompts" class="overlay" :class="{ active: $store.getters.showOverlay }"></div>
<footer>Served with <a rel="noopener noreferrer" href="https://github.com/hacdias/caddy-filemanager">File Manager</a>.</footer>
</div>
</template>
<script>
import Search from './Search'
import Help from './Help'
import Preview from './Preview'
import Listing from './Listing'
import Editor from './Editor'
import InfoButton from './InfoButton'
import InfoPrompt from './InfoPrompt'
import DeleteButton from './DeleteButton'
import DeletePrompt from './DeletePrompt'
import RenameButton from './RenameButton'
import RenamePrompt from './RenamePrompt'
import UploadButton from './UploadButton'
import DownloadButton from './DownloadButton'
import DownloadPrompt from './DownloadPrompt'
import SwitchButton from './SwitchViewButton'
import MoveButton from './MoveButton'
import MovePrompt from './MovePrompt'
import NewFilePrompt from './NewFilePrompt'
import NewDirPrompt from './NewDirPrompt'
import css from '@/utils/css'
import auth from '@/utils/auth'
import {mapGetters, mapState} from 'vuex'
function updateColumnSizes () {
let columns = Math.floor(document.querySelector('main').offsetWidth / 300)
let items = css(['#listing.mosaic .item', '.mosaic#listing .item'])
if (columns === 0) columns = 1
items.style.width = `calc(${100 / columns}% - 1em)`
}
export default {
name: 'main',
components: {
Search,
Preview,
Listing,
Editor,
InfoButton,
InfoPrompt,
Help,
DeleteButton,
DeletePrompt,
RenameButton,
RenamePrompt,
DownloadButton,
DownloadPrompt,
UploadButton,
SwitchButton,
MoveButton,
MovePrompt,
NewFilePrompt,
NewDirPrompt
},
computed: {
...mapGetters(['selectedCount']),
...mapState([
'req',
'user',
'baseURL',
'multiple',
'showInfo',
'showHelp',
'showDelete',
'showRename',
'showMove',
'showNewFile',
'showNewDir',
'showDownload'
])
},
data: function () {
return {
plugins: []
}
},
mounted: function () {
updateColumnSizes()
window.addEventListener('resize', updateColumnSizes)
if (window.plugins !== undefined || window.plugins !== null) {
this.plugins = window.plugins
}
document.title = this.req.data.name
window.history.replaceState({
url: window.location.pathname,
name: document.title
}, document.title, window.location.pathname)
/* window.addEventListener('popstate', (event) => {
event.preventDefault()
event.stopPropagation()
this.$store.commit('multiple', false)
this.$store.commit('resetSelected')
this.$store.commit('resetPrompts')
let request = new window.XMLHttpRequest()
request.open('GET', event.state.url, true)
request.setRequestHeader('Accept', 'application/json')
request.onload = () => {
if (request.status === 200) {
let req = JSON.parse(request.responseText)
this.$store.commit('updateRequest', req)
document.title = event.state.name
} else {
console.log(request.responseText)
}
}
request.onerror = (error) => { console.log(error) }
request.send()
}) */
window.addEventListener('keydown', (event) => {
// Esc!
if (event.keyCode === 27) {
this.$store.commit('resetPrompts')
// Unselect all files and folders.
if (this.req.kind === 'listing') {
let items = document.getElementsByClassName('item')
Array.from(items).forEach(link => {
link.setAttribute('aria-selected', false)
})
this.$store.commit('resetSelected')
}
return
}
// Del!
if (event.keyCode === 46) {
if (this.showDeleteButton()) {
this.$store.commit('showDelete', true)
}
}
// F1!
if (event.keyCode === 112) {
event.preventDefault()
this.$store.commit('showHelp', true)
}
// F2!
if (event.keyCode === 113) {
if (this.showRenameButton()) {
this.$store.commit('showRename', true)
}
}
// CTRL + S
if (event.ctrlKey || event.metaKey) {
switch (String.fromCharCode(event.which).toLowerCase()) {
case 's':
event.preventDefault()
if (this.req.kind !== 'editor') {
window.location = '?download=true'
return
}
// TODO: save file on editor!
}
}
})
},
methods: {
showUpload: function () {
if (this.req.kind === 'editor') return false
return this.user.allowNew
},
showDeleteButton: function () {
if (this.req.kind === 'listing') {
if (this.selectedCount === 0) {
return false
}
return this.user.allowEdit
}
return this.user.allowEdit
},
showRenameButton: function () {
if (this.req.kind === 'listing') {
if (this.selectedCount === 1) {
return this.user.allowEdit
}
return false
}
return this.user.allowEdit
},
showMoveButton: function () {
if (this.req.kind !== 'listing') {
return false
}
if (this.selectedCount > 0) {
return this.user.allowEdit
}
return false
},
resetPrompts: function () {
this.$store.commit('resetPrompts')
},
logout: auth.logout
}
}
</script>

View File

@ -40,6 +40,11 @@ pre {
word-wrap: break-word;
}
input, button {
outline: 0 !important;
}
input[type="submit"],
button {
border: 0;
padding: .5em 1em;
@ -53,6 +58,7 @@ button {
transition: .1s ease all;
}
input[type="submit"]:hover,
button:hover {
background-color: #1E88E5;
}

View File

@ -1,18 +1,15 @@
import Vue from 'vue'
import App from './App'
import store from './store/store'
import router from './router'
Vue.config.productionTip = false
if (window.info === undefined || window.info === null) {
window.alert('Something is wrong, please refresh!')
window.location.reload()
}
/* eslint-disable no-new */
new Vue({
el: '#app',
store,
router,
template: '<App/>',
components: { App }
})

View File

@ -0,0 +1,72 @@
import Vue from 'vue'
import Router from 'vue-router'
import Login from '@/components/Login'
import Files from '@/components/Files'
import Main from '@/components/Main'
import auth from '@/utils/auth.js'
Vue.use(Router)
const router = new Router({
base: document.querySelector('meta[name="base"]').getAttribute('content'),
mode: 'history',
routes: [
{
path: '/login',
name: 'Login',
component: Login,
beforeEnter: function (to, from, next) {
auth.loggedIn()
.then(() => {
next({ path: '/files' })
})
.catch(() => {
next()
})
}
},
{
path: '/*',
component: Main,
meta: {
requiresAuth: true
},
children: [
{
path: '/files/*',
name: 'Files',
component: Files
},
{
path: '/*',
redirect: {
name: 'Files'
}
}
]
}
]
})
router.beforeEach((to, from, next) => {
if (to.matched.some(record => record.meta.requiresAuth)) {
// this route requires auth, check if logged in
// if not, redirect to login page.
auth.loggedIn()
.then(() => {
next()
})
.catch(e => {
next({
path: '/login',
query: { redirect: to.fullPath }
})
})
return
}
next()
})
export default router

View File

@ -17,6 +17,7 @@ const mutations = {
state.showNewDir = false
state.showDownload = false
},
setUser: (state, value) => (state.user = value),
multiple: (state, value) => (state.multiple = value),
addSelected: (state, value) => (state.selected.push(value)),
removeSelected: (state, value) => {

View File

@ -6,10 +6,9 @@ import getters from './getters'
Vue.use(Vuex)
const state = {
user: window.info.user,
req: window.info.req,
webDavURL: window.info.webdavURL,
baseURL: window.info.baseURL,
user: {},
req: {},
baseURL: document.querySelector('meta[name="base"]').getAttribute('content'),
ssl: (window.location.protocol === 'https:'),
selected: [],
multiple: false,

59
_assets/src/utils/auth.js Normal file
View File

@ -0,0 +1,59 @@
import cookie from './cookie'
import store from '@/store/store'
import router from '@/router'
function parseToken (token) {
document.cookie = `auth=${token}; max-age=86400; path=${store.state.baseURL}`
let res = token.split('.')
let user = JSON.parse(window.atob(res[1]))
store.commit('setUser', user)
}
function loggedIn () {
return new Promise((resolve, reject) => {
let request = new window.XMLHttpRequest()
request.open('GET', `${store.state.baseURL}/api/auth/renew`, true)
request.setRequestHeader('Authorization', `Bearer ${cookie('auth')}`)
request.onload = () => {
if (request.status === 200) {
parseToken(request.responseText)
resolve()
} else {
reject()
}
}
request.onerror = () => reject()
request.send()
})
}
function login (user, password) {
let data = {username: user, password: password}
return new Promise((resolve, reject) => {
let request = new window.XMLHttpRequest()
request.open('POST', `${store.state.baseURL}/api/auth/get`, true)
request.onload = () => {
if (request.status === 200) {
parseToken(request.responseText)
resolve()
} else {
reject(request.responseText)
}
}
request.onerror = () => reject()
request.send(JSON.stringify(data))
})
}
function logout () {
document.cookie = `auth='nothing'; max-age=0; path=${store.state.baseURL}`
router.push({path: 'login'})
}
export default {
loggedIn: loggedIn,
login: login,
logout: logout
}

26
http.go
View File

@ -56,23 +56,17 @@ func serveHTTP(c *requestContext, w http.ResponseWriter, r *http.Request) (int,
return serveAPI(c, w, r)
}
// Checks if this request is made to the base path /files. If so,
// shows the index.html page.
if matchURL(r.URL.Path, "/files") {
w.Header().Set("x-frame-options", "SAMEORIGIN")
w.Header().Set("x-content-type", "nosniff")
w.Header().Set("x-xss-protection", "1; mode=block")
// Any other request should show the index.html file.
w.Header().Set("x-frame-options", "SAMEORIGIN")
w.Header().Set("x-content-type", "nosniff")
w.Header().Set("x-xss-protection", "1; mode=block")
return renderFile(
w,
c.fm.assets.MustString("index.html"),
"text/html",
c.fm.RootURL(),
)
}
http.Redirect(w, r, c.fm.RootURL()+"/files"+r.URL.Path, http.StatusTemporaryRedirect)
return 0, nil
return renderFile(
w,
c.fm.assets.MustString("index.html"),
"text/html",
c.fm.RootURL(),
)
}
// staticHandler handles the static assets path.