Migrated settings panel and logout button

This commit is contained in:
Fabio Manganiello 2021-02-15 22:49:13 +01:00
parent 201bb5986f
commit 748609c6f4
41 changed files with 869 additions and 92 deletions

View file

@ -1 +1 @@
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="/favicon.ico"><title>platypush</title><link href="/static/css/chunk-134ec1dc.849ccfd5.css" rel="prefetch"><link href="/static/css/chunk-13b07ca5.029dd736.css" rel="prefetch"><link href="/static/css/chunk-1653b664.5b949e24.css" rel="prefetch"><link href="/static/css/chunk-23726328.7e460329.css" rel="prefetch"><link href="/static/css/chunk-283aacba.f186cc51.css" rel="prefetch"><link href="/static/css/chunk-2ca39dde.efa1eae8.css" rel="prefetch"><link href="/static/css/chunk-2f304dee.a8a2d99a.css" rel="prefetch"><link href="/static/css/chunk-3b435dde.f186cc51.css" rel="prefetch"><link href="/static/css/chunk-487896e7.b7730bd4.css" rel="prefetch"><link href="/static/css/chunk-49211740.43a25f0f.css" rel="prefetch"><link href="/static/css/chunk-4dae396b.92b3713e.css" rel="prefetch"><link href="/static/css/chunk-5145872a.197de139.css" rel="prefetch"><link href="/static/css/chunk-53e279b3.f186cc51.css" rel="prefetch"><link href="/static/css/chunk-595ffc05.678c9c97.css" rel="prefetch"><link href="/static/css/chunk-5a1e13e4.f186cc51.css" rel="prefetch"><link href="/static/css/chunk-5d5c4530.75269c9b.css" rel="prefetch"><link href="/static/css/chunk-64076603.e451beea.css" rel="prefetch"><link href="/static/css/chunk-675c7703.75b51be7.css" rel="prefetch"><link href="/static/css/chunk-792fd41e.4d467174.css" rel="prefetch"><link href="/static/css/chunk-7fae0422.c233115f.css" rel="prefetch"><link href="/static/css/chunk-d22da0c0.7c71cffb.css" rel="prefetch"><link href="/static/css/chunk-d28a86c4.cdd32c08.css" rel="prefetch"><link href="/static/css/chunk-da9476ec.f1965e2d.css" rel="prefetch"><link href="/static/css/chunk-ee62c128.44bbe779.css" rel="prefetch"><link href="/static/js/chunk-134ec1dc.87638287.js" rel="prefetch"><link href="/static/js/chunk-13b07ca5.11833bcd.js" rel="prefetch"><link href="/static/js/chunk-1653b664.4bba37ff.js" rel="prefetch"><link href="/static/js/chunk-23726328.7a638dfb.js" rel="prefetch"><link href="/static/js/chunk-283aacba.52472391.js" rel="prefetch"><link href="/static/js/chunk-2ca39dde.bfb67629.js" rel="prefetch"><link href="/static/js/chunk-2d0cc2be.71e3fcd8.js" rel="prefetch"><link href="/static/js/chunk-2d2091df.90a98553.js" rel="prefetch"><link href="/static/js/chunk-2d21da1a.707bd994.js" rel="prefetch"><link href="/static/js/chunk-2d237d41.b4b87abb.js" rel="prefetch"><link href="/static/js/chunk-2f304dee.649e4dc7.js" rel="prefetch"><link href="/static/js/chunk-3b435dde.bd4904a1.js" rel="prefetch"><link href="/static/js/chunk-487896e7.69cdcafb.js" rel="prefetch"><link href="/static/js/chunk-49211740.e4dea096.js" rel="prefetch"><link href="/static/js/chunk-4dae396b.0ee6bb40.js" rel="prefetch"><link href="/static/js/chunk-5145872a.f0bd0577.js" rel="prefetch"><link href="/static/js/chunk-53e279b3.cf489a46.js" rel="prefetch"><link href="/static/js/chunk-595ffc05.8affd7fe.js" rel="prefetch"><link href="/static/js/chunk-5a1e13e4.287f68a0.js" rel="prefetch"><link href="/static/js/chunk-5d5c4530.f0675a96.js" rel="prefetch"><link href="/static/js/chunk-64076603.2c344ed9.js" rel="prefetch"><link href="/static/js/chunk-675c7703.7c7378cd.js" rel="prefetch"><link href="/static/js/chunk-792fd41e.aca41198.js" rel="prefetch"><link href="/static/js/chunk-7fae0422.0d9be069.js" rel="prefetch"><link href="/static/js/chunk-d22da0c0.da01e99e.js" rel="prefetch"><link href="/static/js/chunk-d28a86c4.d0c1f74e.js" rel="prefetch"><link href="/static/js/chunk-da9476ec.f8c15985.js" rel="prefetch"><link href="/static/js/chunk-ee62c128.c11fb53e.js" rel="prefetch"><link href="/static/css/app.011e7c1b.css" rel="preload" as="style"><link href="/static/css/chunk-vendors.5dad8b00.css" rel="preload" as="style"><link href="/static/js/app.7e139a4f.js" rel="preload" as="script"><link href="/static/js/chunk-vendors.948dc2e5.js" rel="preload" as="script"><link href="/static/css/chunk-vendors.5dad8b00.css" rel="stylesheet"><link href="/static/css/app.011e7c1b.css" rel="stylesheet"></head><body><noscript><strong>We're sorry but platypush doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div><script src="/static/js/chunk-vendors.948dc2e5.js"></script><script src="/static/js/app.7e139a4f.js"></script></body></html> <!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="/favicon.ico"><title>platypush</title><link href="/static/css/chunk-134ec1dc.849ccfd5.css" rel="prefetch"><link href="/static/css/chunk-13b07ca5.029dd736.css" rel="prefetch"><link href="/static/css/chunk-1653b664.5b949e24.css" rel="prefetch"><link href="/static/css/chunk-23726328.7e460329.css" rel="prefetch"><link href="/static/css/chunk-2ca39dde.efa1eae8.css" rel="prefetch"><link href="/static/css/chunk-2f304dee.a8a2d99a.css" rel="prefetch"><link href="/static/css/chunk-487896e7.b7730bd4.css" rel="prefetch"><link href="/static/css/chunk-49211740.43a25f0f.css" rel="prefetch"><link href="/static/css/chunk-4dae396b.92b3713e.css" rel="prefetch"><link href="/static/css/chunk-5145872a.197de139.css" rel="prefetch"><link href="/static/css/chunk-595ffc05.678c9c97.css" rel="prefetch"><link href="/static/css/chunk-64076603.e451beea.css" rel="prefetch"><link href="/static/css/chunk-675c7703.75b51be7.css" rel="prefetch"><link href="/static/css/chunk-792fd41e.4d467174.css" rel="prefetch"><link href="/static/css/chunk-7fae0422.c233115f.css" rel="prefetch"><link href="/static/css/chunk-d22da0c0.7c71cffb.css" rel="prefetch"><link href="/static/css/chunk-d28a86c4.cdd32c08.css" rel="prefetch"><link href="/static/css/chunk-da9476ec.f1965e2d.css" rel="prefetch"><link href="/static/css/chunk-ee62c128.44bbe779.css" rel="prefetch"><link href="/static/js/chunk-134ec1dc.87638287.js" rel="prefetch"><link href="/static/js/chunk-13b07ca5.11833bcd.js" rel="prefetch"><link href="/static/js/chunk-1653b664.4bba37ff.js" rel="prefetch"><link href="/static/js/chunk-23726328.7a638dfb.js" rel="prefetch"><link href="/static/js/chunk-2ca39dde.bfb67629.js" rel="prefetch"><link href="/static/js/chunk-2d0b270c.82d7f897.js" rel="prefetch"><link href="/static/js/chunk-2d0c1eb0.2fc91e77.js" rel="prefetch"><link href="/static/js/chunk-2d0cc2be.71e3fcd8.js" rel="prefetch"><link href="/static/js/chunk-2d2091df.90a98553.js" rel="prefetch"><link href="/static/js/chunk-2d21b0dc.465e6abf.js" rel="prefetch"><link href="/static/js/chunk-2d21da1a.707bd994.js" rel="prefetch"><link href="/static/js/chunk-2d231217.5ff519da.js" rel="prefetch"><link href="/static/js/chunk-2d237d41.b4b87abb.js" rel="prefetch"><link href="/static/js/chunk-2f304dee.649e4dc7.js" rel="prefetch"><link href="/static/js/chunk-487896e7.69cdcafb.js" rel="prefetch"><link href="/static/js/chunk-49211740.e4dea096.js" rel="prefetch"><link href="/static/js/chunk-4dae396b.0ee6bb40.js" rel="prefetch"><link href="/static/js/chunk-5145872a.f0bd0577.js" rel="prefetch"><link href="/static/js/chunk-595ffc05.8affd7fe.js" rel="prefetch"><link href="/static/js/chunk-64076603.2c344ed9.js" rel="prefetch"><link href="/static/js/chunk-675c7703.7c7378cd.js" rel="prefetch"><link href="/static/js/chunk-792fd41e.aca41198.js" rel="prefetch"><link href="/static/js/chunk-7fae0422.0d9be069.js" rel="prefetch"><link href="/static/js/chunk-d22da0c0.da01e99e.js" rel="prefetch"><link href="/static/js/chunk-d28a86c4.d0c1f74e.js" rel="prefetch"><link href="/static/js/chunk-da9476ec.f8c15985.js" rel="prefetch"><link href="/static/js/chunk-ee62c128.c11fb53e.js" rel="prefetch"><link href="/static/css/app.a835db3a.css" rel="preload" as="style"><link href="/static/css/chunk-vendors.5dad8b00.css" rel="preload" as="style"><link href="/static/js/app.2721d165.js" rel="preload" as="script"><link href="/static/js/chunk-vendors.948dc2e5.js" rel="preload" as="script"><link href="/static/css/chunk-vendors.5dad8b00.css" rel="stylesheet"><link href="/static/css/app.a835db3a.css" rel="stylesheet"></head><body><noscript><strong>We're sorry but platypush doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div><script src="/static/js/chunk-vendors.948dc2e5.js"></script><script src="/static/js/app.2721d165.js"></script></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,2 @@
(window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["chunk-2d0b270c"],{"23b7":function(e,a,n){"use strict";n.r(a);var c=n("7a23"),d=Object(c["K"])("data-v-52effd7c"),t=d((function(e,a,n,d,t,i){var p=Object(c["z"])("Media");return Object(c["r"])(),Object(c["e"])(p,{"plugin-name":"media.mpv"})})),i=n("3951"),p={name:"MediaMpv",components:{Media:i["default"]}};p.render=t,p.__scopeId="data-v-52effd7c";a["default"]=p}}]);
//# sourceMappingURL=chunk-2d0b270c.82d7f897.js.map

