diff options
Diffstat (limited to 'core/src/components')
-rw-r--r-- | core/src/components/ContactsMenu.js | 6 | ||||
-rw-r--r-- | core/src/components/HeaderMenu.vue | 215 | ||||
-rw-r--r-- | core/src/components/MainMenu.js | 29 | ||||
-rw-r--r-- | core/src/components/UnifiedSearch/SearchResult.vue | 257 | ||||
-rw-r--r-- | core/src/components/UnifiedSearch/SearchResultPlaceholders.vue | 100 | ||||
-rw-r--r-- | core/src/components/UserMenu.js | 7 | ||||
-rw-r--r-- | core/src/components/login/LoginButton.vue | 56 | ||||
-rw-r--r-- | core/src/components/login/LoginForm.vue | 43 | ||||
-rw-r--r-- | core/src/components/login/PasswordLessLoginForm.vue | 216 | ||||
-rw-r--r-- | core/src/components/login/ResetPassword.vue | 101 | ||||
-rw-r--r-- | core/src/components/login/UpdatePassword.vue | 2 | ||||
-rw-r--r-- | core/src/components/setup/RecommendedApps.vue | 18 |
12 files changed, 964 insertions, 86 deletions
diff --git a/core/src/components/ContactsMenu.js b/core/src/components/ContactsMenu.js index 0791fe83b0c..a2e09341067 100644 --- a/core/src/components/ContactsMenu.js +++ b/core/src/components/ContactsMenu.js @@ -1,7 +1,8 @@ /** * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + * @author Christoph Wurst <christoph@winzerhof-wurst.at> + * @author John Molakvoæ <skjnldsv@protonmail.com> * * @license GNU AGPL version 3 or any later version * @@ -16,7 +17,8 @@ * 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/>. + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * */ import $ from 'jquery' diff --git a/core/src/components/HeaderMenu.vue b/core/src/components/HeaderMenu.vue new file mode 100644 index 00000000000..12afa16e091 --- /dev/null +++ b/core/src/components/HeaderMenu.vue @@ -0,0 +1,215 @@ + <!-- + - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.com> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> +<template> + <div + :id="id" + v-click-outside="clickOutsideConfig" + :class="{ 'header-menu--opened': opened }" + class="header-menu"> + <a class="header-menu__trigger" + href="#" + :aria-controls="`header-menu-${id}`" + :aria-expanded="opened" + aria-haspopup="true" + @click.prevent="toggleMenu"> + <slot name="trigger" /> + </a> + <div v-if="opened" + :id="`header-menu-${id}`" + class="header-menu__wrapper" + role="menu"> + <div class="header-menu__carret" /> + <div class="header-menu__content"> + <slot /> + </div> + </div> + </div> +</template> + +<script> +import { directive as ClickOutside } from 'v-click-outside' +import excludeClickOutsideClasses from '@nextcloud/vue/dist/Mixins/excludeClickOutsideClasses' + +export default { + name: 'HeaderMenu', + + directives: { + ClickOutside, + }, + + mixins: [ + excludeClickOutsideClasses, + ], + + props: { + id: { + type: String, + required: true, + }, + open: { + type: Boolean, + default: false, + }, + }, + + data() { + return { + opened: this.open, + clickOutsideConfig: { + handler: this.closeMenu, + middleware: this.clickOutsideMiddleware, + }, + } + }, + + watch: { + open(newVal) { + this.opened = newVal + this.$nextTick(() => { + if (this.opened) { + this.openMenu() + } else { + this.closeMenu() + } + }) + }, + }, + + mounted() { + document.addEventListener('keydown', this.onKeyDown) + }, + beforeDestroy() { + document.removeEventListener('keydown', this.onKeyDown) + }, + + methods: { + /** + * Toggle the current menu open state + */ + toggleMenu() { + // Toggling current state + if (!this.opened) { + this.openMenu() + } else { + this.closeMenu() + } + }, + + /** + * Close the current menu + */ + closeMenu() { + if (!this.opened) { + return + } + + this.opened = false + this.$emit('close') + this.$emit('update:open', false) + }, + + /** + * Open the current menu + */ + openMenu() { + if (this.opened) { + return + } + + this.opened = true + this.$emit('open') + this.$emit('update:open', true) + }, + + onKeyDown(event) { + // If opened and escape pressed, close + if (event.key === 'Escape' && this.opened) { + event.preventDefault() + + /** user cancelled the menu by pressing escape */ + this.$emit('cancel') + + /** we do NOT fire a close event to differentiate cancel and close */ + this.opened = false + this.$emit('update:open', false) + } + }, + }, +} +</script> + +<style lang="scss" scoped> +.header-menu { + &__trigger { + display: flex; + align-items: center; + justify-content: center; + width: 50px; + height: 100%; + margin: 0; + padding: 0; + cursor: pointer; + opacity: .6; + } + + &--opened &__trigger, + &__trigger:hover, + &__trigger:focus, + &__trigger:active { + opacity: 1; + } + + &__wrapper { + position: absolute; + z-index: 2000; + top: 50px; + right: 5px; + box-sizing: border-box; + margin: 0; + border-radius: 0 0 var(--border-radius) var(--border-radius); + background-color: var(--color-main-background); + + filter: drop-shadow(0 1px 5px var(--color-box-shadow)); + } + + &__carret { + position: absolute; + right: 10px; + bottom: 100%; + width: 0; + height: 0; + content: ' '; + pointer-events: none; + border: 10px solid transparent; + border-bottom-color: var(--color-main-background); + } + + &__content { + overflow: auto; + width: 350px; + max-width: 350px; + min-height: calc(44px * 1.5); + max-height: calc(100vh - 50px * 2); + } +} + +</style> diff --git a/core/src/components/MainMenu.js b/core/src/components/MainMenu.js index d57711010ea..3b5aa19245e 100644 --- a/core/src/components/MainMenu.js +++ b/core/src/components/MainMenu.js @@ -1,7 +1,9 @@ -/* +/** * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + * @author Christoph Wurst <christoph@winzerhof-wurst.at> + * @author John Molakvoæ <skjnldsv@protonmail.com> + * @author Julius Härtl <jus@bitgrid.net> * * @license GNU AGPL version 3 or any later version * @@ -16,7 +18,8 @@ * 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/>. + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * */ import $ from 'jquery' @@ -29,6 +32,26 @@ import OC from '../OC' * If the screen is bigger, the main menu is not a toggle any more. */ export const setUp = () => { + + Object.assign(OC, { + setNavigationCounter(id, counter) { + const appmenuElement = document.getElementById('appmenu').querySelector('[data-id="' + id + '"] svg') + const appsElement = document.getElementById('apps').querySelector('[data-id="' + id + '"] svg') + if (counter === 0) { + appmenuElement.classList.remove('has-unread') + appsElement.classList.remove('has-unread') + appmenuElement.getElementsByTagName('image')[0].style.mask = '' + appsElement.getElementsByTagName('image')[0].style.mask = '' + } else { + appmenuElement.classList.add('has-unread') + appsElement.classList.add('has-unread') + appmenuElement.getElementsByTagName('image')[0].style.mask = 'url(#hole)' + appsElement.getElementsByTagName('image')[0].style.mask = 'url(#hole)' + } + document.getElementById('appmenu').querySelector('[data-id="' + id + '"] .unread-counter').textContent = counter + document.getElementById('apps').querySelector('[data-id="' + id + '"] .unread-counter').textContent = counter + }, + }) // init the more-apps menu OC.registerMenu($('#more-apps > a'), $('#navigation')) diff --git a/core/src/components/UnifiedSearch/SearchResult.vue b/core/src/components/UnifiedSearch/SearchResult.vue new file mode 100644 index 00000000000..e50cc413d03 --- /dev/null +++ b/core/src/components/UnifiedSearch/SearchResult.vue @@ -0,0 +1,257 @@ + <!-- + - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.com> + - + - @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> + <a :href="resourceUrl || '#'" + class="unified-search__result" + :class="{ + 'unified-search__result--focused': focused, + }" + @click="reEmitEvent" + @focus="reEmitEvent"> + + <!-- Icon describing the result --> + <div class="unified-search__result-icon" + :class="{ + 'unified-search__result-icon--rounded': rounded, + 'unified-search__result-icon--no-preview': !hasValidThumbnail && !loaded, + 'unified-search__result-icon--with-thumbnail': hasValidThumbnail && loaded, + [icon]: !loaded && !isIconUrl, + }" + :style="{ + backgroundImage: isIconUrl ? `url(${icon})` : '', + }" + role="img"> + + <img v-if="hasValidThumbnail" + v-show="loaded" + :src="thumbnailUrl" + alt="" + @error="onError" + @load="onLoad"> + </div> + + <!-- Title and sub-title --> + <span class="unified-search__result-content"> + <h3 class="unified-search__result-line-one" :title="title"> + <Highlight :text="title" :search="query" /> + </h3> + <h4 v-if="subline" class="unified-search__result-line-two" :title="subline">{{ subline }}</h4> + </span> + </a> +</template> + +<script> +import Highlight from '@nextcloud/vue/dist/Components/Highlight' + +export default { + name: 'SearchResult', + + components: { + Highlight, + }, + + props: { + thumbnailUrl: { + type: String, + default: null, + }, + title: { + type: String, + required: true, + }, + subline: { + type: String, + default: null, + }, + resourceUrl: { + type: String, + default: null, + }, + icon: { + type: String, + default: '', + }, + rounded: { + type: Boolean, + default: false, + }, + query: { + type: String, + default: '', + }, + + /** + * Only used for the first result as a visual feedback + * so we can keep the search input focused but pressing + * enter still opens the first result + */ + focused: { + type: Boolean, + default: false, + }, + }, + + data() { + return { + hasValidThumbnail: this.thumbnailUrl && this.thumbnailUrl.trim() !== '', + loaded: false, + } + }, + + computed: { + isIconUrl() { + // If we're facing an absolute url + if (this.icon.startsWith('/')) { + return true + } + + // Otherwise, let's check if this is a valid url + try { + // eslint-disable-next-line no-new + new URL(this.icon) + } catch { + return false + } + return true + }, + }, + + watch: { + // Make sure to reset state on change even when vue recycle the component + thumbnailUrl() { + this.hasValidThumbnail = this.thumbnailUrl && this.thumbnailUrl.trim() !== '' + this.loaded = false + }, + }, + + methods: { + reEmitEvent(e) { + this.$emit(e.type, e) + }, + + /** + * If the image fails to load, fallback to iconClass + */ + onError() { + this.hasValidThumbnail = false + }, + + onLoad() { + this.loaded = true + }, + }, +} +</script> + +<style lang="scss" scoped> +$clickable-area: 44px; +$margin: 10px; + +.unified-search__result { + display: flex; + height: $clickable-area; + padding: $margin; + border-bottom: 1px solid var(--color-border); + + // Load more entry, + &:last-child { + border-bottom: none; + } + + &--focused, + &:active, + &:hover, + &:focus { + background-color: var(--color-background-hover); + } + + * { + cursor: pointer; + } + + &-icon { + overflow: hidden; + width: $clickable-area; + height: $clickable-area; + border-radius: var(--border-radius); + background-repeat: no-repeat; + background-position: center center; + background-size: 32px; + &--rounded { + border-radius: $clickable-area / 2; + } + &--no-preview { + background-size: 32px; + } + &--with-thumbnail { + background-size: cover; + } + &--with-thumbnail:not(&--rounded) { + // compensate for border + max-width: $clickable-area - 2px; + max-height: $clickable-area - 2px; + border: 1px solid var(--color-border); + } + + img { + // Make sure to keep ratio + width: 100%; + height: 100%; + + object-fit: cover; + object-position: center; + } + } + + &-icon, + &-actions { + flex: 0 0 $clickable-area; + } + + &-content { + display: flex; + align-items: center; + flex: 1 1 100%; + flex-wrap: wrap; + // Set to minimum and gro from it + min-width: 0; + padding-left: $margin; + } + + &-line-one, + &-line-two { + overflow: hidden; + flex: 1 1 100%; + margin: 1px 0; + white-space: nowrap; + text-overflow: ellipsis; + // Use the same color as the `a` + color: inherit; + font-size: inherit; + } + &-line-two { + opacity: .7; + font-size: var(--default-font-size); + } +} + +</style> diff --git a/core/src/components/UnifiedSearch/SearchResultPlaceholders.vue b/core/src/components/UnifiedSearch/SearchResultPlaceholders.vue new file mode 100644 index 00000000000..31f85f413d3 --- /dev/null +++ b/core/src/components/UnifiedSearch/SearchResultPlaceholders.vue @@ -0,0 +1,100 @@ +<template> + <ul> + <!-- Placeholder animation --> + <svg class="unified-search__result-placeholder-gradient"> + <defs> + <linearGradient id="unified-search__result-placeholder-gradient"> + <stop offset="0%" :stop-color="light"> + <animate attributeName="stop-color" + :values="`${light}; ${light}; ${dark}; ${dark}; ${light}`" + dur="2s" + repeatCount="indefinite" /> + </stop> + <stop offset="100%" :stop-color="dark"> + <animate attributeName="stop-color" + :values="`${dark}; ${light}; ${light}; ${dark}; ${dark}`" + dur="2s" + repeatCount="indefinite" /> + </stop> + </linearGradient> + </defs> + </svg> + + <!-- Placeholders --> + <li v-for="placeholder in [1, 2, 3]" :key="placeholder"> + <svg + class="unified-search__result-placeholder" + xmlns="http://www.w3.org/2000/svg" + fill="url(#unified-search__result-placeholder-gradient)"> + <rect class="unified-search__result-placeholder-icon" /> + <rect class="unified-search__result-placeholder-line-one" /> + <rect class="unified-search__result-placeholder-line-two" :style="{width: `calc(${randWidth()}%)`}" /> + </svg> + </li> + </ul> +</template> + +<script> +export default { + name: 'SearchResultPlaceholders', + + data() { + return { + light: null, + dark: null, + } + }, + mounted() { + const styles = getComputedStyle(document.documentElement) + this.dark = styles.getPropertyValue('--color-placeholder-dark') + this.light = styles.getPropertyValue('--color-placeholder-light') + }, + + methods: { + randWidth() { + return Math.floor(Math.random() * 20) + 30 + }, + }, +} +</script> + +<style lang="scss" scoped> +$clickable-area: 44px; +$margin: 10px; + +.unified-search__result-placeholder-gradient { + position: fixed; + height: 0; + width: 0; + z-index: -1; +} + +.unified-search__result-placeholder { + width: calc(100% - 2 * #{$margin}); + height: $clickable-area; + margin: $margin; + + &-icon { + width: $clickable-area; + height: $clickable-area; + rx: var(--border-radius); + ry: var(--border-radius); + } + + &-line-one, + &-line-two { + width: calc(100% - #{$margin + $clickable-area}); + height: 1em; + x: $margin + $clickable-area; + } + + &-line-one { + y: 5px; + } + + &-line-two { + y: 25px; + } +} + +</style> diff --git a/core/src/components/UserMenu.js b/core/src/components/UserMenu.js index a9e7d8725bb..c4cb6527da3 100644 --- a/core/src/components/UserMenu.js +++ b/core/src/components/UserMenu.js @@ -1,7 +1,7 @@ -/* +/** * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> * - * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + * @author Christoph Wurst <christoph@winzerhof-wurst.at> * * @license GNU AGPL version 3 or any later version * @@ -16,7 +16,8 @@ * 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/>. + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * */ import OC from '../OC' diff --git a/core/src/components/login/LoginButton.vue b/core/src/components/login/LoginButton.vue new file mode 100644 index 00000000000..f7d426e6c63 --- /dev/null +++ b/core/src/components/login/LoginButton.vue @@ -0,0 +1,56 @@ +<!-- + - @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at> + - + - @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + --> + +<template> + <div id="submit-wrapper" @click="$emit('click')"> + <input id="submit-form" + type="submit" + class="login primary" + title="" + :value="!loading ? t('core', 'Log in') : t('core', 'Logging in …')"> + <div class="submit-icon" + :class="{ + 'icon-confirm-white': !loading, + 'icon-loading-small': loading && invertedColors, + 'icon-loading-small-dark': loading && !invertedColors, + }" /> + </div> +</template> + +<script> +export default { + name: 'LoginButton', + props: { + loading: { + type: Boolean, + required: true, + }, + invertedColors: { + type: Boolean, + default: false, + }, + }, +} +</script> + +<style scoped> + +</style> diff --git a/core/src/components/login/LoginForm.vue b/core/src/components/login/LoginForm.vue index 687896ceb54..c75b6f2c7ea 100644 --- a/core/src/components/login/LoginForm.vue +++ b/core/src/components/login/LoginForm.vue @@ -20,9 +20,10 @@ --> <template> - <form method="post" + <form ref="loginForm" + method="post" name="login" - :action="OC.generateUrl('login')" + :action="loginActionUrl" @submit="submit"> <fieldset> <div v-if="apacheAuthFailed" @@ -46,7 +47,7 @@ class="hidden"> <img class="float-spinner" alt="" - :src="OC.imagePath('core', 'loading-dark.gif')"> + :src="loadingIcon"> <span id="messageText" /> <!-- the following div ensures that the spinner is always inside the #message div --> <div style="clear: both;" /> @@ -58,12 +59,13 @@ v-model="user" type="text" name="user" + autocapitalize="off" :autocomplete="autoCompleteAllowed ? 'on' : 'off'" :placeholder="t('core', 'Username or email')" :aria-label="t('core', 'Username or email')" required @change="updateUsername"> - <label for="user" class="infield">{{ t('core', 'Username or email') }}</label> + <label for="user" class="infield">{{ t('core', 'Username or email') }}</label> </p> <p class="groupbottom" @@ -80,23 +82,11 @@ <label for="password" class="infield">{{ t('Password') }}</label> <a href="#" class="toggle-password" @click.stop.prevent="togglePassword"> - <img :src="OC.imagePath('core', 'actions/toggle.svg')"> + <img :src="toggleIcon" :alt="t('core', 'Toggle password visibility')"> </a> </p> - <div id="submit-wrapper"> - <input id="submit-form" - type="submit" - class="login primary" - title="" - :value="!loading ? t('core', 'Log in') : t('core', 'Logging in …')"> - <div class="submit-icon" - :class="{ - 'icon-confirm-white': !loading, - 'icon-loading-small': loading && invertedColors, - 'icon-loading-small-dark': loading && !invertedColors, - }" /> - </div> + <LoginButton :loading="loading" :inverted-colors="invertedColors" /> <p v-if="invalidPassword" class="warning wrongPasswordMsg"> @@ -104,7 +94,7 @@ </p> <p v-else-if="userDisabled" class="warning userDisabledMsg"> - {{ t('lib', 'User disabled') }} + {{ t('core', 'User disabled') }} </p> <p v-if="throttleDelay && throttleDelay > 5000" @@ -135,9 +125,15 @@ <script> import jstz from 'jstimezonedetect' +import LoginButton from './LoginButton' +import { + generateUrl, + imagePath, +} from '@nextcloud/router' export default { name: 'LoginForm', + components: { LoginButton }, props: { username: { type: String, @@ -193,6 +189,15 @@ export default { userDisabled() { return this.errors.indexOf('userdisabled') !== -1 }, + toggleIcon() { + return imagePath('core', 'actions/toggle.svg') + }, + loadingIcon() { + return imagePath('core', 'loading-dark.gif') + }, + loginActionUrl() { + return generateUrl('login') + }, }, mounted() { if (this.username === '') { diff --git a/core/src/components/login/PasswordLessLoginForm.vue b/core/src/components/login/PasswordLessLoginForm.vue new file mode 100644 index 00000000000..0203bd74c48 --- /dev/null +++ b/core/src/components/login/PasswordLessLoginForm.vue @@ -0,0 +1,216 @@ +<template> + <form v-if="(isHttps || isLocalhost) && hasPublicKeyCredential" + ref="loginForm" + method="post" + name="login" + @submit.prevent="submit"> + <fieldset> + <p class="grouptop groupbottom"> + <input id="user" + ref="user" + v-model="user" + type="text" + name="user" + :autocomplete="autoCompleteAllowed ? 'on' : 'off'" + :placeholder="t('core', 'Username or email')" + :aria-label="t('core', 'Username or email')" + required + @change="$emit('update:username', user)"> + <label for="user" class="infield">{{ t('core', 'Username or email') }}</label> + </p> + + <div v-if="!validCredentials"> + {{ t('core', 'Your account is not setup for passwordless login.') }} + </div> + + <LoginButton v-if="validCredentials" + :loading="loading" + :inverted-colors="invertedColors" + @click="authenticate" /> + </fieldset> + </form> + <div v-else-if="!hasPublicKeyCredential"> + {{ t('core', 'Passwordless authentication is not supported in your browser.') }} + </div> + <div v-else-if="!isHttps && !isLocalhost"> + {{ t('core', 'Passwordless authentication is only available over a secure connection.') }} + </div> +</template> + +<script> +import { + startAuthentication, + finishAuthentication, +} from '../../services/WebAuthnAuthenticationService' +import LoginButton from './LoginButton' + +class NoValidCredentials extends Error { + +} + +export default { + name: 'PasswordLessLoginForm', + components: { + LoginButton, + }, + props: { + username: { + type: String, + default: '', + }, + redirectUrl: { + type: String, + }, + invertedColors: { + type: Boolean, + default: false, + }, + autoCompleteAllowed: { + type: Boolean, + default: true, + }, + isHttps: { + type: Boolean, + default: false, + }, + isLocalhost: { + type: Boolean, + default: false, + }, + hasPublicKeyCredential: { + type: Boolean, + default: false, + }, + }, + data() { + return { + user: this.username, + loading: false, + validCredentials: true, + } + }, + methods: { + authenticate() { + console.debug('passwordless login initiated') + + this.getAuthenticationData(this.user) + .then(publicKey => { + console.debug(publicKey) + return publicKey + }) + .then(this.sign) + .then(this.completeAuthentication) + .catch(error => { + if (error instanceof NoValidCredentials) { + this.validCredentials = false + return + } + console.debug(error) + }) + }, + getAuthenticationData(uid) { + const base64urlDecode = function(input) { + // Replace non-url compatible chars with base64 standard chars + input = input + .replace(/-/g, '+') + .replace(/_/g, '/') + + // Pad out with standard base64 required padding characters + const pad = input.length % 4 + if (pad) { + if (pad === 1) { + throw new Error('InvalidLengthError: Input base64url string is the wrong length to determine padding') + } + input += new Array(5 - pad).join('=') + } + + return window.atob(input) + } + + return startAuthentication(uid) + .then(publicKey => { + console.debug('Obtained PublicKeyCredentialRequestOptions') + console.debug(publicKey) + + if (!Object.prototype.hasOwnProperty.call(publicKey, 'allowCredentials')) { + console.debug('No credentials found.') + throw new NoValidCredentials() + } + + publicKey.challenge = Uint8Array.from(base64urlDecode(publicKey.challenge), c => c.charCodeAt(0)) + publicKey.allowCredentials = publicKey.allowCredentials.map(function(data) { + return { + ...data, + id: Uint8Array.from(base64urlDecode(data.id), c => c.charCodeAt(0)), + } + }) + + console.debug('Converted PublicKeyCredentialRequestOptions') + console.debug(publicKey) + return publicKey + }) + .catch(error => { + console.debug('Error while obtaining data') + throw error + }) + }, + sign(publicKey) { + const arrayToBase64String = function(a) { + return window.btoa(String.fromCharCode(...a)) + } + + const arrayToString = function(a) { + return String.fromCharCode(...a) + } + + return navigator.credentials.get({ publicKey }) + .then(data => { + console.debug(data) + console.debug(new Uint8Array(data.rawId)) + console.debug(arrayToBase64String(new Uint8Array(data.rawId))) + return { + id: data.id, + type: data.type, + rawId: arrayToBase64String(new Uint8Array(data.rawId)), + response: { + authenticatorData: arrayToBase64String(new Uint8Array(data.response.authenticatorData)), + clientDataJSON: arrayToBase64String(new Uint8Array(data.response.clientDataJSON)), + signature: arrayToBase64String(new Uint8Array(data.response.signature)), + userHandle: data.response.userHandle ? arrayToString(new Uint8Array(data.response.userHandle)) : null, + }, + } + }) + .then(challenge => { + console.debug(challenge) + return challenge + }) + .catch(error => { + console.debug('GOT AN ERROR!') + console.debug(error) // Example: timeout, interaction refused... + }) + }, + completeAuthentication(challenge) { + console.debug('TIME TO COMPLETE') + + const location = this.redirectUrl + + return finishAuthentication(JSON.stringify(challenge)) + .then(data => { + console.debug('Logged in redirecting') + window.location.href = location + }) + .catch(error => { + console.debug('GOT AN ERROR WHILE SUBMITTING CHALLENGE!') + console.debug(error) // Example: timeout, interaction refused... + }) + }, + submit() { + // noop + }, + }, +} +</script> + +<style scoped> + +</style> diff --git a/core/src/components/login/ResetPassword.vue b/core/src/components/login/ResetPassword.vue index 2d045692285..79589a96aca 100644 --- a/core/src/components/login/ResetPassword.vue +++ b/core/src/components/login/ResetPassword.vue @@ -21,61 +21,64 @@ <template> <form @submit.prevent="submit"> - <p> - <input id="user" - v-model="user" - type="text" - name="user" - :placeholder="t('core', 'Username or email')" - :aria-label="t('core', 'Username or email')" - required - @change="updateUsername"> - <!--<?php p($_['user_autofocus'] ? 'autofocus' : ''); ?> - autocomplete="<?php p($_['login_form_autocomplete']); ?>" autocapitalize="none" autocorrect="off"--> - <label for="user" class="infield">{{ t('core', 'Username or email') }}</label> - </p> - <div id="reset-password-wrapper"> - <input id="reset-password-submit" - type="submit" - class="login primary" - title="" - :value="t('core', 'Reset password')"> - <div class="submit-icon" - :class="{ - 'icon-confirm-white': !loading, - 'icon-loading-small': loading && invertedColors, - 'icon-loading-small-dark': loading && !invertedColors, - }" /> - </div> - <p v-if="message === 'send-success'" - class="update"> - {{ t('core', 'A password reset message has been sent to the e-mail address of this account. If you do not receive it, check your spam/junk folders or ask your local administrator for help.') }} - <br> - {{ t('core', 'If it is not there ask your local administrator.') }} - </p> - <p v-else-if="message === 'send-error'" - class="update warning"> - {{ t('core', 'Couldn\'t send reset email. Please contact your administrator.') }} - </p> - <p v-else-if="message === 'reset-error'" - class="update warning"> - {{ t('core', 'Password can not be changed. Please contact your administrator.') }} - </p> - <p v-else-if="message" - class="update" - :class="{warning: error}" /> + <fieldset> + <p> + <input id="user" + v-model="user" + type="text" + name="user" + autocapitalize="off" + :placeholder="t('core', 'Username or email')" + :aria-label="t('core', 'Username or email')" + required + @change="updateUsername"> + <!--<?php p($_['user_autofocus'] ? 'autofocus' : ''); ?> + autocomplete="<?php p($_['login_form_autocomplete']); ?>" autocapitalize="none" autocorrect="off"--> + <label for="user" class="infield">{{ t('core', 'Username or email') }}</label> + </p> + <div id="reset-password-wrapper"> + <input id="reset-password-submit" + type="submit" + class="login primary" + title="" + :value="t('core', 'Reset password')"> + <div class="submit-icon" + :class="{ + 'icon-confirm-white': !loading, + 'icon-loading-small': loading && invertedColors, + 'icon-loading-small-dark': loading && !invertedColors, + }" /> + </div> + <p v-if="message === 'send-success'" + class="update"> + {{ t('core', 'A password reset message has been sent to the email address of this account. If you do not receive it, check your spam/junk folders or ask your local administrator for help.') }} + <br> + {{ t('core', 'If it is not there ask your local administrator.') }} + </p> + <p v-else-if="message === 'send-error'" + class="update warning"> + {{ t('core', 'Couldn\'t send reset email. Please contact your administrator.') }} + </p> + <p v-else-if="message === 'reset-error'" + class="update warning"> + {{ t('core', 'Password cannot be changed. Please contact your administrator.') }} + </p> + <p v-else-if="message" + class="update" + :class="{warning: error}" /> - <a href="#" - @click.prevent="$emit('abort')"> - {{ t('core', 'Back to login') }} - </a> + <a href="#" + @click.prevent="$emit('abort')"> + {{ t('core', 'Back to login') }} + </a> + </fieldset> </form> </template> <script> import axios from '@nextcloud/axios' -import { generateUrl } from '../../OC/routing' +import { generateUrl } from '@nextcloud/router' export default { name: 'ResetPassword', @@ -130,7 +133,7 @@ export default { this.message = 'send-success' }) .catch(e => { - console.error('could not send reset e-mail request', e) + console.error('could not send reset email request', e) this.error = true this.message = 'send-error' diff --git a/core/src/components/login/UpdatePassword.vue b/core/src/components/login/UpdatePassword.vue index 3fa3c60773c..cb615a846d2 100644 --- a/core/src/components/login/UpdatePassword.vue +++ b/core/src/components/login/UpdatePassword.vue @@ -125,7 +125,7 @@ export default { } } catch (e) { this.error = true - this.message = e.message ? e.message : t('core', 'Password can not be changed. Please contact your administrator.') + this.message = e.message ? e.message : t('core', 'Password cannot be changed. Please contact your administrator.') } finally { this.loading = false } diff --git a/core/src/components/setup/RecommendedApps.vue b/core/src/components/setup/RecommendedApps.vue index 550c3aaff70..581fb99582c 100644 --- a/core/src/components/setup/RecommendedApps.vue +++ b/core/src/components/setup/RecommendedApps.vue @@ -26,17 +26,17 @@ {{ t('core', 'Loading apps …') }} </p> <p v-else-if="loadingAppsError" class="loading-error text-center"> - {{ t('core', 'Could not fetch list of apps from the app store.') }} + {{ t('core', 'Could not fetch list of apps from the App Store.') }} </p> <p v-else class="text-center"> {{ t('core', 'Installing apps …') }} </p> <div v-for="app in recommendedApps" :key="app.id" class="app"> - <img :src="customIcon(app.id)" :alt="t('core', 'Nextcloud {app}', { app: app.name })"> + <img :src="customIcon(app.id)" alt=""> <div class="info"> <h3> {{ app.name }} - <span v-if="app.loading" class="icon icon-loading-small" /> + <span v-if="app.loading" class="icon icon-loading-small-dark" /> <span v-else-if="app.active" class="icon icon-checkmark-white" /> </h3> <p v-html="customDescription(app.id)" /> @@ -44,10 +44,10 @@ <strong>{{ t('core', 'App download or installation failed') }}</strong> </p> <p v-else-if="!app.isCompatible"> - <strong>{{ t('core', 'Can\'t install this app because it is not compatible') }}</strong> + <strong>{{ t('core', 'Cannot install this app because it is not compatible') }}</strong> </p> <p v-else-if="!app.canInstall"> - <strong>{{ t('core', 'Can\'t install this app') }}</strong> + <strong>{{ t('core', 'Cannot install this app') }}</strong> </p> </div> </div> @@ -82,11 +82,11 @@ const recommended = { spreed: { description: t('core', 'Chatting, video calls, screensharing, online meetings and web conferencing – in your browser and with mobile apps.'), }, - onlyoffice: { + richdocuments: { description: t('core', 'Collaboratively edit office documents.'), }, - documentserver_community: { - description: t('core', 'Local document editing back-end used by the OnlyOffice app.'), + richdocumentscode: { + description: t('core', 'Local document editing back-end used by the Collabora Online app.'), }, } const recommendedIds = Object.keys(recommended) @@ -135,7 +135,7 @@ export default { .map(app => limit(() => { logger.info(`installing ${app.id}`) app.loading = true - return axios.post(generateUrl(`settings/apps/enable`), { appIds: [app.id], groups: [] }) + return axios.post(generateUrl('settings/apps/enable'), { appIds: [app.id], groups: [] }) .catch(error => { logger.error(`could not install ${app.id}`, { error }) app.installationError = true |