mirror of
https://github.com/nextcloud/server.git
synced 2026-06-07 15:53:04 -04:00
Merge pull request #42430 from nextcloud/backport/42334/stable28
[stable28] enh(settings): Refactor UI for session and app token management
This commit is contained in:
commit
81d49265c6
13 changed files with 852 additions and 617 deletions
|
|
@ -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)
|
||||
canChangeScope() {
|
||||
return this.token.type === TokenType.PERMANENT_TOKEN
|
||||
},
|
||||
lastActivity() {
|
||||
return OC.Util.formatDate(this.token.lastActivity * 1000, 'LLL')
|
||||
},
|
||||
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])
|
||||
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]
|
||||
}
|
||||
|
||||
icon = iconMap[client]
|
||||
}
|
||||
}
|
||||
if (this.token.current) {
|
||||
name = t('settings', 'This session')
|
||||
|
||||
for (const client in userAgentMap) {
|
||||
const matches = this.token.name.match(userAgentMap[client])
|
||||
if (matches) {
|
||||
return {
|
||||
id: client,
|
||||
os: matches[2] && matches[1],
|
||||
version: matches[2] ?? matches[1],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
icon,
|
||||
name,
|
||||
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) {
|
||||
return t('settings', 'This session')
|
||||
}
|
||||
if (this.client === null) {
|
||||
return this.token.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);
|
||||
.auth-token {
|
||||
border-top: 2px solid var(--color-border);
|
||||
max-width: 200px;
|
||||
white-space: normal;
|
||||
vertical-align: middle;
|
||||
position: relative;
|
||||
|
||||
&--wiping {
|
||||
background-color: var(--color-background-dark);
|
||||
}
|
||||
|
||||
td {
|
||||
border-top: 1px solid var(--color-border);
|
||||
max-width: 200px;
|
||||
white-space: normal;
|
||||
vertical-align: middle;
|
||||
position: relative;
|
||||
|
||||
&%icon {
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
&.token-name {
|
||||
padding: 10px 6px;
|
||||
|
||||
&.token-rename {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
&.token-name .wiping-warning {
|
||||
color: var(--color-text-lighter);
|
||||
}
|
||||
|
||||
&.more {
|
||||
@extend %icon;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
&.client {
|
||||
@extend %icon;
|
||||
|
||||
div {
|
||||
opacity: 0.57;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
}
|
||||
&__name {
|
||||
padding-block: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 355px; // ensure no jumping when renaming
|
||||
}
|
||||
|
||||
&__name-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__name-form {
|
||||
align-items: end;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
&__last-activity {
|
||||
padding-inline-start: 10px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
</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-device {
|
||||
padding-inline-start: 50px; // 44px icon + 6px padding
|
||||
}
|
||||
&__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>
|
||||
|
|
|
|||
114
apps/settings/src/components/AuthTokenSetup.vue
Normal file
114
apps/settings/src/components/AuthTokenSetup.vue
Normal file
|
|
@ -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>
|
||||
220
apps/settings/src/components/AuthTokenSetupDialog.vue
Normal file
220
apps/settings/src/components/AuthTokenSetupDialog.vue
Normal file
|
|
@ -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')
|
||||
|
|
|
|||
214
apps/settings/src/store/authtoken.ts
Normal file
214
apps/settings/src/store/authtoken.ts
Normal file
|
|
@ -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
|
||||
},
|
||||
},
|
||||
|
||||
})
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,3 +1,9 @@
|
|||
/*!
|
||||
* pinia v2.1.7
|
||||
* (c) 2023 Eduardo San Martin Morote
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
/*!
|
||||
* vue-qrcode v1.0.2
|
||||
* https://fengyuanchen.github.io/vue-qrcode
|
||||
|
|
@ -13,6 +19,29 @@
|
|||
*
|
||||
* @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
|
||||
*
|
||||
* 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/>.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
|
||||
*
|
||||
* @author Roeland Jago Douma <roeland@famdouma.nl>
|
||||
*
|
||||
* @license AGPL-3.0-or-later
|
||||
*
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
6
package-lock.json
generated
6
package-lock.json
generated
|
|
@ -10,6 +10,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",
|
||||
|
|
@ -3581,6 +3582,11 @@
|
|||
"unist-util-is": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mdi/js": {
|
||||
"version": "7.4.47",
|
||||
"resolved": "https://registry.npmjs.org/@mdi/js/-/js-7.4.47.tgz",
|
||||
"integrity": "sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ=="
|
||||
},
|
||||
"node_modules/@mdi/svg": {
|
||||
"version": "7.3.67",
|
||||
"resolved": "https://registry.npmjs.org/@mdi/svg/-/svg-7.3.67.tgz",
|
||||
|
|
|
|||
|
|
@ -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…
Reference in a new issue