View file

@ -0,0 +1 @@
{"version":3,"sources":["webpack:///./src/components/panels/MediaMpv/Index.vue","webpack:///./src/components/panels/MediaMpv/Index.vue?1b60"],"names":["plugin-name","name","components","Media","render","__scopeId"],"mappings":"8PACE,eAAiC,GAA1BA,cAAY,iB,YAMN,GACbC,KAAM,WACNC,WAAY,CAACC,MAAA,eCNf,EAAOC,OAAS,EAChB,EAAOC,UAAY,kBAEJ","file":"static/js/chunk-2d0b270c.82d7f897.js","sourcesContent":["<template>\n <Media plugin-name=\"media.mpv\" />\n</template>\n\n<script>\nimport Media from '@/components/panels/Media/Index'\n\nexport default {\n name: \"MediaMpv\",\n components: {Media},\n}\n</script>\n\n<style scoped>\n\n</style>\n","import { render } from \"./Index.vue?vue&type=template&id=52effd7c&scoped=true\"\nimport script from \"./Index.vue?vue&type=script&lang=js\"\nexport * from \"./Index.vue?vue&type=script&lang=js\"\nscript.render = render\nscript.__scopeId = \"data-v-52effd7c\"\n\nexport default script"],"sourceRoot":""}

View file

