Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/nextcloud/server.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMichaIng <micha@dietpi.com>2021-06-20 13:20:10 +0300
committerGitHub <noreply@github.com>2021-06-20 13:20:10 +0300
commit4f47bec782c90d89a501e7ed5ed43c2816a8ce81 (patch)
treead5bac61b7a005a92f234a44531354ff6ab24032 /core/src/components
parent4d51ed3918032c44df612fad6b2c12b0e9eff693 (diff)
parent61a31dcdd73aae9a728551421116c5947e5b3089 (diff)
Merge branch 'master' into improve-contributing-docsimprove-contributing-docs
Diffstat (limited to 'core/src/components')
-rw-r--r--core/src/components/ContactsMenu.js6
-rw-r--r--core/src/components/HeaderMenu.vue215
-rw-r--r--core/src/components/MainMenu.js29
-rw-r--r--core/src/components/UnifiedSearch/SearchResult.vue257
-rw-r--r--core/src/components/UnifiedSearch/SearchResultPlaceholders.vue100
-rw-r--r--core/src/components/UserMenu.js7
-rw-r--r--core/src/components/login/LoginButton.vue56
-rw-r--r--core/src/components/login/LoginForm.vue43
-rw-r--r--core/src/components/login/PasswordLessLoginForm.vue216
-rw-r--r--core/src/components/login/ResetPassword.vue101
-rw-r--r--core/src/components/login/UpdatePassword.vue2
-rw-r--r--core/src/components/setup/RecommendedApps.vue18
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