enh(settings): Refactor frontend for session and app token management

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
pull/42334/head
Ferdinand Thiessen 11 months ago
parent 0dcea036a7
commit 960bec949a
No known key found for this signature in database
GPG Key ID: 45FAE7268762B400
  1. 319
      apps/settings/src/components/AuthToken.vue
  2. 122
      apps/settings/src/components/AuthTokenList.vue
  3. 157
      apps/settings/src/components/AuthTokenSection.vue
  4. 114
      apps/settings/src/components/AuthTokenSetup.vue
  5. 220
      apps/settings/src/components/AuthTokenSetupDialog.vue
  6. 239
      apps/settings/src/components/AuthTokenSetupDialogue.vue
  7. 18
      apps/settings/src/main-personal-security.js
  8. 214
      apps/settings/src/store/authtoken.ts
  9. 1
      package.json

@ -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>

@ -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>

@ -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>

@ -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>

@ -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>

@ -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>

@ -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')

@ -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
},
},
})

@ -37,6 +37,7 @@
"license": "AGPL-3.0-or-later",
"dependencies": {
"@chenfengyuan/vue-qrcode": "^1.0.2",
"@mdi/js": "^7.3.67",
"@mdi/svg": "^7.3.67",
"@nextcloud/auth": "^2.1.0",
"@nextcloud/axios": "^2.3.0",

Loading…
Cancel
Save