@ -0,0 +1,2 @@
(window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["chunk-2d0c1eb0"],{"47a8":function(e,a,n){"use strict";n.r(a);var t=n("7a23"),c=Object(t["K"])("data-v-08ab61b7"),d=c((function(e,a,n,c,d,b){var r=Object(t["z"])("Media");return Object(t["r"])(),Object(t["e"])(r,{"plugin-name":"media.mplayer"})})),b=n("3951"),r={name:"MediaMplayer",components:{Media:b["default"]}};r.render=d,r.__scopeId="data-v-08ab61b7";a["default"]=r}}]);
//# sourceMappingURL=chunk-2d0c1eb0.2fc91e77.js.map

View file

@ -0,0 +1 @@
{"version":3,"sources":["webpack:///./src/components/panels/MediaMplayer/Index.vue","webpack:///./src/components/panels/MediaMplayer/Index.vue?3a6b"],"names":["plugin-name","name","components","Media","render","__scopeId"],"mappings":"8PACE,eAAqC,GAA9BA,cAAY,qB,YAMN,GACbC,KAAM,eACNC,WAAY,CAACC,MAAA,eCNf,EAAOC,OAAS,EAChB,EAAOC,UAAY,kBAEJ","file":"static/js/chunk-2d0c1eb0.2fc91e77.js","sourcesContent":["<template>\n <Media plugin-name=\"media.mplayer\" />\n</template>\n\n<script>\nimport Media from '@/components/panels/Media/Index'\n\nexport default {\n name: \"MediaMplayer\",\n components: {Media},\n}\n</script>\n\n<style scoped>\n\n</style>\n","import { render } from \"./Index.vue?vue&type=template&id=08ab61b7&scoped=true\"\nimport script from \"./Index.vue?vue&type=script&lang=js\"\nexport * from \"./Index.vue?vue&type=script&lang=js\"\nscript.render = render\nscript.__scopeId = \"data-v-08ab61b7\"\n\nexport default script"],"sourceRoot":""}

View file

@ -0,0 +1,2 @@
(window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["chunk-2d21b0dc"],{bdae:function(e,a,n){"use strict";n.r(a);var c=n("7a23"),d=Object(c["K"])("data-v-9233e214"),t=d((function(e,a,n,d,t,i){var o=Object(c["z"])("Media");return Object(c["r"])(),Object(c["e"])(o,{"plugin-name":"media.vlc"})})),i=n("3951"),o={name:"MediaVlc",components:{Media:i["default"]}};o.render=t,o.__scopeId="data-v-9233e214";a["default"]=o}}]);
//# sourceMappingURL=chunk-2d21b0dc.465e6abf.js.map

View file

@ -0,0 +1 @@
{"version":3,"sources":["webpack:///./src/components/panels/MediaVlc/Index.vue","webpack:///./src/components/panels/MediaVlc/Index.vue?f087"],"names":["plugin-name","name","components","Media","render","__scopeId"],"mappings":"4PACE,eAAiC,GAA1BA,cAAY,iB,YAMN,GACbC,KAAM,WACNC,WAAY,CAACC,MAAA,eCNf,EAAOC,OAAS,EAChB,EAAOC,UAAY,kBAEJ","file":"static/js/chunk-2d21b0dc.465e6abf.js","sourcesContent":["<template>\n <Media plugin-name=\"media.vlc\" />\n</template>\n\n<script>\nimport Media from '@/components/panels/Media/Index'\n\nexport default {\n name: \"MediaVlc\",\n components: {Media},\n}\n</script>\n\n<style scoped>\n\n</style>\n","import { render } from \"./Index.vue?vue&type=template&id=9233e214&scoped=true\"\nimport script from \"./Index.vue?vue&type=script&lang=js\"\nexport * from \"./Index.vue?vue&type=script&lang=js\"\nscript.render = render\nscript.__scopeId = \"data-v-9233e214\"\n\nexport default script"],"sourceRoot":""}

