aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
authorFerdinand Thiessen <opensource@fthiessen.de>2023-12-16 23:38:17 +0100
committerFerdinand Thiessen <opensource@fthiessen.de>2023-12-21 15:15:52 +0100
commit960bec949aa08a5c67a5fd2699213048f56ea0b7 (patch)
tree42a5e39fc57b0957f4385b6366aa212201ff0d6d /apps
parent0dcea036a7d8ce7b078d051c1752971ddef0473f (diff)
downloadnextcloud-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.vue319
-rw-r--r--apps/settings/src/components/AuthTokenList.vue122
-rw-r--r--apps/settings/src/components/AuthTokenSection.vue157
-rw-r--r--apps/settings/src/components/AuthTokenSetup.vue114
-rw-r--r--apps/settings/src/components/AuthTokenSetupDialog.vue220
-rw-r--r--apps/settings/src/components/AuthTokenSetupDialogue.vue239
-rw-r--r--apps/settings/src/main-personal-security.js18
-rw-r--r--apps/settings/src/store/authtoken.ts214
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
+ },
+ },
+
+})