diff options
author | Christoph Wurst <christoph@winzerhof-wurst.at> | 2019-02-26 19:43:59 +0100 |
---|---|---|
committer | Christoph Wurst <christoph@winzerhof-wurst.at> | 2019-02-28 17:38:48 +0100 |
commit | 4b724751307447cb5153ce4708d6ad9d04a6bff5 (patch) | |
tree | da0e43b8a3e198c3451522ab544a202c3cff4ccc /settings/src | |
parent | fb48abc35ab9d36c6d9eef5fa0ac1fe92bba0f73 (diff) | |
download | nextcloud-server-4b724751307447cb5153ce4708d6ad9d04a6bff5.tar.gz nextcloud-server-4b724751307447cb5153ce4708d6ad9d04a6bff5.zip |
Move personal auth token settings to Vue
Signed-off-by: Christoph Wurst <christoph@winzerhof-wurst.at>
Always crate OC.Settings, even if not used
Signed-off-by: Christoph Wurst <christoph@winzerhof-wurst.at>
Diffstat (limited to 'settings/src')
-rw-r--r-- | settings/src/components/AuthToken.vue | 272 | ||||
-rw-r--r-- | settings/src/components/AuthTokenList.vue | 134 | ||||
-rw-r--r-- | settings/src/components/AuthTokenSection.vue | 153 | ||||
-rw-r--r-- | settings/src/components/AuthTokenSetupDialogue.vue | 181 | ||||
-rw-r--r-- | settings/src/main-admin-security.js | 4 | ||||
-rw-r--r-- | settings/src/main-personal-security.js | 35 |
6 files changed, 779 insertions, 0 deletions
diff --git a/settings/src/components/AuthToken.vue b/settings/src/components/AuthToken.vue new file mode 100644 index 00000000000..a8b1bb14437 --- /dev/null +++ b/settings/src/components/AuthToken.vue @@ -0,0 +1,272 @@ +<!-- + - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + - + - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + --> + +<template> + <tr :data-id="token.id"> + <td class="client"> + <div :class="iconName.icon"></div> + </td> + <td class="token-name"> + <input v-if="token.canRename && renaming" + type="text" + ref="input" + v-model="newName" + @keyup.enter="rename" + @blur="cancelRename" + @keyup.esc="cancelRename"> + <span v-else>{{iconName.name}}</span> + </td> + <td> + <span class="last-activity" v-tooltip="lastActivity">{{lastActivityRelative}}</span> + </td> + <td class="more"> + <Action v-if="!token.current" + :actions="actions" + v-bind:open.sync="actionOpen" + v-tooltip="{content: t('settings', 'Device settings'), container: 'body'}" + tabindex="0"/> + </td> + </tr> +</template> + +<script> + import {Action} from 'nextcloud-vue'; + + const userAgentMap = { + ie: /(?:MSIE|Trident|Trident\/7.0; rv)[ :](\d+)/, + // Microsoft Edge User Agent from https://msdn.microsoft.com/en-us/library/hh869301(v=vs.85).aspx + edge: /^Mozilla\/5\.0 \([^)]+\) AppleWebKit\/[0-9.]+ \(KHTML, like Gecko\) Chrome\/[0-9.]+ (?:Mobile Safari|Safari)\/[0-9.]+ Edge\/[0-9.]+$/, + // Firefox User Agent from https://developer.mozilla.org/en-US/docs/Web/HTTP/Gecko_user_agent_string_reference + firefox: /^Mozilla\/5\.0 \([^)]*(Windows|OS X|Linux)[^)]+\) Gecko\/[0-9.]+ Firefox\/(\d+)(?:\.\d)?$/, + // Chrome User Agent from https://developer.chrome.com/multidevice/user-agent + chrome: /^Mozilla\/5\.0 \([^)]*(Windows|OS X|Linux)[^)]+\) AppleWebKit\/[0-9.]+ \(KHTML, like Gecko\) Chrome\/(\d+)[0-9.]+ (?:Mobile Safari|Safari)\/[0-9.]+$/, + // Safari User Agent from http://www.useragentstring.com/pages/Safari/ + safari: /^Mozilla\/5\.0 \([^)]*(Windows|OS X)[^)]+\) AppleWebKit\/[0-9.]+ \(KHTML, like Gecko\)(?: Version\/([0-9]+)[0-9.]+)? Safari\/[0-9.A-Z]+$/, + // Android Chrome user agent: https://developers.google.com/chrome/mobile/docs/user-agent + androidChrome: /Android.*(?:; (.*) Build\/).*Chrome\/(\d+)[0-9.]+/, + iphone: / *CPU +iPhone +OS +([0-9]+)_(?:[0-9_])+ +like +Mac +OS +X */, + ipad: /\(iPad\; *CPU +OS +([0-9]+)_(?:[0-9_])+ +like +Mac +OS +X */, + iosClient: /^Mozilla\/5\.0 \(iOS\) (ownCloud|Nextcloud)\-iOS.*$/, + androidClient: /^Mozilla\/5\.0 \(Android\) ownCloud\-android.*$/, + iosTalkClient: /^Mozilla\/5\.0 \(iOS\) Nextcloud\-Talk.*$/, + androidTalkClient: /^Mozilla\/5\.0 \(Android\) Nextcloud\-Talk.*$/, + // DAVdroid/1.2 (2016/07/03; dav4android; okhttp3) Android/6.0.1 + davDroid: /DAV(droid|x5)\/([0-9.]+)/, + // Mozilla/5.0 (U; Linux; Maemo; Jolla; Sailfish; like Android 4.3) AppleWebKit/538.1 (KHTML, like Gecko) WebPirate/2.0 like Mobile Safari/538.1 (compatible) + webPirate: /(Sailfish).*WebPirate\/(\d+)/, + // Mozilla/5.0 (Maemo; Linux; U; Jolla; Sailfish; Mobile; rv:31.0) Gecko/31.0 Firefox/31.0 SailfishBrowser/1.0 + sailfishBrowser: /(Sailfish).*SailfishBrowser\/(\d+)/ + }; + const nameMap = { + ie: t('setting', 'Internet Explorer'), + edge: t('setting', 'Edge'), + firefox: t('setting', 'Firefox'), + chrome: t('setting', 'Google Chrome'), + safari: t('setting', 'Safari'), + androidChrome: t('setting', 'Google Chrome for Android'), + iphone: t('setting', 'iPhone'), + ipad: t('setting', 'iPad'), + iosClient: t('setting', 'Nextcloud iOS app'), + androidClient: t('setting', 'Nextcloud Android app'), + iosTalkClient: t('setting', 'Nextcloud Talk for iOS'), + androidTalkClient: t('setting', 'Nextcloud Talk for Android'), + davDroid: 'DAVdroid', + webPirate: 'WebPirate', + sailfishBrowser: 'SailfishBrowser' + }; + const iconMap = { + ie: 'icon-desktop', + edge: 'icon-desktop', + firefox: 'icon-desktop', + chrome: 'icon-desktop', + safari: 'icon-desktop', + androidChrome: 'icon-phone', + iphone: 'icon-phone', + ipad: 'icon-tablet', + iosClient: 'icon-phone', + androidClient: 'icon-phone', + iosTalkClient: 'icon-phone', + androidTalkClient: 'icon-phone', + davDroid: 'icon-phone', + webPirate: 'icon-link', + sailfishBrowser: 'icon-link' + }; + + export default { + name: "AuthToken", + components: { + Action, + }, + props: { + token: { + type: Object, + required: true, + } + }, + computed: { + actions () { + const actions = []; + + if (this.token.type === 1) { + // TODO: add text/longtext with some description + actions.push({ + input: 'checkbox', + action: () => this.$emit('toggleScope', this.token, 'filesystem', !this.token.scope.filesystem), + model: this.token.scope.filesystem, + text: t('settings', 'Allow filesystem access'), + }); + } + if (this.token.canRename) { + // TODO: add text/longtext with some description + actions.push({ + icon: 'icon-rename', + action: () => this.startRename(), + text: t('settings', 'Rename'), + }); + } + if (this.token.canDelete) { + // TODO: add text/longtext with some description + actions.push({ + icon: 'icon-delete', + action: () => this.$emit('delete', this.token), + text: t('settings', 'Revoke'), + }); + } + + return actions; + }, + lastActivityRelative () { + return OC.Util.relativeModifiedDate(this.token.lastActivity * 1000); + }, + lastActivity () { + return OC.Util.formatDate(this.token.lastActivity * 1000, 'LLL'); + }, + iconName () { + // pretty format sync client user agent + let matches = this.token.name.match(/Mozilla\/5\.0 \((\w+)\) (?:mirall|csyncoC)\/(\d+\.\d+\.\d+)/); + + let icon = ''; + if (matches) { + this.token.name = t('settings', 'Sync client - {os}', { + os: matches[1], + version: matches[2] + }); + icon = 'icon-desktop'; + } + + // preserve title for cases where we format it further + const title = this.token.name; + let name = this.token.name; + for (let client in userAgentMap) { + if (matches = title.match(userAgentMap[client])) { + if (matches[2] && matches[1]) { // version number and os + name = nameMap[client] + ' ' + matches[2] + ' - ' + matches[1]; + } else if (matches[1]) { // only version number + name = nameMap[client] + ' ' + matches[1]; + } else { + name = nameMap[client]; + } + + icon = iconMap[client]; + } + } + if (this.token.current) { + name = t('settings', 'This session'); + } + + return { + icon, + name, + }; + }, + }, + data () { + return { + showMore: this.token.canScope || this.token.canDelete, + renaming: false, + newName: '', + actionOpen: false, + }; + }, + methods: { + startRename () { + // Close action (popover menu) + this.actionOpen = false; + + this.newName = this.token.name; + this.renaming = true; + this.$nextTick(() => { + this.$refs.input.select(); + }); + }, + cancelRename () { + this.renaming = false; + }, + rename () { + this.renaming = false; + this.$emit('rename', this.token, this.newName); + }, + } + } +</script> + +<style lang="scss" scoped> + td { + border-top: 1px solid var(--color-border); + max-width: 200px; + white-space: normal; + vertical-align: middle; + position: relative; + + &%icon { + overflow: visible; + position: relative; + width: 16px; + } + + &.token-name { + padding: 10px 6px; + + &.token-rename { + padding: 0; + } + + input { + width: 100%; + margin: 0; + } + } + + &.more { + @extend %icon; + } + + &.client { + @extend %icon; + + div { + opacity: 0.57; + width: 44px; + height: 44px; + } + } + } +</style> diff --git a/settings/src/components/AuthTokenList.vue b/settings/src/components/AuthTokenList.vue new file mode 100644 index 00000000000..fe92852921c --- /dev/null +++ b/settings/src/components/AuthTokenList.vue @@ -0,0 +1,134 @@ +<!-- + - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + - + - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + --> + +<template> + <table id="app-tokens-table" :class="{ 'icon-loading' : loading }"> + <thead v-if="tokens.length"> + <tr> + <th></th> + <th>{{ t('settings', 'Device') }}</th> + <th>{{ t('settings', 'Last activity') }}</th> + <th></th> + </tr> + </thead> + <tbody class="token-list"> + <AuthToken v-for="token in sortedTokens" + :key="token.id" + :token="token" + @toggleScope="toggleScope" + @rename="rename" + @delete="onDelete"/> + </tbody> + </table> +</template> + +<script> + import AuthToken from './AuthToken'; + + export default { + name: 'AuthTokenList', + components: { + AuthToken + }, + props: { + tokens: { + type: Array, + required: true, + }, + loading: { + type: Boolean, + required: true, + } + }, + computed: { + sortedTokens () { + return this.tokens.sort((t1, t2) => { + var ts1 = parseInt(t1.lastActivity, 10); + var ts2 = parseInt(t2.lastActivity, 10); + return ts2 - ts1; + }) + } + }, + methods: { + toggleScope (token, scope, value) { + // Just pass it on + this.$emit('toggleScope', token, scope, value); + }, + rename (token, newName) { + // Just pass it on + this.$emit('rename', token, newName); + }, + onDelete (token) { + // Just pass it on + this.$emit('delete', token); + } + } + } +</script> + +<style lang="scss" scoped> + table { + width: 100%; + min-height: 50px; + padding-top: 5px; + max-width: 580px; + + th { + opacity: .5; + padding: 10px 10px 10px 0; + } + } + + .token-list { + td > a.icon-more { + transition: opacity var(--animation-quick); + } + + a.icon-more { + padding: 14px; + display: block; + width: 44px; + height: 44px; + opacity: .5; + } + + tr { + &:hover td > a.icon, + td > a.icon:focus, + &.active td > a.icon { + opacity: 1; + } + } + } +</style> + +<!-- some styles are not scoped to make them work on subcomponents --> +<style lang="scss"> + #app-tokens-table { + tr > *:nth-child(2) { + padding-left: 6px; + } + + tr > *:nth-child(3) { + text-align: right; + } + } +</style> diff --git a/settings/src/components/AuthTokenSection.vue b/settings/src/components/AuthTokenSection.vue new file mode 100644 index 00000000000..e5be46fba15 --- /dev/null +++ b/settings/src/components/AuthTokenSection.vue @@ -0,0 +1,153 @@ +<!-- + - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + - + - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + --> + +<template> + <div id="security" class="section"> + <h2>{{ t('settings', 'Devices & sessions') }}</h2> + <p class="settings-hint hidden-when-empty">{{ t('settings', 'Web, desktop and mobile clients currently logged in to your account.') }}</p> + <AuthTokenList :tokens="tokens" + :loading="loading" + @toggleScope="toggleTokenScope" + @rename="rename" + @delete="deleteToken"/> + <AuthTokenSetupDialogue :add="addNewToken" /> + </div> +</template> + +<script> + import Axios from 'nextcloud-axios'; + + import AuthTokenList from './AuthTokenList'; + import AuthTokenSetupDialogue from './AuthTokenSetupDialogue'; + + /** + * Tap into a promise without losing the value + */ + const tap = cb => val => { + cb(val); + return val; + }; + + export default { + name: "AuthTokenSection", + components: { + AuthTokenSetupDialogue, + AuthTokenList + }, + data() { + return { + loading: true, + baseUrl: OC.generateUrl('/settings/personal/authtokens'), + tokens: [], + } + }, + mounted() { + Axios.get(this.baseUrl) + .then(resp => resp.data) + .then(tokens => { + console.debug('loaded app tokens', tokens); + this.loading = false; + this.tokens = tokens; + }) + .catch(err => { + OC.Notification.showTemporary(t('core', 'Error while loading browser sessions and device tokens')); + console.error('could not load app tokens', err); + throw err; + }); + }, + methods: { + addNewToken (name) { + console.debug('creating a new app token', name); + + const data = { + name, + }; + return Axios.post(this.baseUrl, data) + .then(resp => resp.data) + .then(tap(() => console.debug('app token created'))) + .then(tap(data => this.tokens.push(data.deviceToken))) + .catch(err => { + console.error.bind('could not create app password', err); + OC.Notification.showTemporary(t('core', 'Error while creating device token')); + throw err; + }); + }, + toggleTokenScope (token, scope, value) { + console.debug('updating app token scope', token.id, scope, value); + + const oldVal = token.scope[scope]; + token.scope[scope] = value; + + return this.updateToken(token) + .then(tap(() => console.debug('app token scope updated'))) + .catch(err => { + console.error.bind('could not update app token scope', err); + OC.Notification.showTemporary(t('core', 'Error while updating device token scope')); + + // Restore + token.scope[scope] = oldVal; + + throw err; + }) + }, + rename (token, newName) { + console.debug('renaming app token', token.id, token.name, newName); + + const oldName = token.name; + token.name = newName; + + return this.updateToken(token) + .then(tap(() => console.debug('app token name updated'))) + .catch(err => { + console.error.bind('could not update app token name', err); + OC.Notification.showTemporary(t('core', 'Error while updating device token name')); + + // Restore + token.name = oldName; + }) + }, + updateToken (token) { + return Axios.put(this.baseUrl + '/' + token.id, token) + .then(resp => resp.data) + }, + deleteToken (token) { + console.debug('deleting app token', token); + + this.tokens = this.tokens.filter(t => t !== token); + + return Axios.delete(this.baseUrl + '/' + token.id) + .then(resp => resp.data) + .then(tap(() => console.debug('app token deleted'))) + .catch(err => { + console.error.bind('could not delete app token', err); + OC.Notification.showTemporary(t('core', 'Error while deleting the token')); + + // Restore + this.tokens.push(token); + }) + } + } + } +</script> + +<style scoped> + +</style> diff --git a/settings/src/components/AuthTokenSetupDialogue.vue b/settings/src/components/AuthTokenSetupDialogue.vue new file mode 100644 index 00000000000..020f4695c79 --- /dev/null +++ b/settings/src/components/AuthTokenSetupDialogue.vue @@ -0,0 +1,181 @@ +<!-- + - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + - + - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + --> + +<template> + <div v-if="!adding"> + <input v-model="deviceName" + type="text" + @keydown.enter="submit" + :disabled="loading" + :placeholder="t('settings', 'App name')"> + <button class="button" + :disabled="loading" + @click="submit">{{ t('settings', 'Create new app password') }} + </button> + </div> + <div v-else> + {{ t('settings', 'Use the credentials below to configure your app or device.') }} + {{ t('settings', 'For security reasons this password will only be shown once.') }} + <div class="app-password-row"> + <span class="app-password-label">{{ t('settings', 'Username') }}</span> + <input :value="loginName" + type="text" + class="monospaced" + readonly="readonly" + @focus="selectInput"/> + </div> + <div class="app-password-row"> + <span class="app-password-label">{{ t('settings', 'Password') }}</span> + <input :value="appPassword" + type="text" + class="monospaced" + ref="appPassword" + readonly="readonly" + @focus="selectInput"/> + <a class="icon icon-clippy" + ref="clipboardButton" + v-tooltip="copyTooltipOptions" + @mouseover="hoveringCopyButton = true" + @mouseleave="hoveringCopyButton = false" + v-clipboard:copy="appPassword" + v-clipboard:success="onCopyPassword" + v-clipboard:error="onCopyPasswordFailed"></a> + <button class="button" + @click="reset"> + {{ t('settings', 'Done') }} + </button> + </div> + </div> +</template> + +<script> + import confirmPassword from 'nextcloud-password-confirmation'; + + export default { + name: 'AuthTokenSetupDialogue', + props: { + add: { + type: Function, + required: true, + } + }, + data () { + return { + adding: false, + loading: false, + deviceName: '', + appPassword: '', + loginName: '', + passwordCopied: false, + hoveringCopyButton: false, + } + }, + computed: { + copyTooltipOptions() { + const base = { + hideOnTargetClick: false, + trigger: 'manual', + }; + + if (this.passwordCopied) { + return { + ...base, + content:t('core', 'Copied!'), + show: true, + } + } else { + return { + ...base, + content: t('core', 'Copy'), + show: this.hoveringCopyButton, + } + } + } + }, + methods: { + selectInput (e) { + e.currentTarget.select(); + }, + submit: function () { + confirmPassword() + .then(() => { + this.loading = true; + return this.add(this.deviceName) + }) + .then(token => { + this.adding = true; + this.loginName = token.loginName; + this.appPassword = token.token; + this.$nextTick(() => { + this.$refs.appPassword.select(); + }) + }) + .catch(err => { + console.error('could not create a new app password', err); + OC.Notification.showTemporary(t('core', 'Error while creating device token')); + + this.reset(); + }); + }, + onCopyPassword() { + this.passwordCopied = true; + this.$refs.clipboardButton.blur(); + setTimeout(() => this.passwordCopied = false, 3000); + }, + onCopyPasswordFailed() { + OC.Notification.showTemporary(t('core', 'Could not copy app password. Please copy it manually.')); + }, + reset () { + this.adding = false; + this.loading = false; + this.deviceName = ''; + this.appPassword = ''; + this.loginName = ''; + } + } + } +</script> + +<style lang="scss" scoped> + .app-password-row { + display: table-row; + + .icon { + background-size: 16px 16px; + display: inline-block; + position: relative; + top: 3px; + margin-left: 5px; + margin-right: 8px; + } + + } + + .app-password-label { + display: table-cell; + padding-right: 1em; + } + + .monospaced { + width: 245px; + font-family: monospace; + } +</style> diff --git a/settings/src/main-admin-security.js b/settings/src/main-admin-security.js index fcf0038740d..a728c085b43 100644 --- a/settings/src/main-admin-security.js +++ b/settings/src/main-admin-security.js @@ -7,6 +7,10 @@ __webpack_nonce__ = btoa(OC.requestToken) Vue.prototype.t = t; +// Not used here but required for legacy templates +window.OC = window.OC || {}; +window.OC.Settings = window.OC.Settings || {}; + store.replaceState( OCP.InitialState.loadState('settings', 'mandatory2FAState') ) diff --git a/settings/src/main-personal-security.js b/settings/src/main-personal-security.js new file mode 100644 index 00000000000..5b481a843ff --- /dev/null +++ b/settings/src/main-personal-security.js @@ -0,0 +1,35 @@ +/* + * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import Vue from 'vue'; +import VueClipboard from 'vue-clipboard2'; +import VTooltip from 'v-tooltip'; + +import AuthTokenSection from './components/AuthTokenSection'; + +__webpack_nonce__ = btoa(OC.requestToken); + +Vue.use(VueClipboard); +Vue.use(VTooltip); +Vue.prototype.t = t; + +const View = Vue.extend(AuthTokenSection); +new View().$mount('#security'); |