View file

@ -0,0 +1,2 @@
(window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["chunk-2d231217"],{eede:function(e,a,d){"use strict";d.r(a);var n=d("7a23"),c=Object(n["K"])("data-v-7264d7fc"),t=c((function(e,a,d,c,t,i){var o=Object(n["z"])("Media");return Object(n["r"])(),Object(n["e"])(o,{"plugin-name":"media.omxplayer"})})),i=d("3951"),o={name:"MediaMpv",components:{Media:i["default"]}};o.render=t,o.__scopeId="data-v-7264d7fc";a["default"]=o}}]);
//# sourceMappingURL=chunk-2d231217.5ff519da.js.map

View file

@ -0,0 +1 @@
{"version":3,"sources":["webpack:///./src/components/panels/MediaOmxplayer/Index.vue","webpack:///./src/components/panels/MediaOmxplayer/Index.vue?279c"],"names":["plugin-name","name","components","Media","render","__scopeId"],"mappings":"4PACE,eAAuC,GAAhCA,cAAY,uB,YAMN,GACbC,KAAM,WACNC,WAAY,CAACC,MAAA,eCNf,EAAOC,OAAS,EAChB,EAAOC,UAAY,kBAEJ","file":"static/js/chunk-2d231217.5ff519da.js","sourcesContent":["<template>\n <Media plugin-name=\"media.omxplayer\" />\n</template>\n\n<script>\nimport Media from '@/components/panels/Media/Index'\n\nexport default {\n name: \"MediaMpv\",\n components: {Media},\n}\n</script>\n\n<style scoped>\n\n</style>\n","import { render } from \"./Index.vue?vue&type=template&id=7264d7fc&scoped=true\"\nimport script from \"./Index.vue?vue&type=script&lang=js\"\nexport * from \"./Index.vue?vue&type=script&lang=js\"\nscript.render = render\nscript.__scopeId = \"data-v-7264d7fc\"\n\nexport default script"],"sourceRoot":""}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,2 +0,0 @@
(window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["chunk-5d5c4530"],{"524a":function(t,e,n){"use strict";var i=n("7a23"),c=Object(i["K"])("data-v-3cb494ce");Object(i["u"])("data-v-3cb494ce");var o={key:0,class:"col-1 icon"};Object(i["s"])();var s=c((function(t,e,n,c,s,l){return Object(i["r"])(),Object(i["e"])("div",{class:"row item",onClick:e[1]||(e[1]=function(){return l.clicked.apply(l,arguments)})},[n.iconClass?(Object(i["r"])(),Object(i["e"])("div",o,[Object(i["h"])("i",{class:n.iconClass},null,2)])):Object(i["f"])("",!0),Object(i["h"])("div",{class:["text",{"col-11":null!=n.iconClass}],textContent:Object(i["C"])(n.text)},null,10,["textContent"])])})),l={name:"DropdownItem",props:{iconClass:{type:String},text:{type:String},disabled:{type:Boolean,default:!1}},methods:{clicked:function(t){this.$parent.$emit("click",t),this.$parent.visible=!1}}};n("c9a1");l.render=s,l.__scopeId="data-v-3cb494ce";e["a"]=l},5769:function(t,e,n){},"64b0":function(t,e,n){},"87ac":function(t,e,n){"use strict";n("5769")},ab0f:function(t,e,n){"use strict";var i=n("7a23"),c=Object(i["K"])("data-v-00fa59b4");Object(i["u"])("data-v-00fa59b4");var o={class:"dropdown-container",ref:"container"};Object(i["s"])();var s=c((function(t,e,n,c,s,l){return Object(i["r"])(),Object(i["e"])("div",o,[Object(i["h"])("button",{title:n.title,ref:"button",onClick:e[1]||(e[1]=Object(i["J"])((function(t){return l.toggle(t)}),["stop"]))},[n.iconClass?(Object(i["r"])(),Object(i["e"])("i",{key:0,class:["icon",n.iconClass]},null,2)):Object(i["f"])("",!0),n.text?(Object(i["r"])(),Object(i["e"])("span",{key:1,class:"text",textContent:Object(i["C"])(n.text)},null,8,["textContent"])):Object(i["f"])("",!0)],8,["title"]),Object(i["h"])("div",{class:["dropdown fade-in",{hidden:!s.visible}],id:n.id,ref:"dropdown"},[Object(i["y"])(t.$slots,"default")],10,["id"])],512)})),l={name:"Dropdown",emits:["click"],props:{id:{type:String},items:{type:Array,default:function(){return[]}},iconClass:{type:String,default:"fa fa-ellipsis-h"},text:{type:String},title:{type:String}},data:function(){return{visible:!1}},methods:{documentClickHndl:function(t){if(this.visible){var e=t.target;while(e){if(!this.$refs.dropdown)break;if(e===this.$refs.dropdown.element)return;e=e.parentElement}this.close()}},close:function(){this.visible=!1,document.removeEventListener("click",this.documentClickHndl)},open:function(){var t=this;document.addEventListener("click",this.documentClickHndl),this.visible=!0,setTimeout((function(){var e=t.$refs.dropdown;e.style.left=0,e.style.top=parseFloat(getComputedStyle(t.$refs.button).height)+"px",e.getBoundingClientRect().left>window.innerWidth/2&&(e.style.left=-e.clientWidth+parseFloat(getComputedStyle(t.$refs.button).width)+"px"),e.getBoundingClientRect().top>window.innerHeight/2&&(e.style.top=-e.clientHeight+parseFloat(getComputedStyle(t.$refs.button).height)+"px")}),10)},toggle:function(t){t.stopPropagation(),this.$emit("click"),this.visible?this.close():this.open()}}};n("87ac");l.render=s,l.__scopeId="data-v-00fa59b4";e["a"]=l},c9a1:function(t,e,n){"use strict";n("64b0")}}]);
//# sourceMappingURL=chunk-5d5c4530.f0675a96.js.map

