diff options
author | Ferdinand Thiessen <opensource@fthiessen.de> | 2023-12-16 23:38:17 +0100 |
---|---|---|
committer | Ferdinand Thiessen <opensource@fthiessen.de> | 2023-12-21 15:15:52 +0100 |
commit | 960bec949aa08a5c67a5fd2699213048f56ea0b7 (patch) | |
tree | 42a5e39fc57b0957f4385b6366aa212201ff0d6d /apps | |
parent | 0dcea036a7d8ce7b078d051c1752971ddef0473f (diff) | |
download | nextcloud-server-960bec949aa08a5c67a5fd2699213048f56ea0b7.tar.gz nextcloud-server-960bec949aa08a5c67a5fd2699213048f56ea0b7.zip |
enh(settings): Refactor frontend for session and app token management
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
Diffstat (limited to 'apps')
-rw-r--r-- | apps/settings/src/components/AuthToken.vue | 319 | ||||
-rw-r--r-- | apps/settings/src/components/AuthTokenList.vue | 122 | ||||
-rw-r--r-- | apps/settings/src/components/AuthTokenSection.vue | 157 | ||||
-rw-r--r-- | apps/settings/src/components/AuthTokenSetup.vue | 114 | ||||
-rw-r--r-- | apps/settings/src/components/AuthTokenSetupDialog.vue | 220 | ||||
-rw-r--r-- | apps/settings/src/components/AuthTokenSetupDialogue.vue | 239 | ||||
-rw-r--r-- | apps/settings/src/main-personal-security.js | 18 | ||||
-rw-r--r-- | apps/settings/src/store/authtoken.ts | 214 |
8 files changed, 801 insertions, 602 deletions
diff --git a/apps/settings/src/components/AuthToken.vue b/apps/settings/src/components/AuthToken.vue index 4e0a94543be..12d801d0db1 100644 --- a/apps/settings/src/components/AuthToken.vue +++ b/apps/settings/src/components/AuthToken.vue @@ -2,6 +2,7 @@ - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + - @author Ferdinand Thiessen <opensource@fthiessen.de> - - @license GNU AGPL version 3 or any later version - @@ -20,34 +21,43 @@ --> <template> - <tr :data-id="token.id" - :class="wiping"> - <td class="client"> - <div :class="iconName.icon" /> - </td> - <td class="token-name"> - <NcTextField v-if="token.canRename && renaming" - ref="input" - v-model="newName" - type="text" - :label="t('settings', 'Device name')" - @keyup.enter="rename" - @change="rename" - @keyup.esc="cancelRename" /> - <span v-else>{{ iconName.name }}</span> - <span v-if="wiping" class="wiping-warning">({{ t('settings', 'Marked for remote wipe') }})</span> + <tr :class="['auth-token', { 'auth-token--wiping': wiping }]" :data-id="token.id"> + <td class="auth-token__name"> + <NcIconSvgWrapper :path="tokenIcon" /> + <div class="auth-token__name-wrapper"> + <form v-if="token.canRename && renaming" + class="auth-token__name-form" + @submit.prevent.stop="rename"> + <NcTextField ref="input" + :value.sync="newName" + :label="t('settings', 'Device name')" + :show-trailing-button="true" + :trailing-button-label="t('settings', 'Cancel renaming')" + @trailing-button-click="cancelRename" + @keyup.esc="cancelRename" /> + <NcButton :aria-label="t('settings', 'Save new name')" type="tertiary" native-type="submit"> + <template #icon> + <NcIconSvgWrapper :path="mdiCheck" /> + </template> + </NcButton> + </form> + <span v-else>{{ tokenLabel }}</span> + <span v-if="wiping" class="wiping-warning">({{ t('settings', 'Marked for remote wipe') }})</span> + </div> </td> <td> - <span :title="lastActivity" class="last-activity">{{ lastActivityRelative }}</span> + <NcDateTime class="auth-token__last-activity" + :ignore-seconds="true" + :timestamp="tokenLastActivity" /> </td> - <td class="more"> + <td class="auth-token__actions"> <NcActions v-if="!token.current" :title="t('settings', 'Device settings')" :aria-label="t('settings', 'Device settings')" :open.sync="actionOpen"> - <NcActionCheckbox v-if="token.type === 1" + <NcActionCheckbox v-if="canChangeScope" :checked="token.scope.filesystem" - @change.stop.prevent="$emit('toggle-scope', token, 'filesystem', !token.scope.filesystem)"> + @update:checked="updateFileSystemScope"> <!-- TODO: add text/longtext with some description --> {{ t('settings', 'Allow filesystem access') }} </NcActionCheckbox> @@ -73,7 +83,7 @@ </template> <NcActionButton v-else-if="token.type === 2" icon="icon-delete" - :title="t('settings', 'Revoke')" + :name="t('settings', 'Revoke')" @click.stop.prevent="revoke"> {{ t('settings', 'Revoking this token might prevent the wiping of your device if it has not started the wipe yet.') }} </NcActionButton> @@ -83,10 +93,21 @@ </tr> </template> -<script> +<script lang="ts"> +import type { PropType } from 'vue' +import type { IToken } from '../store/authtoken' + +import { mdiCheck, mdiCellphone, mdiTablet, mdiMonitor, mdiWeb, mdiKey, mdiMicrosoftEdge, mdiFirefox, mdiGoogleChrome, mdiAppleSafari, mdiAndroid, mdiAppleIos } from '@mdi/js' +import { translate as t } from '@nextcloud/l10n' +import { defineComponent } from 'vue' +import { TokenType, useAuthTokenStore } from '../store/authtoken.ts' + import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' import NcActionCheckbox from '@nextcloud/vue/dist/Components/NcActionCheckbox.js' +import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' +import NcDateTime from '@nextcloud/vue/dist/Components/NcDateTime.js' +import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' // When using capture groups the following parts are extracted the first is used as the version number, the second as the OS @@ -118,116 +139,162 @@ const userAgentMap = { neon: /Neon \d+\.\d+\.\d+\+\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', '{productName} iOS app', { productName: window.oc_defaults.productName }), - androidClient: t('setting', '{productName} Android app', { productName: window.oc_defaults.productName }), - iosTalkClient: t('setting', '{productName} Talk for iOS', { productName: window.oc_defaults.productName }), - androidTalkClient: t('setting', '{productName} Talk for Android', { productName: window.oc_defaults.productName }), + edge: 'Microsoft Edge', + firefox: 'Firefox', + chrome: 'Google Chrome', + safari: 'Safari', + androidChrome: t('settings', 'Google Chrome for Android'), + iphone: 'iPhone', + ipad: 'iPad', + iosClient: t('settings', '{productName} iOS app', { productName: window.oc_defaults.productName }), + androidClient: t('settings', '{productName} Android app', { productName: window.oc_defaults.productName }), + iosTalkClient: t('settings', '{productName} Talk for iOS', { productName: window.oc_defaults.productName }), + androidTalkClient: t('settings', '{productName} Talk for Android', { productName: window.oc_defaults.productName }), + syncClient: t('settings', 'Sync client'), davx5: 'DAVx5', webPirate: 'WebPirate', sailfishBrowser: 'SailfishBrowser', neon: 'Neon', } -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', - davx5: 'icon-phone', - webPirate: 'icon-link', - sailfishBrowser: 'icon-link', -} -export default { +export default defineComponent({ name: 'AuthToken', components: { NcActions, NcActionButton, NcActionCheckbox, + NcButton, + NcDateTime, + NcIconSvgWrapper, NcTextField, }, props: { token: { - type: Object, + type: Object as PropType<IToken>, required: true, }, }, + setup() { + const authTokenStore = useAuthTokenStore() + return { authTokenStore } + }, data() { return { - showMore: this.token.canScope || this.token.canDelete, + actionOpen: false, renaming: false, newName: '', oldName: '', - actionOpen: false, + mdiCheck, } }, computed: { - lastActivityRelative() { - return OC.Util.relativeModifiedDate(this.token.lastActivity * 1000) - }, - lastActivity() { - return OC.Util.formatDate(this.token.lastActivity * 1000, 'LLL') + canChangeScope() { + return this.token.type === TokenType.PERMANENT_TOKEN }, - iconName() { + /** + * Object ob the current user agend used by the token + * @return Either an object containing user agent information or null if unknown + */ + client() { // pretty format sync client user agent const matches = this.token.name.match(/Mozilla\/5\.0 \((\w+)\) (?:mirall|csyncoC)\/(\d+\.\d+\.\d+)/) - let icon = '' if (matches) { - /* eslint-disable-next-line */ - this.token.name = t('settings', 'Sync client - {os}', { + return { + id: 'syncClient', 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 (const client in userAgentMap) { - const matches = title.match(userAgentMap[client]) + const matches = this.token.name.match(userAgentMap[client]) if (matches) { - 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] + return { + id: client, + os: matches[2] && matches[1], + version: matches[2] ?? matches[1], } - - icon = iconMap[client] } } + + return null + }, + /** + * Last activity of the token as ECMA timestamp (in ms) + */ + tokenLastActivity() { + return this.token.lastActivity * 1000 + }, + /** + * Icon to use for the current token + */ + tokenIcon() { + // For custom created app tokens / app passwords + if (this.token.type === TokenType.PERMANENT_TOKEN) { + return mdiKey + } + + switch (this.client?.id) { + case 'edge': + return mdiMicrosoftEdge + case 'firefox': + return mdiFirefox + case 'chrome': + return mdiGoogleChrome + case 'safari': + return mdiAppleSafari + case 'androidChrome': + case 'androidClient': + case 'androidTalkClient': + return mdiAndroid + case 'iphone': + case 'iosClient': + case 'iosTalkClient': + return mdiAppleIos + case 'ipad': + return mdiTablet + case 'davx5': + return mdiCellphone + case 'syncClient': + return mdiMonitor + case 'webPirate': + case 'sailfishBrowser': + default: + return mdiWeb + } + }, + /** + * Label to be shown for current token + */ + tokenLabel() { if (this.token.current) { - name = t('settings', 'This session') + return t('settings', 'This session') + } + if (this.client === null) { + return this.token.name } - return { - icon, - name, + const name = nameMap[this.client.id] + if (this.client.os) { + return t('settings', '{client} - {version} ({system})', { client: name, system: this.client.os, version: this.client.version }) + } else if (this.client.version) { + return t('settings', '{client} - {version}', { client: name, version: this.client.version }) } + return name }, + /** + * If the current token is considered for remote wiping + */ wiping() { - return this.token.type === 2 + return this.token.type === TokenType.WIPING_TOKEN }, }, methods: { + t, + updateFileSystemScope(state: boolean) { + this.authTokenStore.setTokenScope(this.token, 'filesystem', state) + }, startRename() { // Close action (popover menu) this.actionOpen = false @@ -236,77 +303,69 @@ export default { this.newName = this.token.name this.renaming = true this.$nextTick(() => { - this.$refs.input.select() + this.$refs.input!.select() }) }, cancelRename() { this.renaming = false - this.$emit('rename', this.token, this.oldName) }, revoke() { this.actionOpen = false - this.$emit('delete', this.token) + this.authTokenStore.deleteToken(this.token) }, rename() { this.renaming = false - this.$emit('rename', this.token, this.newName) + this.authTokenStore.renameToken(this.token, this.newName) }, wipe() { this.actionOpen = false - this.$emit('wipe', this.token) + this.authTokenStore.wipeToken(this.token) }, }, -} +}) </script> <style lang="scss" scoped> - .wiping { - background-color: var(--color-background-darker); - } - - td { - border-top: 1px solid var(--color-border); - max-width: 200px; - white-space: normal; - vertical-align: middle; - position: relative; +.auth-token { + border-top: 2px solid var(--color-border); + max-width: 200px; + white-space: normal; + vertical-align: middle; + position: relative; - &%icon { - overflow: visible; - position: relative; - width: 44px; - height: 44px; - } + &--wiping { + background-color: var(--color-background-dark); + } - &.token-name { - padding: 10px 6px; + &__name { + padding-block: 10px; + display: flex; + align-items: center; + gap: 6px; + min-width: 355px; // ensure no jumping when renaming + } - &.token-rename { - padding: 0; - } + &__name-wrapper { + display: flex; + flex-direction: column; + } - input { - width: 100%; - margin: 0; - } - } - &.token-name .wiping-warning { - color: var(--color-text-lighter); - } + &__name-form { + align-items: end; + display: flex; + gap: 4px; + } - &.more { - @extend %icon; - padding: 0 10px; - } + &__actions { + padding: 0 10px; + } - &.client { - @extend %icon; + &__last-activity { + padding-inline-start: 10px; + } - div { - opacity: 0.57; - width: 44px; - height: 44px; - } - } + .wiping-warning { + color: var(--color-text-maxcontrast); } +} </style> diff --git a/apps/settings/src/components/AuthTokenList.vue b/apps/settings/src/components/AuthTokenList.vue index 18b4c87d565..e4759adba8d 100644 --- a/apps/settings/src/components/AuthTokenList.vue +++ b/apps/settings/src/components/AuthTokenList.vue @@ -2,6 +2,7 @@ - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + - @author Ferdinand Thiessen <opensource@fthiessen.de> - - @license GNU AGPL version 3 or any later version - @@ -20,115 +21,74 @@ --> <template> - <table id="app-tokens-table"> - <thead v-if="tokens.length"> + <table id="app-tokens-table" class="token-list"> + <thead> <tr> - <th /> - <th>{{ t('settings', 'Device') }}</th> - <th>{{ t('settings', 'Last activity') }}</th> - <th /> + <th class="token-list__header-device"> + {{ t('settings', 'Device') }} + </th> + <th class="toke-list__header-activity"> + {{ t('settings', 'Last activity') }} + </th> + <th> + <span class="hidden-visually"> + {{ t('settings', 'Actions') }} + </span> + </th> </tr> </thead> - <tbody class="token-list"> + <tbody class="token-list__body"> <AuthToken v-for="token in sortedTokens" :key="token.id" - :token="token" - @toggle-scope="toggleScope" - @rename="rename" - @delete="onDelete" - @wipe="onWipe" /> + :token="token" /> </tbody> </table> </template> -<script> +<script lang="ts"> +import { translate as t } from '@nextcloud/l10n' +import { defineComponent } from 'vue' +import { useAuthTokenStore } from '../store/authtoken' + import AuthToken from './AuthToken.vue' -export default { +export default defineComponent({ name: 'AuthTokenList', components: { AuthToken, }, - props: { - tokens: { - type: Array, - required: true, - }, + setup() { + const authTokenStore = useAuthTokenStore() + return { authTokenStore } }, computed: { sortedTokens() { - return this.tokens.slice().sort((t1, t2) => { - const ts1 = parseInt(t1.lastActivity, 10) - const ts2 = parseInt(t2.lastActivity, 10) - return ts2 - ts1 - }) + return [...this.authTokenStore.tokens].sort((t1, t2) => t2.lastActivity - t1.lastActivity) }, }, methods: { - toggleScope(token, scope, value) { - // Just pass it on - this.$emit('toggle-scope', 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) - }, - onWipe(token) { - // Just pass it on - this.$emit('wipe', token) - }, + t, }, -} +}) </script> <style lang="scss" scoped> - table { - width: 100%; - min-height: 50px; - padding-top: 5px; - max-width: 580px; +.token-list { + width: 100%; + min-height: 50px; + padding-top: 5px; + max-width: fit-content; - th { - padding: 10px 0; - } + th { + padding-block: 10px; + padding-inline-start: 10px; } - .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; - } - } + #{&}__header-device { + padding-inline-start: 50px; // 44px icon + 6px padding } -</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; - } + &__header-activity { + text-align: end; } +} </style> diff --git a/apps/settings/src/components/AuthTokenSection.vue b/apps/settings/src/components/AuthTokenSection.vue index bb9bd3fb065..a1689846130 100644 --- a/apps/settings/src/components/AuthTokenSection.vue +++ b/apps/settings/src/components/AuthTokenSection.vue @@ -2,6 +2,7 @@ - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + - @author Ferdinand Thiessen <opensource@fthiessen.de> - - @license GNU AGPL version 3 or any later version - @@ -25,164 +26,32 @@ <p class="settings-hint hidden-when-empty"> {{ t('settings', 'Web, desktop and mobile clients currently logged in to your account.') }} </p> - <AuthTokenList :tokens="tokens" - @toggle-scope="toggleTokenScope" - @rename="rename" - @delete="deleteToken" - @wipe="wipeToken" /> - <AuthTokenSetupDialogue v-if="canCreateToken" :add="addNewToken" /> + <AuthTokenList /> + <AuthTokenSetup v-if="canCreateToken" /> </div> </template> -<script> -import axios from '@nextcloud/axios' -import { confirmPassword } from '@nextcloud/password-confirmation' -import '@nextcloud/password-confirmation/dist/style.css' -import { generateUrl } from '@nextcloud/router' +<script lang="ts"> +import { loadState } from '@nextcloud/initial-state' +import { translate as t } from '@nextcloud/l10n' +import { defineComponent } from 'vue' import AuthTokenList from './AuthTokenList.vue' -import AuthTokenSetupDialogue from './AuthTokenSetupDialogue.vue' +import AuthTokenSetup from './AuthTokenSetup.vue' -const confirm = () => { - return new Promise(resolve => { - OC.dialogs.confirm( - t('settings', 'Do you really want to wipe your data from this device?'), - t('settings', 'Confirm wipe'), - resolve, - true, - ) - }) -} - -/** - * Tap into a promise without losing the value - * - * @param {Function} cb the callback - * @return {any} val the value - */ -const tap = cb => val => { - cb(val) - return val -} - -export default { +export default defineComponent({ name: 'AuthTokenSection', components: { - AuthTokenSetupDialogue, AuthTokenList, - }, - props: { - tokens: { - type: Array, - required: true, - }, - canCreateToken: { - type: Boolean, - required: true, - }, + AuthTokenSetup, }, data() { return { - baseUrl: generateUrl('/settings/personal/authtokens'), + canCreateToken: loadState('settings', 'can_create_app_token'), } }, 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'))) - // eslint-disable-next-line vue/no-mutating-props - .then(tap(data => this.tokens.push(data.deviceToken))) - .catch(err => { - console.error.bind('could not create app password', err) - OC.Notification.showTemporary(t('settings', '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('settings', '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('settings', '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) - - // eslint-disable-next-line vue/no-mutating-props - 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('settings', 'Error while deleting the token')) - - // Restore - // eslint-disable-next-line vue/no-mutating-props - this.tokens.push(token) - }) - }, - async wipeToken(token) { - console.debug('wiping app token', token) - - try { - await confirmPassword() - - if (!(await confirm())) { - console.debug('wipe aborted by user') - return - } - await axios.post(this.baseUrl + '/wipe/' + token.id) - console.debug('app token marked for wipe') - - token.type = 2 - } catch (err) { - console.error('could not wipe app token', err) - OC.Notification.showTemporary(t('settings', 'Error while wiping the device with the token')) - } - }, + t, }, -} +}) </script> - -<style scoped> - -</style> diff --git a/apps/settings/src/components/AuthTokenSetup.vue b/apps/settings/src/components/AuthTokenSetup.vue new file mode 100644 index 00000000000..9e709397362 --- /dev/null +++ b/apps/settings/src/components/AuthTokenSetup.vue @@ -0,0 +1,114 @@ +<!-- + - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + - + - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + - @author Ferdinand Thiessen <opensource@fthiessen.de> + - + - @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> + <form id="generate-app-token-section" + class="row spacing" + @submit.prevent="submit"> + <!-- Port to TextField component when available --> + <NcTextField :value.sync="deviceName" + type="text" + :maxlength="120" + :disabled="loading" + class="app-name-text-field" + :label="t('settings', 'App name')" + :placeholder="t('settings', 'App name')" /> + <NcButton type="primary" + :disabled="loading || deviceName.length === 0" + native-type="submit"> + {{ t('settings', 'Create new app password') }} + </NcButton> + + <AuthTokenSetupDialog :token="newToken" @close="newToken = null" /> + </form> +</template> + +<script lang="ts"> +import { showError } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n' +import { defineComponent } from 'vue' +import { useAuthTokenStore, type ITokenResponse } from '../store/authtoken' + +import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' +import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' + +import AuthTokenSetupDialog from './AuthTokenSetupDialog.vue' +import logger from '../logger' + +export default defineComponent({ + name: 'AuthTokenSetup', + components: { + NcButton, + NcTextField, + AuthTokenSetupDialog, + }, + setup() { + const authTokenStore = useAuthTokenStore() + return { authTokenStore } + }, + data() { + return { + deviceName: '', + loading: false, + newToken: null as ITokenResponse|null, + } + }, + methods: { + t, + reset() { + this.loading = false + this.deviceName = '' + this.newToken = null + }, + async submit() { + try { + this.loading = true + this.newToken = await this.authTokenStore.addToken(this.deviceName) + } catch (error) { + logger.error(error as Error) + showError(t('settings', 'Error while creating device token')) + this.reset() + } finally { + this.loading = false + } + }, + }, +}) +</script> + +<style lang="scss" scoped> + .app-name-text-field { + height: 44px !important; + padding-left: 12px; + margin-right: 12px; + width: 200px; + } + + .row { + display: flex; + align-items: center; + } + + .spacing { + padding-top: 16px; + } +</style> diff --git a/apps/settings/src/components/AuthTokenSetupDialog.vue b/apps/settings/src/components/AuthTokenSetupDialog.vue new file mode 100644 index 00000000000..f40fe722cef --- /dev/null +++ b/apps/settings/src/components/AuthTokenSetupDialog.vue @@ -0,0 +1,220 @@ +<!-- + - @copyright 2023 Ferdinand Thiessen <opensource@fthiessen.de> + - + - @author Ferdinand Thiessen <opensource@fthiessen.de> + - + - @license AGPL-3.0-or-later + - + - 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> + <NcDialog :open.sync="open" + :name="t('settings', 'New app password')" + content-classes="token-dialog"> + <p> + {{ t('settings', 'Use the credentials below to configure your app or device. For security reasons this password will only be shown once.') }} + </p> + <div class="token-dialog__name"> + <NcTextField :label="t('settings', 'Username')" :value="loginName" readonly /> + <NcButton type="tertiary" + :title="copyLoginNameLabel" + :aria-label="copyLoginNameLabel" + @click="copyLoginName"> + <template #icon> + <NcIconSvgWrapper :path="copyNameIcon" /> + </template> + </NcButton> + </div> + <div class="token-dialog__password"> + <NcTextField ref="appPassword" + :label="t('settings', 'Password')" + :value="appPassword" + readonly /> + <NcButton type="tertiary" + :title="copyPasswordLabel" + :aria-label="copyPasswordLabel" + @click="copyPassword"> + <template #icon> + <NcIconSvgWrapper :path="copyPasswordIcon" /> + </template> + </NcButton> + </div> + <div class="token-dialog__qrcode"> + <NcButton v-if="!showQRCode" @click="showQRCode = true"> + {{ t('settings', 'Show QR code for mobile apps') }} + </NcButton> + <QR v-else :value="qrUrl" /> + </div> + </NcDialog> +</template> + +<script lang="ts"> +import type { ITokenResponse } from '../store/authtoken' + +import { mdiCheck, mdiContentCopy } from '@mdi/js' +import { showError } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n' +import { getRootUrl } from '@nextcloud/router' +import { defineComponent, type PropType } from 'vue' + +import QR from '@chenfengyuan/vue-qrcode' +import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' +import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js' +import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' +import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' + +import logger from '../logger' + +export default defineComponent({ + name: 'AuthTokenSetupDialog', + components: { + NcButton, + NcDialog, + NcIconSvgWrapper, + NcTextField, + QR, + }, + props: { + token: { + type: Object as PropType<ITokenResponse|null>, + required: false, + default: null, + }, + }, + data() { + return { + isNameCopied: false, + isPasswordCopied: false, + showQRCode: false, + } + }, + computed: { + open: { + get() { + return this.token !== null + }, + set(value: boolean) { + if (!value) { + this.$emit('close') + } + }, + }, + copyPasswordIcon() { + return this.isPasswordCopied ? mdiCheck : mdiContentCopy + }, + copyNameIcon() { + return this.isNameCopied ? mdiCheck : mdiContentCopy + }, + appPassword() { + return this.token?.token ?? '' + }, + loginName() { + return this.token?.loginName ?? '' + }, + qrUrl() { + const server = window.location.protocol + '//' + window.location.host + getRootUrl() + return `nc://login/user:${this.loginName}&password:${this.appPassword}&server:${server}` + }, + copyPasswordLabel() { + if (this.isPasswordCopied) { + return t('settings', 'App password copied!') + } + return t('settings', 'Copy app password') + }, + copyLoginNameLabel() { + if (this.isNameCopied) { + return t('settings', 'Login name copied!') + } + return t('settings', 'Copy login name') + }, + }, + watch: { + token() { + // reset showing the QR code on token change + this.showQRCode = false + }, + open() { + if (this.open) { + this.$nextTick(() => { + this.$refs.appPassword!.select() + }) + } + }, + }, + methods: { + t, + async copyPassword() { + try { + await navigator.clipboard.writeText(this.appPassword) + this.isPasswordCopied = true + } catch (e) { + this.isPasswordCopied = false + logger.error(e as Error) + showError(t('settings', 'Could not copy app password. Please copy it manually.')) + } finally { + setTimeout(() => { + this.isPasswordCopied = false + }, 4000) + } + }, + async copyLoginName() { + try { + await navigator.clipboard.writeText(this.loginName) + this.isNameCopied = true + } catch (e) { + this.isNameCopied = false + logger.error(e as Error) + showError(t('settings', 'Could not copy login name. Please copy it manually.')) + } finally { + setTimeout(() => { + this.isNameCopied = false + }, 4000) + } + }, + }, +}) +</script> + +<style scoped lang="scss"> +:deep(.token-dialog) { + display: flex; + flex-direction: column; + gap: 12px; + + padding-inline: 22px; + padding-block-end: 20px; + + > * { + box-sizing: border-box; + } +} + +.token-dialog { + &__name, &__password { + align-items: end; + display: flex; + gap: 10px; + + :deep(input) { + font-family: monospace; + } + } + + &__qrcode { + display: flex; + justify-content: center; + } +} +</style> diff --git a/apps/settings/src/components/AuthTokenSetupDialogue.vue b/apps/settings/src/components/AuthTokenSetupDialogue.vue deleted file mode 100644 index 18fa0f3ab2f..00000000000 --- a/apps/settings/src/components/AuthTokenSetupDialogue.vue +++ /dev/null @@ -1,239 +0,0 @@ -<!-- - - @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" id="generate-app-token-section" class="row spacing"> - <!-- Port to TextField component when available --> - <NcTextField :value.sync="deviceName" - type="text" - :maxlength="120" - :disabled="loading" - class="app-name-text-field" - :label="t('settings', 'App name')" - :placeholder="t('settings', 'App name')" - @keydown.enter="submit" /> - <NcButton :disabled="loading || deviceName.length === 0" - type="primary" - @click="submit"> - {{ t('settings', 'Create new app password') }} - </NcButton> - </div> - <div v-else class="spacing"> - {{ 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"> - <label for="app-username" class="app-password-label">{{ t('settings', 'Username') }}</label> - <input id="app-username" - :value="loginName" - type="text" - class="monospaced" - readonly="readonly" - @focus="selectInput"> - </div> - <div class="app-password-row"> - <label for="app-password" class="app-password-label">{{ t('settings', 'Password') }}</label> - <input id="app-password" - ref="appPassword" - :value="appPassword" - type="text" - class="monospaced" - readonly="readonly" - @focus="selectInput"> - <NcButton type="tertiary" - :title="copyTooltipOptions" - :aria-label="copyTooltipOptions" - @click="copyPassword"> - <template #icon> - <Check v-if="copied" :size="20" /> - <ContentCopy v-else :size="20" /> - </template> - </NcButton> - <NcButton @click="reset"> - {{ t('settings', 'Done') }} - </NcButton> - </div> - <div class="app-password-row"> - <span class="app-password-label" /> - <NcButton v-if="!showQR" - @click="showQR = true"> - {{ t('settings', 'Show QR code for mobile apps') }} - </NcButton> - <QR v-else - :value="qrUrl" /> - </div> - </div> -</template> - -<script> -import QR from '@chenfengyuan/vue-qrcode' -import { confirmPassword } from '@nextcloud/password-confirmation' -import '@nextcloud/password-confirmation/dist/style.css' -import { showError } from '@nextcloud/dialogs' -import { getRootUrl } from '@nextcloud/router' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' - -import Check from 'vue-material-design-icons/Check.vue' -import ContentCopy from 'vue-material-design-icons/ContentCopy.vue' - -export default { - name: 'AuthTokenSetupDialogue', - components: { - Check, - ContentCopy, - NcButton, - QR, - NcTextField, - }, - props: { - add: { - type: Function, - required: true, - }, - }, - data() { - return { - adding: false, - loading: false, - deviceName: '', - appPassword: '', - loginName: '', - copied: false, - showQR: false, - qrUrl: '', - } - }, - computed: { - copyTooltipOptions() { - if (this.copied) { - return t('settings', 'Copied!') - } - return t('settings', 'Copy') - }, - }, - methods: { - selectInput(e) { - e.currentTarget.select() - }, - submit() { - confirmPassword() - .then(() => { - this.loading = true - return this.add(this.deviceName) - }) - .then(token => { - this.adding = true - this.loginName = token.loginName - this.appPassword = token.token - - const server = window.location.protocol + '//' + window.location.host + getRootUrl() - this.qrUrl = `nc://login/user:${token.loginName}&password:${token.token}&server:${server}` - - this.$nextTick(() => { - this.$refs.appPassword.select() - }) - }) - .catch(err => { - console.error('could not create a new app password', err) - OC.Notification.showTemporary(t('settings', 'Error while creating device token')) - - this.reset() - }) - }, - async copyPassword() { - try { - await navigator.clipboard.writeText(this.appPassword) - this.copied = true - } catch (e) { - this.copied = false - console.error(e) - showError(t('settings', 'Could not copy app password. Please copy it manually.')) - } finally { - setTimeout(() => { - this.copied = false - }, 4000) - } - }, - reset() { - this.adding = false - this.loading = false - this.showQR = false - this.qrUrl = '' - this.deviceName = '' - this.appPassword = '' - this.loginName = '' - }, - }, -} -</script> - -<style lang="scss" scoped> - .app-password-row { - display: flex; - align-items: center; - flex-wrap: wrap; - margin-top: calc(var(--default-grid-baseline) * 2); - - .icon { - background-size: 16px 16px; - display: inline-block; - position: relative; - top: 3px; - margin-left: 5px; - margin-right: 8px; - } - - } - - .app-password-label { - display: table-cell; - margin-right: 1em; - text-align: left; - vertical-align: middle; - width: 100px; - } - - .app-name-text-field { - height: 44px !important; - padding-left: 12px; - margin-right: 12px; - width: 200px; - } - - .monospaced { - width: 245px; - font-family: monospace; - } - - .button-vue{ - display:inline-block; - margin: 3px 3px 3px 3px; - } - - .row { - display: flex; - align-items: center; - } - - .spacing { - padding-top: 16px; - } -</style> diff --git a/apps/settings/src/main-personal-security.js b/apps/settings/src/main-personal-security.js index 634deca61b5..d2aef1039ea 100644 --- a/apps/settings/src/main-personal-security.js +++ b/apps/settings/src/main-personal-security.js @@ -3,6 +3,7 @@ * * @author Christoph Wurst <christoph@winzerhof-wurst.at> * @author John Molakvoæ <skjnldsv@protonmail.com> + * @author Ferdinand Thiessen <opensource@fthiessen.de> * * @license AGPL-3.0-or-later * @@ -21,22 +22,23 @@ * */ -import { loadState } from '@nextcloud/initial-state' import Vue from 'vue' import VTooltip from 'v-tooltip' import AuthTokenSection from './components/AuthTokenSection.vue' +import { getRequestToken } from '@nextcloud/auth' +import { PiniaVuePlugin, createPinia } from 'pinia' + +import '@nextcloud/password-confirmation/dist/style.css' // eslint-disable-next-line camelcase -__webpack_nonce__ = btoa(OC.requestToken) +__webpack_nonce__ = btoa(getRequestToken()) + +const pinia = createPinia() +Vue.use(PiniaVuePlugin) Vue.use(VTooltip, { defaultHtml: false }) Vue.prototype.t = t const View = Vue.extend(AuthTokenSection) -new View({ - propsData: { - tokens: loadState('settings', 'app_tokens'), - canCreateToken: loadState('settings', 'can_create_app_token'), - }, -}).$mount('#security-authtokens') +new View({ pinia }).$mount('#security-authtokens') diff --git a/apps/settings/src/store/authtoken.ts b/apps/settings/src/store/authtoken.ts new file mode 100644 index 00000000000..399c39faae7 --- /dev/null +++ b/apps/settings/src/store/authtoken.ts @@ -0,0 +1,214 @@ +/** + * @copyright 2023 Ferdinand Thiessen <opensource@fthiessen.de> + * + * @author Ferdinand Thiessen <opensource@fthiessen.de> + * + * @license AGPL-3.0-or-later + * + * 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 { showError } from '@nextcloud/dialogs' +import { loadState } from '@nextcloud/initial-state' +import { translate as t } from '@nextcloud/l10n' +import { confirmPassword } from '@nextcloud/password-confirmation' +import { generateUrl } from '@nextcloud/router' +import { defineStore } from 'pinia' + +import axios from '@nextcloud/axios' +import logger from '../logger' + +const BASE_URL = generateUrl('/settings/personal/authtokens') + +const confirm = () => { + return new Promise(resolve => { + window.OC.dialogs.confirm( + t('settings', 'Do you really want to wipe your data from this device?'), + t('settings', 'Confirm wipe'), + resolve, + true, + ) + }) +} + +export enum TokenType { + TEMPORARY_TOKEN = 0, + PERMANENT_TOKEN = 1, + WIPING_TOKEN = 2, +} + +export interface IToken { + id: number + canDelete: boolean + canRename: boolean + current?: true + /** + * Last activity as UNIX timestamp (in seconds) + */ + lastActivity: number + name: string + type: TokenType + scope: Record<string, boolean> +} + +export interface ITokenResponse { + /** + * The device token created + */ + deviceToken: IToken + /** + * User who is assigned with this token + */ + loginName: string + /** + * The token for authentication + */ + token: string +} + +export const useAuthTokenStore = defineStore('auth-token', { + state() { + return { + tokens: loadState<IToken[]>('settings', 'app_tokens', []), + } + }, + actions: { + /** + * Update a token on server + * @param token Token to update + */ + async updateToken(token: IToken) { + const { data } = await axios.put(`${BASE_URL}/${token.id}`, token) + return data + }, + + /** + * Add a new token + * @param name The token name + */ + async addToken(name: string) { + logger.debug('Creating a new app token') + + try { + await confirmPassword() + + const { data } = await axios.post<ITokenResponse>(BASE_URL, { name }) + this.tokens.push(data.deviceToken) + logger.debug('App token created') + return data + } catch (error) { + return null + } + }, + + /** + * Delete a given app token + * @param token Token to delete + */ + async deleteToken(token: IToken) { + logger.debug('Deleting app token', { token }) + + this.tokens = this.tokens.filter(({ id }) => id !== token.id) + + try { + await axios.delete(`${BASE_URL}/${token.id}`) + logger.debug('App token deleted') + return true + } catch (error) { + logger.error('Could not delete app token', { error }) + showError(t('settings', 'Could not delete the app token')) + // Restore + this.tokens.push(token) + } + return false + }, + + /** + * Wipe a token and the connected device + * @param token Token to wipe + */ + async wipeToken(token: IToken) { + logger.debug('Wiping app token', { token }) + + try { + await confirmPassword() + + if (!(await confirm())) { + logger.debug('Wipe aborted by user') + return + } + + await axios.post(`${BASE_URL}/wipe/${token.id}`) + logger.debug('App token marked for wipe', { token }) + + token.type = TokenType.WIPING_TOKEN + return true + } catch (error) { + logger.error('Could not wipe app token', { error }) + showError(t('settings', 'Error while wiping the device with the token')) + } + return false + }, + + /** + * Rename an existing token + * @param token The token to rename + * @param newName The new name to set + */ + async renameToken(token: IToken, newName: string) { + logger.debug(`renaming app token ${token.id} from ${token.name} to '${newName}'`) + + const oldName = token.name + token.name = newName + + try { + await this.updateToken(token) + logger.debug('App token name updated') + return true + } catch (error) { + logger.error('Could not update app token name', { error }) + showError(t('settings', 'Error while updating device token name')) + // Restore + token.name = oldName + } + return false + }, + + /** + * Set scope of the token + * @param token Token to set scope + * @param scope scope to set + * @param value value to set + */ + async setTokenScope(token: IToken, scope: string, value: boolean) { + logger.debug('Updating app token scope', { token, scope, value }) + + const oldVal = token.scope[scope] + token.scope[scope] = value + + try { + await this.updateToken(token) + logger.debug('app token scope updated') + return true + } catch (error) { + logger.error('could not update app token scope', { error }) + showError(t('settings', 'Error while updating device token scope')) + // Restore + token.scope[scope] = oldVal + } + return false + }, + }, + +}) |