File diff suppressed because one or more lines are too long

View file

@ -1,5 +1,6 @@
<script> <script>
import Api from "@/utils/Api"; import Api from "@/utils/Api";
import Cookies from "@/utils/Cookies";
import DateTime from "@/utils/DateTime"; import DateTime from "@/utils/DateTime";
import Events from "@/utils/Events"; import Events from "@/utils/Events";
import Notification from "@/utils/Notification"; import Notification from "@/utils/Notification";
@ -8,6 +9,6 @@ import Types from "@/utils/Types";
export default { export default {
name: "Utils", name: "Utils",
mixins: [Api, Notification, Events, DateTime, Screen, Types], mixins: [Api, Cookies, Notification, Events, DateTime, Screen, Types],
} }
</script> </script>

View file

@ -74,6 +74,10 @@ export default {
this.isVisible = false this.isVisible = false
}, },
hide() {
this.close()
},
show() { show() {
this.prevVisible = this.isVisible this.prevVisible = this.isVisible
this.isVisible = true this.isVisible = true

View file

@ -5,7 +5,7 @@
<span class="hostname" v-if="hostname" v-text="hostname" /> <span class="hostname" v-if="hostname" v-text="hostname" />
</div> </div>
<ul> <ul class="plugins">
<li v-for="name in Object.keys(panels).sort()" :key="name" class="entry" :class="{selected: name === selectedPanel}" <li v-for="name in Object.keys(panels).sort()" :key="name" class="entry" :class="{selected: name === selectedPanel}"
:title="name" @click="onItemClick(name)"> :title="name" @click="onItemClick(name)">
<a :href="`/#${name}`"> <a :href="`/#${name}`">
@ -18,6 +18,28 @@
</a> </a>
</li> </li>
</ul> </ul>
<ul class="footer">
<li :class="{selected: selectedPanel === 'settings'}" title="Settings" @click="onItemClick('settings')">
<!--suppress HtmlUnknownAnchorTarget -->
<a href="/#settings">
<span class="icon">
<i class="fa fa-cog" />
</span>
<span class="name" v-if="!collapsed">Settings</span>
</a>
</li>
<li title="Logout" @click="onItemClick('logout')">
<!--suppress HtmlUnknownTarget -->
<a href="/logout">
<span class="icon">
<i class="fas fa-sign-out-alt" />
</span>
<span class="name" v-if="!collapsed">Logout</span>
</a>
</li>
</ul>
</nav> </nav>
</template> </template>
@ -68,6 +90,10 @@ export default {
<!--suppress SassScssResolvedByNameOnly --> <!--suppress SassScssResolvedByNameOnly -->
<style lang="scss" scoped> <style lang="scss" scoped>
$toggler-height: 2em;
$footer-collapsed-height: 4em;
$footer-expanded-height: 8.25em;
nav { nav {
@media screen and (max-width: $tablet) { @media screen and (max-width: $tablet) {
width: 100%; width: 100%;
@ -150,6 +176,18 @@ nav {
} }
} }
.plugins {
height: calc(100% - #{$toggler-height} - #{$footer-expanded-height} - .75em);
overflow: auto;
}
.footer {
height: $footer-expanded-height;
background: none;
padding: 0;
margin: 0;
}
&.collapsed { &.collapsed {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -180,14 +218,31 @@ nav {
} }
.toggler { .toggler {
height: $toggler-height;
text-align: center; text-align: center;
} }
.plugins {
height: calc(100% - #{$toggler-height} - #{$footer-collapsed-height});
overflow: auto;
}
.footer {
height: $footer-collapsed-height;
padding: 0;
margin-bottom: .5em;
}
@media screen and (max-width: $tablet) {
.footer {
display: none;
}
}
ul { ul {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
flex-grow: 1;
li { li {
box-shadow: none; box-shadow: none;

View file

@ -0,0 +1,114 @@
<template>
<div class="settings-container">
<header>
<div class="col-8">
<select title="View" @change="selectedView = $event.target.value">
<option value="users" :selected="selectedView === 'users'">Users</option>
<option value="token" :selected="selectedView === 'token'">Generate Token</option>
</select>
</div>
<div class="col-4 pull-right">
<button title="Add User" @click="$refs.usersView.$refs.addUserModal.show()" v-if="selectedView === 'users'">
<i class="fa fa-plus" />
</button>
</div>
</header>
<main>
<Users :session-token="sessionToken" :current-user="currentUser"
v-if="selectedView === 'users'" ref="usersView" />
<Token :session-token="sessionToken" :current-user="currentUser"
v-else-if="selectedView === 'token'" ref="tokenView" />
</main>
</div>
</template>
<script>
import Token from "@/components/panels/Settings/Token";
import Users from "@/components/panels/Settings/Users";
import Utils from "@/Utils";
export default {
name: "Settings",
components: {Users, Token},
mixins: [Utils],
data() {
return {
selectedView: 'users',
currentUser: null,
sessionToken: null,
}
},
methods: {
async refresh() {
this.sessionToken = this.getCookies()['session_token']
this.currentUser = await this.request('user.get_user_by_session', {session_token: this.sessionToken})
}
},
mounted() {
this.refresh()
}
}
</script>
<style lang="scss">
$header-height: 3em;
.settings-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
header {
width: 100%;
height: $header-height;
display: flex;
background: $background-color;
box-shadow: $border-shadow-bottom;
padding: .5em;
select {
width: 100%;
}
button {
padding-top: .25em;
}
}
main {
height: calc(100% - #{$header-height});
overflow: auto;
}
button {
background: none;
border: none;
&:hover {
border: none;
color: $default-hover-fg;
}
}
form {
padding: 0;
border: none;
border-radius: 0;
box-shadow: none;
input {
margin-bottom: 1em;
}
}
input[type=password] {
border-radius: 1em;
}
}
</style>

View file

@ -0,0 +1,234 @@
<template>
<div class="token-container">
<Loading v-if="loading" />
<Modal ref="tokenModal">
<div class="token-container">
<label>
This is your generated token. Treat it carefully and do not share it with untrusted parties.<br/>
Also, make sure to save it - it WILL NOT be displayed again.
<textarea class="token" v-text="token" @focus="onTokenSelect" />
</label>
</div>
</Modal>
<div class="body">
<div class="description">
<p>Generate a JWT authentication token that can be used for API calls to the <tt>/execute</tt> endpoint.</p><br/>
<p>You can include the token in your requests in any of the following ways:</p>
<ul>
<li>Specify it on the <tt>Authorization: Bearer</tt> header;</li>
<li>Specify it on the <tt>X-Token</tt> header;</li>
<li>Specify it as a URL parameter: <tt>http://site:8008/execute?token=...</tt>;</li>
<li>Specify it on the body of your JSON request: <tt>{"type":"request", "action", "...", "token":"..."}</tt>.</li>
</ul>
Confirm your credentials in order to generate a new token.
</div>
<div class="form-container">
<form @submit.prevent="generateToken" ref="generateTokenForm">
<label>
Username
<input type="text" name="username" :value="currentUser.username" disabled>
</label>
<label>
Password
<input type="password" name="password">
</label>
<label>
Token validity in days
<input type="text" name="validityDays">
<span class="note">
Decimal values are also supported (e.g. <i>0.5</i> to identify 6 hours). An empty or zero value means that
the token has no expiry date.
</span>
</label>
<input type="submit" value="Generate token">
</form>
</div>
</div>
</div>
</template>
<script>
import axios from "axios";
import Loading from "@/components/Loading";
import Utils from "@/Utils";
import Modal from "@/components/Modal";
export default {
name: "Token",
components: {Modal, Loading},
mixins: [Utils],
props: {
currentUser: {
type: Object,
required: true,
},
},
data() {
return {
loading: false,
token: null,
}
},
methods: {
async generateToken(event) {
const username = this.currentUser.username
const password = event.target.password.value
let validityDays = event.target.validityDays?.length ? parseInt(event.target.validityDays.value) : 0
if (!validityDays)
validityDays = null
this.loading = true
try {
this.token = (await axios.post('/auth', {
username: username,
password: password,
expiry_days: validityDays,
})).data.token
if (this.token?.length)
this.$refs.tokenModal.show()
} catch (e) {
console.error(e.toString())
this.notify({
text: e.toString(),
error: true,
})
} finally {
this.loading = false
}
},
onTokenSelect(event) {
event.target.select()
document.execCommand('copy')
this.notify({
text: 'Token copied to clipboard',
image: {
iconClass: 'fa fa-check',
}
})
},
}
}
</script>
<style lang="scss">
.token-container {
width: 100%;
display: flex;
margin-top: .15em;
.body {
background: $background-color;
display: flex;
.description {
text-align: left;
padding: 1em;
}
}
ul {
margin: 1em .5em;
li {
list-style: initial;
}
}
.form-container {
display: flex;
}
form {
max-width: 250pt;
.note {
display: block;
font-size: .75em;
margin: -.75em 0 2em 0;
}
}
input[type=password] {
border-radius: 1em;
}
.modal {
.content {
width: 90%;
}
.body {
margin-top: 0;
}
}
.token-container {
label {
display: flex;
flex-direction: column;
}
textarea {
height: 10em;
margin-top: 1em;
border-radius: 1em;
}
}
}
@media screen and (max-width: calc(#{$desktop} - 1px)) {
.token-container {
.body {
flex-direction: column;
}
}
.form-container {
justify-content: center;
box-shadow: $border-shadow-top;
margin-top: -1em;
padding-top: 1em;
}
}
@media screen and (min-width: $desktop) {
.token-container {
justify-content: center;
align-items: center;
.description {
width: 50%;
}
.form-container {
width: 50%;
justify-content: right;
padding: 1em;
}
.body {
max-width: 650pt;
flex-direction: row;
justify-content: left;
margin-top: 1.5em;
border-radius: 1em;
border: $default-border-2;
}
}
}
</style>

View file

@ -0,0 +1,313 @@
<template>
<Loading v-if="loading" />
<Modal ref="addUserModal" title="Add User">
<form action="#" method="POST" ref="addUserForm" @submit="createUser">
<label>
<input type="text" name="username" placeholder="Username" :disabled="commandRunning">
</label>
<label>
<input type="password" name="password" placeholder="Password" :disabled="commandRunning">
</label>
<label>
<input type="password" name="confirm_password" placeholder="Confirm password" :disabled="commandRunning">
</label>
<input type="submit" value="Create User" :disabled="commandRunning">
</form>
</Modal>
<Modal ref="changePasswordModal" title="Change Password">
<form action="#" method="POST" ref="changePasswordForm" @submit="changePassword">
<label>
<input type="text" name="username" placeholder="Username" :value="selectedUser" disabled="disabled">
</label>
<label>
<input type="password" name="password" placeholder="Current password" :disabled="commandRunning">
</label>
<label>
<input type="password" name="new_password" placeholder="New password" :disabled="commandRunning">
</label>
<label>
<input type="password" name="confirm_new_password" placeholder="Confirm new password" :disabled="commandRunning">
</label>
<input type="submit" value="Change Password" :disabled="commandRunning">
</form>
</modal>
<div class="body">
<ul class="users-list">
<li v-for="user in users" :key="user.user_id" class="item user" @click="selectedUser = user.username">
<div class="name col-8" v-text="user.username" />
<div class="actions pull-right col-4">
<Dropdown title="User Actions" icon-class="fa fa-cog">
<DropdownItem text="Change Password" :disabled="commandRunning" icon-class="fa fa-key"
@click="selectedUser = user.username; $refs.changePasswordModal.show()" />
<DropdownItem text="Delete User" :disabled="commandRunning" icon-class="fa fa-trash"
@click="deleteUser(user)" />
</Dropdown>
</div>
</li>
</ul>
</div>
</template>
<script>
import Dropdown from "@/components/elements/Dropdown";
import Modal from "@/components/Modal";
import Loading from "@/components/Loading";
import Utils from "@/Utils";
import DropdownItem from "@/components/elements/DropdownItem";
export default {
name: "Users",
components: {DropdownItem, Loading, Modal, Dropdown},
mixins: [Utils],
props: {
sessionToken: {
type: String,
required: true,
},
currentUser: {
type: Object,
required: true,
}
},
data() {
return {
users: [],
commandRunning: false,
loading: false,
selectedUser: null,
}
},
methods: {
async refresh() {
this.loading = true
try {
this.users = await this.request('user.get_users')
} finally {
this.loading = false
}
},
async createUser(event) {
event.preventDefault()
const form = [...this.$refs.addUserForm.querySelectorAll('input[name]')].reduce((map, input) => {
map[input.name] = input.value
return map
}, {})
if (form.password !== form.confirm_password) {
this.notify({
title: 'Unable to create user',
text: 'Please check that the passwords match',
error: true,
image: {
iconClass: 'fas fa-times',
},
})
return
}
this.commandRunning = true
try {
await this.request('user.create_user', {
username: form.username,
password: form.password,
session_token: this.sessionToken,
})
} finally {
this.commandRunning = false
}
this.notify({
text: 'User ' + form.username + ' created',
image: {
iconClass: 'fas fa-check',
},
})
this.$refs.addUserModal.close()
await this.refresh()
},
// onTokenFocus(event) {
// event.target.select()
// this.document.execCommand('copy')
// event.target.setAttribute('disabled', true)
//
// this.notify({
// text: 'Token copied to the clipboard',
// image: {
// iconClass: 'fas fa-copy',
// },
// })
// },
//
// onTokenBlur(event) {
// event.target.select()
// this.document.execCommand('copy')
// event.target.removeAttribute('disabled')
//
// this.notify({
// text: 'Token copied to clipboard',
// image: {
// iconClass: 'fas fa-copy',
// },
// })
// },
async changePassword(event) {
event.preventDefault()
const form = [...this.$refs.changePasswordForm.querySelectorAll('input[name]')].reduce((map, input) => {
map[input.name] = input.value
return map
}, {})
if (form.new_password !== form.confirm_new_password) {
this.notify({
title: 'Unable to update password',
text: 'Please check that the passwords match',
error: true,
image: {
iconClass: 'fas fa-times',
},
})
return
}
this.commandRunning = true
let success = false
try {
success = await this.request('user.update_password', {
username: form.username,
old_password: form.password,
new_password: form.new_password,
})
} finally {
this.commandRunning = false
}
if (success) {
this.$refs.changePasswordModal.close()
this.notify({
text: 'Password successfully updated',
image: {
iconClass: 'fas fa-check',
},
})
} else {
this.notify({
title: 'Unable to update password',
text: 'The current password is incorrect',
error: true,
image: {
iconClass: 'fas fa-times',
},
})
}
},
async deleteUser(user) {
if (!confirm('Are you sure that you want to remove the user ' + user.username + '?'))
return
this.commandRunning = true
try {
await this.request('user.delete_user', {
username: user.username,
session_token: this.sessionToken,
})
} finally {
this.commandRunning = false
}
this.notify({
text: 'User ' + user.username + ' removed',
image: {
iconClass: 'fas fa-check',
},
})
await this.refresh()
},
},
mounted() {
this.refresh()
},
}
</script>
<style lang="scss">
.settings-container {
.body {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
}
.modal {
.body {
height: auto;
}
}
.users-list {
background: $background-color;
margin-top: .15em;
height: max-content;
.user {
display: flex;
align-items: center;
padding: .75em;
box-shadow: $border-shadow-bottom;
&:hover {
background: $hover-bg;
}
.actions {
display: inline-flex;
justify-content: right;
button {
width: min-content;
}
}
}
}
@media screen and (max-width: $desktop) {
.users-list {
width: 100%;
}
}
@media screen and (min-width: $desktop) {
.users-list {
min-width: 400pt;
max-width: 600pt;
margin-top: 1em;
border-radius: 1em;
box-shadow: $border-shadow-bottom;
.user {
border-radius: 1em;
}
}
}
}
</style>

View file

@ -0,0 +1,14 @@
<script>
export default {
name: "Cookies",
methods: {
getCookies() {
return document.cookie.split(/;\s*/).reduce((obj, item) => {
const [k, v] = item.split('=')
obj[k] = v
return obj
}, {})
}
}
}
</script>

View file

@ -98,10 +98,15 @@ form {
width: 100%; width: 100%;
} }
input[type=submit] { input[type=submit],
input[type=password] {
border-radius: 1em; border-radius: 1em;
} }
input[type=password] {
padding: .25em .5em;
}
.checkbox { .checkbox {
display: flex; display: flex;
font-size: 0.8em; font-size: 0.8em;

View file

@ -4,7 +4,13 @@
<Nav :panels="components" :selected-panel="selectedPanel" :hostname="hostname" <Nav :panels="components" :selected-panel="selectedPanel" :hostname="hostname"
@select="selectedPanel = $event" v-else /> @select="selectedPanel = $event" v-else />
<div class="canvas"> <div class="canvas" v-if="selectedPanel === 'settings'">
<div class="panel">
<Settings />
</div>
</div>
<div class="canvas" v-else>
<div class="panel" :class="{hidden: name !== selectedPanel}" v-for="(panel, name) in components" :key="name"> <div class="panel" :class="{hidden: name !== selectedPanel}" v-for="(panel, name) in components" :key="name">
<component :is="panel.component" :config="panel.config" :plugin-name="name" v-if="name === selectedPanel" /> <component :is="panel.component" :config="panel.config" :plugin-name="name" v-if="name === selectedPanel" />
</div> </div>
@ -17,11 +23,12 @@ import {defineAsyncComponent} from "vue";
import Utils from '@/Utils' import Utils from '@/Utils'
import Loading from "@/components/Loading"; import Loading from "@/components/Loading";
import Nav from "@/components/Nav"; import Nav from "@/components/Nav";
import Settings from "@/components/panels/Settings/Index";
export default { export default {
name: 'Panel', name: 'Panel',
mixins: [Utils], mixins: [Utils],
components: {Nav, Loading}, components: {Settings, Nav, Loading},
data() { data() {
return { return {

View file

@ -18,6 +18,14 @@ module.exports = {
'/execute': { '/execute': {
target: 'http://localhost:8008', target: 'http://localhost:8008',
changeOrigin: true, changeOrigin: true,
},
'/auth': {
target: 'http://localhost:8008',
changeOrigin: true,
},
'/logout': {
target: 'http://localhost:8008',
changeOrigin: true,
} }
} }
} }

View file

@ -1,3 +1,5 @@
from typing import List, Dict, Any
from platypush.plugins import Plugin, action from platypush.plugins import Plugin, action
from platypush.user import UserManager from platypush.user import UserManager
@ -146,5 +148,64 @@ class UserPlugin(Plugin):
return self.user_manager.delete_user_session(session_token) return self.user_manager.delete_user_session(session_token)
@action
def get_users(self) -> List[Dict[str, Any]]:
"""
Get the list of registered users.
:return:
.. code-block:: json
[
{
"user_id": 1,
"username": "user1",
"created_at": "2020-11-26T22:41:40.550574"
},
{
"user_id": 2,
"username": "user2",
"created_at": "2020-11-28T21:10:23.224813"
}
]
"""
return [
{
'user_id': user.user_id,
'username': user.username,
'created_at': user.created_at.isoformat(),
}
for user in self.user_manager.get_users().all()
]
@action
def get_user_by_session(self, session_token: str) -> dict:
"""
Get the user record associated to a session token.
:param session_token: Session token.
:return:
.. code-block:: json
[
{
"user_id": 1,
"username": "user1",
"created_at": "2020-11-26T22:41:40.550574"
}
]
"""
user = self.user_manager.get_user_by_session(session_token)
assert user, 'No user associated with the specified session token'
return {
'user_id': user.user_id,
'username': user.username,
'created_at': user.created_at.isoformat(),
}
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -161,6 +161,23 @@ class UserManager:
rand = bytes(random.randint(0, 255) for _ in range(0, 255)) rand = bytes(random.randint(0, 255) for _ in range(0, 255))
return hashlib.sha256(rand).hexdigest() return hashlib.sha256(rand).hexdigest()
def get_user_by_session(self, session_token: str):
"""
Get a user associated to a session token.
:param session_token: Session token.
"""
session = self._get_db_session()
return session.query(User).join(UserSession).filter_by(session_token=session_token).first()
if not user:
return None
return {
'user_id': user.user_id,
'username': user.username,
'created_at': user.created_at,
}
def generate_jwt_token(self, username: str, password: str, expires_at: Optional[datetime.datetime] = None) -> str: def generate_jwt_token(self, username: str, password: str, expires_at: Optional[datetime.datetime] = None) -> str:
""" """
Create a user JWT token for API usage. Create a user JWT token for API usage.