mirror of
https://github.com/nextcloud/server.git
synced 2026-06-04 22:35:24 -04:00
Merge pull request #43488 from nextcloud/fix/use-nc-components-account-property-section
fix: Use nextcloud-vue components for personal info settings
This commit is contained in:
commit
8134559bba
29 changed files with 876 additions and 484 deletions
|
|
@ -99,7 +99,7 @@ export default {
|
|||
flex-direction: column;
|
||||
margin: 10px 32px 10px 0;
|
||||
gap: 16px 0;
|
||||
color: var(--color-text-lighter);
|
||||
color: var(--color-text-maxcontrast);
|
||||
|
||||
&__groups,
|
||||
&__quota {
|
||||
|
|
@ -117,7 +117,7 @@ export default {
|
|||
font-weight: bold;
|
||||
}
|
||||
|
||||
&::v-deep .material-design-icon {
|
||||
&:deep(.material-design-icon) {
|
||||
align-self: flex-start;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,63 +23,69 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="email">
|
||||
<input :id="inputIdWithDefault"
|
||||
<NcInputField :id="inputIdWithDefault"
|
||||
ref="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
:aria-label="inputPlaceholder"
|
||||
:placeholder="inputPlaceholder"
|
||||
:value="email"
|
||||
:aria-describedby="helperText ? `${inputIdWithDefault}-helper-text` : undefined"
|
||||
autocapitalize="none"
|
||||
autocomplete="email"
|
||||
:error="hasError || !!helperText"
|
||||
:helper-text="helperText || undefined"
|
||||
:label="inputPlaceholder"
|
||||
:placeholder="inputPlaceholder"
|
||||
spellcheck="false"
|
||||
@input="onEmailChange">
|
||||
:success="isSuccess"
|
||||
type="email"
|
||||
:value.sync="emailAddress" />
|
||||
|
||||
<div class="email__actions-container">
|
||||
<transition name="fade">
|
||||
<Check v-if="showCheckmarkIcon" :size="20" />
|
||||
<AlertOctagon v-else-if="showErrorIcon" :size="20" />
|
||||
</transition>
|
||||
|
||||
<template v-if="!primary">
|
||||
<FederationControl :readable="propertyReadable"
|
||||
:additional="true"
|
||||
:additional-value="email"
|
||||
:disabled="federationDisabled"
|
||||
:handle-additional-scope-change="saveAdditionalEmailScope"
|
||||
:scope.sync="localScope"
|
||||
@update:scope="onScopeChange" />
|
||||
</template>
|
||||
|
||||
<NcActions class="email__actions"
|
||||
:aria-label="t('settings', 'Email options')"
|
||||
:force-menu="true">
|
||||
<NcActionButton :aria-label="deleteEmailLabel"
|
||||
:close-after-click="true"
|
||||
:disabled="deleteDisabled"
|
||||
icon="icon-delete"
|
||||
@click.stop.prevent="deleteEmail">
|
||||
{{ deleteEmailLabel }}
|
||||
</NcActionButton>
|
||||
<NcActionButton v-if="!primary || !isNotificationEmail"
|
||||
:aria-label="setNotificationMailLabel"
|
||||
:close-after-click="true"
|
||||
:disabled="setNotificationMailDisabled"
|
||||
icon="icon-favorite"
|
||||
@click.stop.prevent="setNotificationMail">
|
||||
{{ setNotificationMailLabel }}
|
||||
</NcActionButton>
|
||||
<div class="email__actions">
|
||||
<NcActions :aria-label="actionsLabel" @close="showFederationSettings = false">
|
||||
<template v-if="showFederationSettings">
|
||||
<NcActionButton @click="showFederationSettings = false">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiArrowLeft" />
|
||||
</template>
|
||||
{{ t('settings', 'Back') }}
|
||||
</NcActionButton>
|
||||
<FederationControlActions :readable="propertyReadable"
|
||||
:additional="true"
|
||||
:additional-value="email"
|
||||
:disabled="federationDisabled"
|
||||
:handle-additional-scope-change="saveAdditionalEmailScope"
|
||||
:scope.sync="localScope"
|
||||
@update:scope="onScopeChange" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<NcActionButton v-if="!federationDisabled && !primary"
|
||||
@click="showFederationSettings = true">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiLock" />
|
||||
</template>
|
||||
{{ t('settings', 'Change scope level of {property}', { property: propertyReadable.toLocaleLowerCase() }) }}
|
||||
</NcActionButton>
|
||||
<NcActionCaption v-if="!isConfirmedAddress"
|
||||
:name="t('settings', 'This address is not confirmed')" />
|
||||
<NcActionButton close-after-click
|
||||
:disabled="deleteDisabled"
|
||||
@click="deleteEmail">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiTrashCan" />
|
||||
</template>
|
||||
{{ deleteEmailLabel }}
|
||||
</NcActionButton>
|
||||
<NcActionButton v-if="!primary || !isNotificationEmail"
|
||||
close-after-click
|
||||
:disabled="!isConfirmedAddress"
|
||||
@click="setNotificationMail">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper v-if="isNotificationEmail" :path="mdiStar" />
|
||||
<NcIconSvgWrapper v-else :path="mdiStarOutline" />
|
||||
</template>
|
||||
{{ setNotificationMailLabel }}
|
||||
</NcActionButton>
|
||||
</template>
|
||||
</NcActions>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="helperText"
|
||||
:id="`${inputIdWithDefault}-helper-text`"
|
||||
class="email__helper-text-message email__helper-text-message--error">
|
||||
<AlertCircle class="email__helper-text-message__icon" :size="18" />
|
||||
{{ helperText }}
|
||||
</p>
|
||||
|
||||
<em v-if="isNotificationEmail">
|
||||
{{ t('settings', 'Primary email for password reset and notifications') }}
|
||||
</em>
|
||||
|
|
@ -89,12 +95,13 @@
|
|||
<script>
|
||||
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
|
||||
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
|
||||
import AlertCircle from 'vue-material-design-icons/AlertCircleOutline.vue'
|
||||
import AlertOctagon from 'vue-material-design-icons/AlertOctagon.vue'
|
||||
import Check from 'vue-material-design-icons/Check.vue'
|
||||
import NcActionCaption from '@nextcloud/vue/dist/Components/NcActionCaption.js'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
|
||||
import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js'
|
||||
import debounce from 'debounce'
|
||||
|
||||
import FederationControl from '../shared/FederationControl.vue'
|
||||
import { mdiArrowLeft, mdiLock, mdiStar, mdiStarOutline, mdiTrashCan } from '@mdi/js'
|
||||
import FederationControlActions from '../shared/FederationControlActions.vue'
|
||||
import { handleError } from '../../../utils/handlers.js'
|
||||
|
||||
import { ACCOUNT_PROPERTY_READABLE_ENUM, VERIFICATION_ENUM } from '../../../constants/AccountPropertyConstants.js'
|
||||
|
|
@ -114,10 +121,10 @@ export default {
|
|||
components: {
|
||||
NcActions,
|
||||
NcActionButton,
|
||||
AlertCircle,
|
||||
AlertOctagon,
|
||||
Check,
|
||||
FederationControl,
|
||||
NcActionCaption,
|
||||
NcIconSvgWrapper,
|
||||
NcInputField,
|
||||
FederationControlActions,
|
||||
},
|
||||
|
||||
props: {
|
||||
|
|
@ -152,19 +159,38 @@ export default {
|
|||
},
|
||||
},
|
||||
|
||||
setup() {
|
||||
return {
|
||||
mdiArrowLeft,
|
||||
mdiLock,
|
||||
mdiStar,
|
||||
mdiStarOutline,
|
||||
mdiTrashCan,
|
||||
saveAdditionalEmailScope,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
propertyReadable: ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL,
|
||||
initialEmail: this.email,
|
||||
localScope: this.scope,
|
||||
saveAdditionalEmailScope,
|
||||
hasError: false,
|
||||
helperText: null,
|
||||
showCheckmarkIcon: false,
|
||||
showErrorIcon: false,
|
||||
initialEmail: this.email,
|
||||
isSuccess: false,
|
||||
localScope: this.scope,
|
||||
propertyReadable: ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL,
|
||||
showFederationSettings: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
actionsLabel() {
|
||||
if (this.primary) {
|
||||
return t('settings', 'Email options')
|
||||
} else {
|
||||
return t('settings', 'Options for additional email address {index}', { index: this.index + 1 })
|
||||
}
|
||||
},
|
||||
|
||||
deleteDisabled() {
|
||||
if (this.primary) {
|
||||
// Disable for empty primary email as there is nothing to delete
|
||||
|
|
@ -183,15 +209,13 @@ export default {
|
|||
return t('settings', 'Delete email')
|
||||
},
|
||||
|
||||
setNotificationMailDisabled() {
|
||||
return !this.primary && this.localVerificationState !== VERIFICATION_ENUM.VERIFIED
|
||||
isConfirmedAddress() {
|
||||
return this.primary || this.localVerificationState === VERIFICATION_ENUM.VERIFIED
|
||||
},
|
||||
|
||||
setNotificationMailLabel() {
|
||||
setNotificationMailLabel() {
|
||||
if (this.isNotificationEmail) {
|
||||
return t('settings', 'Unset as primary email')
|
||||
} else if (!this.primary && this.localVerificationState !== VERIFICATION_ENUM.VERIFIED) {
|
||||
return t('settings', 'This address is not confirmed')
|
||||
}
|
||||
return t('settings', 'Set as primary email')
|
||||
},
|
||||
|
|
@ -213,25 +237,30 @@ export default {
|
|||
return (this.email && this.email === this.activeNotificationEmail)
|
||||
|| (this.primary && this.activeNotificationEmail === '')
|
||||
},
|
||||
|
||||
emailAddress: {
|
||||
get() {
|
||||
return this.email
|
||||
},
|
||||
set(value) {
|
||||
this.$emit('update:email', value)
|
||||
this.debounceEmailChange(value.trim())
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
if (!this.primary && this.initialEmail === '') {
|
||||
// $nextTick is needed here, otherwise it may not always work https://stackoverflow.com/questions/51922767/autofocus-input-on-mount-vue-ios/63485725#63485725
|
||||
// $nextTick is needed here, otherwise it may not always work
|
||||
// https://stackoverflow.com/questions/51922767/autofocus-input-on-mount-vue-ios/63485725#63485725
|
||||
this.$nextTick(() => this.$refs.email?.focus())
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
onEmailChange(e) {
|
||||
this.$emit('update:email', e.target.value)
|
||||
this.debounceEmailChange(e.target.value.trim())
|
||||
},
|
||||
|
||||
debounceEmailChange: debounce(async function(email) {
|
||||
this.helperText = null
|
||||
if (this.$refs.email?.validationMessage) {
|
||||
this.helperText = this.$refs.email.validationMessage
|
||||
this.helperText = this.$refs.email?.$refs.input?.validationMessage || null
|
||||
if (this.helperText !== null) {
|
||||
return
|
||||
}
|
||||
if (validateEmail(email) || email === '') {
|
||||
|
|
@ -356,12 +385,12 @@ export default {
|
|||
} else if (notificationEmail !== undefined) {
|
||||
this.$emit('update:notification-email', notificationEmail)
|
||||
}
|
||||
this.showCheckmarkIcon = true
|
||||
setTimeout(() => { this.showCheckmarkIcon = false }, 2000)
|
||||
this.isSuccess = true
|
||||
setTimeout(() => { this.isSuccess = false }, 2000)
|
||||
} else {
|
||||
handleError(error, errorMessage)
|
||||
this.showErrorIcon = true
|
||||
setTimeout(() => { this.showErrorIcon = false }, 2000)
|
||||
this.hasError = true
|
||||
setTimeout(() => { this.hasError = false }, 2000)
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -374,66 +403,16 @@ export default {
|
|||
|
||||
<style lang="scss" scoped>
|
||||
.email {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
|
||||
input {
|
||||
grid-area: 1 / 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.email__actions-container {
|
||||
grid-area: 1 / 1;
|
||||
justify-self: flex-end;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: start;
|
||||
gap: 4px;
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 0 2px;
|
||||
margin-right: 5px;
|
||||
|
||||
.email__actions {
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
opacity: 0.8 !important;
|
||||
}
|
||||
|
||||
&::v-deep button {
|
||||
height: 30px !important;
|
||||
min-height: 30px !important;
|
||||
width: 30px !important;
|
||||
min-width: 30px !important;
|
||||
}
|
||||
}
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
&__helper-text-message {
|
||||
padding: 4px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&__icon {
|
||||
margin-right: 8px;
|
||||
align-self: start;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
&--error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.fade-enter-active {
|
||||
transition: opacity 200ms ease-out;
|
||||
}
|
||||
|
||||
.fade-leave-active {
|
||||
transition: opacity 300ms ease-out;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -199,10 +199,6 @@ export default {
|
|||
section {
|
||||
padding: 10px 10px;
|
||||
|
||||
&::v-deep button:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.additional-emails-label {
|
||||
display: block;
|
||||
margin-top: 16px;
|
||||
|
|
|
|||
|
|
@ -22,23 +22,15 @@
|
|||
|
||||
<template>
|
||||
<div class="language">
|
||||
<select :id="inputId" @change="onLanguageChange">
|
||||
<option v-for="commonLanguage in commonLanguages"
|
||||
:key="commonLanguage.code"
|
||||
:selected="language.code === commonLanguage.code"
|
||||
:value="commonLanguage.code">
|
||||
{{ commonLanguage.name }}
|
||||
</option>
|
||||
<option disabled>
|
||||
──────────
|
||||
</option>
|
||||
<option v-for="otherLanguage in otherLanguages"
|
||||
:key="otherLanguage.code"
|
||||
:selected="language.code === otherLanguage.code"
|
||||
:value="otherLanguage.code">
|
||||
{{ otherLanguage.name }}
|
||||
</option>
|
||||
</select>
|
||||
<NcSelect :aria-label-listbox="t('settings', 'Languages')"
|
||||
class="language__select"
|
||||
:clearable="false"
|
||||
:input-id="inputId"
|
||||
label="name"
|
||||
label-outside
|
||||
:options="allLanguages"
|
||||
:value="language"
|
||||
@option:selected="onLanguageChange" />
|
||||
|
||||
<a href="https://www.transifex.com/nextcloud/nextcloud/"
|
||||
target="_blank"
|
||||
|
|
@ -54,9 +46,15 @@ import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/Person
|
|||
import { validateLanguage } from '../../../utils/validate.js'
|
||||
import { handleError } from '../../../utils/handlers.js'
|
||||
|
||||
import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
|
||||
|
||||
export default {
|
||||
name: 'Language',
|
||||
|
||||
components: {
|
||||
NcSelect,
|
||||
},
|
||||
|
||||
props: {
|
||||
inputId: {
|
||||
type: String,
|
||||
|
|
@ -83,17 +81,18 @@ export default {
|
|||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* All available languages, sorted like: current, common, other
|
||||
*/
|
||||
allLanguages() {
|
||||
return Object.freeze(
|
||||
[...this.commonLanguages, ...this.otherLanguages]
|
||||
.reduce((acc, { code, name }) => ({ ...acc, [code]: name }), {}),
|
||||
)
|
||||
const common = this.commonLanguages.filter(l => l.code !== this.language.code)
|
||||
const other = this.otherLanguages.filter(l => l.code !== this.language.code)
|
||||
return [this.language, ...common, ...other]
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
async onLanguageChange(e) {
|
||||
const language = this.constructLanguage(e.target.value)
|
||||
async onLanguageChange(language) {
|
||||
this.$emit('update:language', language)
|
||||
|
||||
if (validateLanguage(language)) {
|
||||
|
|
@ -108,7 +107,7 @@ export default {
|
|||
language,
|
||||
status: responseData.ocs?.meta?.status,
|
||||
})
|
||||
this.reloadPage()
|
||||
window.location.reload()
|
||||
} catch (e) {
|
||||
this.handleResponse({
|
||||
errorMessage: t('settings', 'Unable to update language'),
|
||||
|
|
@ -117,13 +116,6 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
constructLanguage(languageCode) {
|
||||
return {
|
||||
code: languageCode,
|
||||
name: this.allLanguages[languageCode],
|
||||
}
|
||||
},
|
||||
|
||||
handleResponse({ language, status, errorMessage, error }) {
|
||||
if (status === 'ok') {
|
||||
// Ensure that local state reflects server state
|
||||
|
|
@ -132,10 +124,6 @@ export default {
|
|||
handleError(error, errorMessage)
|
||||
}
|
||||
},
|
||||
|
||||
reloadPage() {
|
||||
location.reload()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
@ -144,12 +132,11 @@ export default {
|
|||
.language {
|
||||
display: grid;
|
||||
|
||||
select {
|
||||
width: 100%;
|
||||
#{&}__select {
|
||||
margin-top: 6px; // align with other inputs
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-main-text);
|
||||
text-decoration: none;
|
||||
width: max-content;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,12 +25,11 @@
|
|||
<HeaderBar :input-id="inputId"
|
||||
:readable="propertyReadable" />
|
||||
|
||||
<template v-if="isEditable">
|
||||
<Language :input-id="inputId"
|
||||
:common-languages="commonLanguages"
|
||||
:other-languages="otherLanguages"
|
||||
:language.sync="language" />
|
||||
</template>
|
||||
<Language v-if="isEditable"
|
||||
:input-id="inputId"
|
||||
:common-languages="commonLanguages"
|
||||
:other-languages="otherLanguages"
|
||||
:language.sync="language" />
|
||||
|
||||
<span v-else>
|
||||
{{ t('settings', 'No language set') }}
|
||||
|
|
@ -56,11 +55,17 @@ export default {
|
|||
HeaderBar,
|
||||
},
|
||||
|
||||
data() {
|
||||
setup() {
|
||||
// Non reactive instance properties
|
||||
return {
|
||||
propertyReadable: ACCOUNT_SETTING_PROPERTY_READABLE_ENUM.LANGUAGE,
|
||||
commonLanguages,
|
||||
otherLanguages,
|
||||
propertyReadable: ACCOUNT_SETTING_PROPERTY_READABLE_ENUM.LANGUAGE,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
language: activeLanguage,
|
||||
}
|
||||
},
|
||||
|
|
@ -80,9 +85,5 @@ export default {
|
|||
<style lang="scss" scoped>
|
||||
section {
|
||||
padding: 10px 10px;
|
||||
|
||||
&::v-deep button:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -22,26 +22,18 @@
|
|||
|
||||
<template>
|
||||
<div class="locale">
|
||||
<select :id="inputId" @change="onLocaleChange">
|
||||
<option v-for="currentLocale in localesForLanguage"
|
||||
:key="currentLocale.code"
|
||||
:selected="locale.code === currentLocale.code"
|
||||
:value="currentLocale.code">
|
||||
{{ currentLocale.name }}
|
||||
</option>
|
||||
<option disabled>
|
||||
──────────
|
||||
</option>
|
||||
<option v-for="currentLocale in otherLocales"
|
||||
:key="currentLocale.code"
|
||||
:selected="locale.code === currentLocale.code"
|
||||
:value="currentLocale.code">
|
||||
{{ currentLocale.name }}
|
||||
</option>
|
||||
</select>
|
||||
<NcSelect :aria-label-listbox="t('settings', 'Locales')"
|
||||
class="locale__select"
|
||||
:clearable="false"
|
||||
:input-id="inputId"
|
||||
label="name"
|
||||
label-outside
|
||||
:options="allLocales"
|
||||
:value="locale"
|
||||
@option:selected="updateLocale" />
|
||||
|
||||
<div class="example">
|
||||
<Web :size="20" />
|
||||
<MapClock :size="20" />
|
||||
<div class="example__text">
|
||||
<p>
|
||||
<span>{{ example.date }}</span>
|
||||
|
|
@ -57,18 +49,19 @@
|
|||
|
||||
<script>
|
||||
import moment from '@nextcloud/moment'
|
||||
import Web from 'vue-material-design-icons/Web.vue'
|
||||
import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
|
||||
import MapClock from 'vue-material-design-icons/MapClock.vue'
|
||||
|
||||
import { ACCOUNT_SETTING_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants.js'
|
||||
import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService.js'
|
||||
import { validateLocale } from '../../../utils/validate.js'
|
||||
import { handleError } from '../../../utils/handlers.js'
|
||||
|
||||
export default {
|
||||
name: 'Locale',
|
||||
|
||||
components: {
|
||||
Web,
|
||||
MapClock,
|
||||
NcSelect,
|
||||
},
|
||||
|
||||
props: {
|
||||
|
|
@ -93,6 +86,7 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
initialLocale: this.locale,
|
||||
intervalId: 0,
|
||||
example: {
|
||||
date: moment().format('L'),
|
||||
time: moment().format('LTS'),
|
||||
|
|
@ -102,28 +96,25 @@ export default {
|
|||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* All available locale, sorted like: current, common, other
|
||||
*/
|
||||
allLocales() {
|
||||
return Object.freeze(
|
||||
[...this.localesForLanguage, ...this.otherLocales]
|
||||
.reduce((acc, { code, name }) => ({ ...acc, [code]: name }), {}),
|
||||
)
|
||||
const common = this.localesForLanguage.filter(l => l.code !== this.locale.code)
|
||||
const other = this.otherLocales.filter(l => l.code !== this.locale.code)
|
||||
return [this.locale, ...common, ...other]
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
setInterval(this.refreshExample, 1000)
|
||||
mounted() {
|
||||
this.intervalId = window.setInterval(this.refreshExample, 1000)
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
window.clearInterval(this.intervalId)
|
||||
},
|
||||
|
||||
methods: {
|
||||
async onLocaleChange(e) {
|
||||
const locale = this.constructLocale(e.target.value)
|
||||
this.$emit('update:locale', locale)
|
||||
|
||||
if (validateLocale(locale)) {
|
||||
await this.updateLocale(locale)
|
||||
}
|
||||
},
|
||||
|
||||
async updateLocale(locale) {
|
||||
try {
|
||||
const responseData = await savePrimaryAccountProperty(ACCOUNT_SETTING_PROPERTY_ENUM.LOCALE, locale.code)
|
||||
|
|
@ -131,7 +122,7 @@ export default {
|
|||
locale,
|
||||
status: responseData.ocs?.meta?.status,
|
||||
})
|
||||
this.reloadPage()
|
||||
window.location.reload()
|
||||
} catch (e) {
|
||||
this.handleResponse({
|
||||
errorMessage: t('settings', 'Unable to update locale'),
|
||||
|
|
@ -140,13 +131,6 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
constructLocale(localeCode) {
|
||||
return {
|
||||
code: localeCode,
|
||||
name: this.allLocales[localeCode],
|
||||
}
|
||||
},
|
||||
|
||||
handleResponse({ locale, status, errorMessage, error }) {
|
||||
if (status === 'ok') {
|
||||
this.initialLocale = locale
|
||||
|
|
@ -163,10 +147,6 @@ export default {
|
|||
firstDayOfWeek: window.dayNames[window.firstDay],
|
||||
}
|
||||
},
|
||||
|
||||
reloadPage() {
|
||||
location.reload()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
@ -175,8 +155,8 @@ export default {
|
|||
.locale {
|
||||
display: grid;
|
||||
|
||||
select {
|
||||
width: 100%;
|
||||
#{&}__select {
|
||||
margin-top: 6px; // align with other inputs
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -184,9 +164,9 @@ export default {
|
|||
margin: 10px 0;
|
||||
display: flex;
|
||||
gap: 0 10px;
|
||||
color: var(--color-text-lighter);
|
||||
color: var(--color-text-maxcontrast);
|
||||
|
||||
&::v-deep .material-design-icon {
|
||||
&:deep(.material-design-icon) {
|
||||
align-self: flex-start;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,12 +25,11 @@
|
|||
<HeaderBar :input-id="inputId"
|
||||
:readable="propertyReadable" />
|
||||
|
||||
<template v-if="isEditable">
|
||||
<Locale :input-id="inputId"
|
||||
:locales-for-language="localesForLanguage"
|
||||
:other-locales="otherLocales"
|
||||
:locale.sync="locale" />
|
||||
</template>
|
||||
<Locale v-if="isEditable"
|
||||
:input-id="inputId"
|
||||
:locales-for-language="localesForLanguage"
|
||||
:other-locales="otherLocales"
|
||||
:locale.sync="locale" />
|
||||
|
||||
<span v-else>
|
||||
{{ t('settings', 'No locale set') }}
|
||||
|
|
@ -80,9 +79,5 @@ export default {
|
|||
<style lang="scss" scoped>
|
||||
section {
|
||||
padding: 10px 10px;
|
||||
|
||||
&::v-deep button:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
:checked.sync="isProfileEnabled"
|
||||
:loading="loading"
|
||||
@update:checked="saveEnableProfile">
|
||||
{{ t('settings', 'Enable Profile') }}
|
||||
{{ t('settings', 'Enable profile') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -22,61 +22,51 @@
|
|||
|
||||
<template>
|
||||
<section>
|
||||
<HeaderBar :scope.sync="scope"
|
||||
:readable.sync="readable"
|
||||
<HeaderBar :scope="scope"
|
||||
:readable="readable"
|
||||
:input-id="inputId"
|
||||
:is-editable="isEditable" />
|
||||
:is-editable="isEditable"
|
||||
@update:scope="(scope) => $emit('update:scope', scope)" />
|
||||
|
||||
<div v-if="isEditable" class="property">
|
||||
<textarea v-if="multiLine"
|
||||
<NcTextArea v-if="multiLine"
|
||||
:id="inputId"
|
||||
:placeholder="placeholder"
|
||||
:value="value"
|
||||
rows="8"
|
||||
autocapitalize="none"
|
||||
autocomplete="off"
|
||||
:error="hasError || !!helperText"
|
||||
:helper-text="helperText"
|
||||
label-outside
|
||||
:placeholder="placeholder"
|
||||
rows="8"
|
||||
spellcheck="false"
|
||||
@input="onPropertyChange" />
|
||||
<input v-else
|
||||
:success="isSuccess"
|
||||
:value.sync="inputValue" />
|
||||
<NcInputField v-else
|
||||
:id="inputId"
|
||||
ref="input"
|
||||
:placeholder="placeholder"
|
||||
:type="type"
|
||||
:value="value"
|
||||
:aria-describedby="helperText ? `${name}-helper-text` : undefined"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
:autocomplete="autocomplete"
|
||||
@input="onPropertyChange">
|
||||
|
||||
<div class="property__actions-container">
|
||||
<Transition name="fade">
|
||||
<Check v-if="showCheckmarkIcon" :size="20" />
|
||||
<AlertOctagon v-else-if="showErrorIcon" :size="20" />
|
||||
</Transition>
|
||||
</div>
|
||||
:error="hasError || !!helperText"
|
||||
:helper-text="helperText"
|
||||
label-outside
|
||||
:placeholder="placeholder"
|
||||
spellcheck="false"
|
||||
:success="isSuccess"
|
||||
:type="type"
|
||||
:value.sync="inputValue" />
|
||||
</div>
|
||||
<span v-else>
|
||||
{{ value || t('settings', 'No {property} set', { property: readable.toLocaleLowerCase() }) }}
|
||||
</span>
|
||||
|
||||
<p v-if="helperText"
|
||||
:id="`${name}-helper-text`"
|
||||
class="property__helper-text-message property__helper-text-message--error">
|
||||
<AlertCircle class="property__helper-text-message__icon" :size="18" />
|
||||
{{ helperText }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import debounce from 'debounce'
|
||||
import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js'
|
||||
import NcTextArea from '@nextcloud/vue/dist/Components/NcTextArea.js'
|
||||
|
||||
import AlertCircle from 'vue-material-design-icons/AlertCircleOutline.vue'
|
||||
import AlertOctagon from 'vue-material-design-icons/AlertOctagon.vue'
|
||||
import Check from 'vue-material-design-icons/Check.vue'
|
||||
|
||||
import HeaderBar from '../shared/HeaderBar.vue'
|
||||
import HeaderBar from './HeaderBar.vue'
|
||||
|
||||
import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService.js'
|
||||
import { handleError } from '../../../utils/handlers.js'
|
||||
|
|
@ -85,10 +75,9 @@ export default {
|
|||
name: 'AccountPropertySection',
|
||||
|
||||
components: {
|
||||
AlertCircle,
|
||||
AlertOctagon,
|
||||
Check,
|
||||
HeaderBar,
|
||||
NcInputField,
|
||||
NcTextArea,
|
||||
},
|
||||
|
||||
props: {
|
||||
|
|
@ -138,12 +127,14 @@ export default {
|
|||
},
|
||||
},
|
||||
|
||||
emits: ['update:scope', 'update:value'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
initialValue: this.value,
|
||||
helperText: null,
|
||||
showCheckmarkIcon: false,
|
||||
showErrorIcon: false,
|
||||
helperText: '',
|
||||
isSuccess: false,
|
||||
hasError: false,
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -151,26 +142,34 @@ export default {
|
|||
inputId() {
|
||||
return `account-property-${this.name}`
|
||||
},
|
||||
|
||||
inputValue: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(value) {
|
||||
this.$emit('update:value', value)
|
||||
this.debouncePropertyChange(value.trim())
|
||||
},
|
||||
},
|
||||
|
||||
debouncePropertyChange() {
|
||||
return debounce(async function(value) {
|
||||
this.helperText = this.$refs.input?.$refs.input?.validationMessage || ''
|
||||
if (this.helperText !== '') {
|
||||
return
|
||||
}
|
||||
this.hasError = this.onValidate && !this.onValidate(value)
|
||||
if (this.hasError) {
|
||||
this.helperText = t('settings', 'Invalid value')
|
||||
return
|
||||
}
|
||||
await this.updateProperty(value)
|
||||
}, 500)
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onPropertyChange(e) {
|
||||
this.$emit('update:value', e.target.value)
|
||||
this.debouncePropertyChange(e.target.value.trim())
|
||||
},
|
||||
|
||||
debouncePropertyChange: debounce(async function(value) {
|
||||
this.helperText = null
|
||||
if (this.$refs.input && this.$refs.input.validationMessage) {
|
||||
this.helperText = this.$refs.input.validationMessage
|
||||
return
|
||||
}
|
||||
if (this.onValidate && !this.onValidate(value)) {
|
||||
return
|
||||
}
|
||||
await this.updateProperty(value)
|
||||
}, 500),
|
||||
|
||||
async updateProperty(value) {
|
||||
try {
|
||||
const responseData = await savePrimaryAccountProperty(
|
||||
|
|
@ -195,13 +194,13 @@ export default {
|
|||
if (this.onSave) {
|
||||
this.onSave(value)
|
||||
}
|
||||
this.showCheckmarkIcon = true
|
||||
setTimeout(() => { this.showCheckmarkIcon = false }, 2000)
|
||||
this.isSuccess = true
|
||||
setTimeout(() => { this.isSuccess = false }, 2000)
|
||||
} else {
|
||||
this.$emit('update:value', this.initialValue)
|
||||
handleError(error, errorMessage)
|
||||
this.showErrorIcon = true
|
||||
setTimeout(() => { this.showErrorIcon = false }, 2000)
|
||||
this.hasError = true
|
||||
setTimeout(() => { this.hasError = false }, 2000)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
@ -212,30 +211,16 @@ export default {
|
|||
section {
|
||||
padding: 10px 10px;
|
||||
|
||||
&::v-deep button:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.property {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
grid-area: 1 / 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input {
|
||||
grid-area: 1 / 1;
|
||||
width: 100%;
|
||||
}
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: start;
|
||||
gap: 4px;
|
||||
|
||||
.property__actions-container {
|
||||
grid-area: 1 / 1;
|
||||
margin-top: 6px;
|
||||
justify-self: flex-end;
|
||||
align-self: flex-end;
|
||||
height: 30px;
|
||||
|
||||
display: flex;
|
||||
gap: 0 2px;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
- @copyright 2021, Christopher Ng <chrng8@gmail.com>
|
||||
-
|
||||
- @author Christopher Ng <chrng8@gmail.com>
|
||||
- @author Ferdinand Thiessen <opensource@fthiessen.de>
|
||||
-
|
||||
- @license GNU AGPL version 3 or any later version
|
||||
-
|
||||
|
|
@ -25,51 +26,37 @@
|
|||
class="federation-actions"
|
||||
:class="{ 'federation-actions--additional': additional }"
|
||||
:aria-label="ariaLabel"
|
||||
:default-icon="scopeIcon"
|
||||
:disabled="disabled">
|
||||
<NcActionButton v-for="federationScope in federationScopes"
|
||||
:key="federationScope.name"
|
||||
:close-after-click="true"
|
||||
:disabled="!supportedScopes.includes(federationScope.name)"
|
||||
:icon="federationScope.iconClass"
|
||||
:name="federationScope.displayName"
|
||||
type="radio"
|
||||
:value="federationScope.name"
|
||||
:model-value="scope"
|
||||
@update:modelValue="changeScope">
|
||||
{{ supportedScopes.includes(federationScope.name) ? federationScope.tooltip : federationScope.tooltipDisabled }}
|
||||
</NcActionButton>
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="scopeIcon" />
|
||||
</template>
|
||||
<FederationControlActions :additional="additional"
|
||||
:additional-value="additionalValue"
|
||||
:handle-additional-scope-change="handleAdditionalScopeChange"
|
||||
:readable="readable"
|
||||
:scope="scope"
|
||||
@update:scope="onUpdateScope" />
|
||||
</NcActions>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
|
||||
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
|
||||
import {
|
||||
ACCOUNT_PROPERTY_READABLE_ENUM,
|
||||
ACCOUNT_SETTING_PROPERTY_READABLE_ENUM,
|
||||
PROFILE_READABLE_ENUM,
|
||||
PROPERTY_READABLE_KEYS_ENUM,
|
||||
PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM,
|
||||
SCOPE_ENUM, SCOPE_PROPERTY_ENUM,
|
||||
UNPUBLISHED_READABLE_PROPERTIES,
|
||||
SCOPE_PROPERTY_ENUM,
|
||||
} from '../../../constants/AccountPropertyConstants.js'
|
||||
import { savePrimaryAccountPropertyScope } from '../../../service/PersonalInfo/PersonalInfoService.js'
|
||||
import { handleError } from '../../../utils/handlers.js'
|
||||
|
||||
const {
|
||||
federationEnabled,
|
||||
lookupServerUploadEnabled,
|
||||
} = loadState('settings', 'accountParameters', {})
|
||||
import FederationControlActions from './FederationControlActions.vue'
|
||||
|
||||
export default {
|
||||
name: 'FederationControl',
|
||||
|
||||
components: {
|
||||
NcActions,
|
||||
NcActionButton,
|
||||
NcIconSvgWrapper,
|
||||
FederationControlActions,
|
||||
},
|
||||
|
||||
props: {
|
||||
|
|
@ -103,7 +90,6 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
readableLowerCase: this.readable.toLocaleLowerCase(),
|
||||
initialScope: this.scope,
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -117,84 +103,16 @@ export default {
|
|||
},
|
||||
|
||||
scopeIcon() {
|
||||
return SCOPE_PROPERTY_ENUM[this.scope].iconClass
|
||||
},
|
||||
|
||||
federationScopes() {
|
||||
return Object.values(SCOPE_PROPERTY_ENUM)
|
||||
},
|
||||
|
||||
supportedScopes() {
|
||||
const scopes = PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM[this.readable]
|
||||
|
||||
if (UNPUBLISHED_READABLE_PROPERTIES.includes(this.readable)) {
|
||||
return scopes
|
||||
}
|
||||
|
||||
if (federationEnabled) {
|
||||
scopes.push(SCOPE_ENUM.FEDERATED)
|
||||
}
|
||||
|
||||
if (lookupServerUploadEnabled) {
|
||||
scopes.push(SCOPE_ENUM.PUBLISHED)
|
||||
}
|
||||
|
||||
return scopes
|
||||
return SCOPE_PROPERTY_ENUM[this.scope].icon
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
async changeScope(scope) {
|
||||
onUpdateScope(scope) {
|
||||
this.$emit('update:scope', scope)
|
||||
|
||||
if (!this.additional) {
|
||||
await this.updatePrimaryScope(scope)
|
||||
} else {
|
||||
await this.updateAdditionalScope(scope)
|
||||
}
|
||||
|
||||
// TODO: provide focus method from NcActions
|
||||
this.$refs.federationActions.$refs.menuButton.$el.focus()
|
||||
},
|
||||
|
||||
async updatePrimaryScope(scope) {
|
||||
try {
|
||||
const responseData = await savePrimaryAccountPropertyScope(PROPERTY_READABLE_KEYS_ENUM[this.readable], scope)
|
||||
this.handleResponse({
|
||||
scope,
|
||||
status: responseData.ocs?.meta?.status,
|
||||
})
|
||||
} catch (e) {
|
||||
this.handleResponse({
|
||||
errorMessage: t('settings', 'Unable to update federation scope of the primary {property}', { property: this.readableLowerCase }),
|
||||
error: e,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
async updateAdditionalScope(scope) {
|
||||
try {
|
||||
const responseData = await this.handleAdditionalScopeChange(this.additionalValue, scope)
|
||||
this.handleResponse({
|
||||
scope,
|
||||
status: responseData.ocs?.meta?.status,
|
||||
})
|
||||
} catch (e) {
|
||||
this.handleResponse({
|
||||
errorMessage: t('settings', 'Unable to update federation scope of additional {property}', { property: this.readableLowerCase }),
|
||||
error: e,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
handleResponse({ scope, status, errorMessage, error }) {
|
||||
if (status === 'ok') {
|
||||
this.initialScope = scope
|
||||
} else {
|
||||
this.$emit('update:scope', this.initialScope)
|
||||
handleError(error, errorMessage)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,181 @@
|
|||
<!--
|
||||
- @copyright 2021, Christopher Ng <chrng8@gmail.com>
|
||||
-
|
||||
- @author Christopher Ng <chrng8@gmail.com>
|
||||
- @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>
|
||||
<Fragment>
|
||||
<NcActionButton v-for="federationScope in federationScopes"
|
||||
:key="federationScope.name"
|
||||
:close-after-click="true"
|
||||
:disabled="!supportedScopes.includes(federationScope.name)"
|
||||
:name="federationScope.displayName"
|
||||
type="radio"
|
||||
:value="federationScope.name"
|
||||
:model-value="scope"
|
||||
@update:modelValue="changeScope">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="federationScope.icon" />
|
||||
</template>
|
||||
{{ supportedScopes.includes(federationScope.name) ? federationScope.tooltip : federationScope.tooltipDisabled }}
|
||||
</NcActionButton>
|
||||
</Fragment>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { Fragment } from 'vue-frag'
|
||||
|
||||
import {
|
||||
ACCOUNT_PROPERTY_READABLE_ENUM,
|
||||
ACCOUNT_SETTING_PROPERTY_READABLE_ENUM,
|
||||
PROFILE_READABLE_ENUM,
|
||||
PROPERTY_READABLE_KEYS_ENUM,
|
||||
PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM,
|
||||
SCOPE_ENUM, SCOPE_PROPERTY_ENUM,
|
||||
UNPUBLISHED_READABLE_PROPERTIES,
|
||||
} from '../../../constants/AccountPropertyConstants.js'
|
||||
import { savePrimaryAccountPropertyScope } from '../../../service/PersonalInfo/PersonalInfoService.js'
|
||||
import { handleError } from '../../../utils/handlers.js'
|
||||
|
||||
const {
|
||||
federationEnabled,
|
||||
lookupServerUploadEnabled,
|
||||
} = loadState('settings', 'accountParameters', {})
|
||||
|
||||
export default {
|
||||
name: 'FederationControlActions',
|
||||
|
||||
components: {
|
||||
Fragment,
|
||||
NcActionButton,
|
||||
NcIconSvgWrapper,
|
||||
},
|
||||
|
||||
props: {
|
||||
readable: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: (value) => Object.values(ACCOUNT_PROPERTY_READABLE_ENUM).includes(value) || Object.values(ACCOUNT_SETTING_PROPERTY_READABLE_ENUM).includes(value) || value === PROFILE_READABLE_ENUM.PROFILE_VISIBILITY,
|
||||
},
|
||||
additional: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
additionalValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
handleAdditionalScopeChange: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
scope: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
readableLowerCase: this.readable.toLocaleLowerCase(),
|
||||
initialScope: this.scope,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
federationScopes() {
|
||||
return Object.values(SCOPE_PROPERTY_ENUM)
|
||||
},
|
||||
|
||||
supportedScopes() {
|
||||
const scopes = PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM[this.readable]
|
||||
|
||||
if (UNPUBLISHED_READABLE_PROPERTIES.includes(this.readable)) {
|
||||
return scopes
|
||||
}
|
||||
|
||||
if (federationEnabled) {
|
||||
scopes.push(SCOPE_ENUM.FEDERATED)
|
||||
}
|
||||
|
||||
if (lookupServerUploadEnabled) {
|
||||
scopes.push(SCOPE_ENUM.PUBLISHED)
|
||||
}
|
||||
|
||||
return scopes
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
async changeScope(scope) {
|
||||
this.$emit('update:scope', scope)
|
||||
|
||||
if (!this.additional) {
|
||||
await this.updatePrimaryScope(scope)
|
||||
} else {
|
||||
await this.updateAdditionalScope(scope)
|
||||
}
|
||||
},
|
||||
|
||||
async updatePrimaryScope(scope) {
|
||||
try {
|
||||
const responseData = await savePrimaryAccountPropertyScope(PROPERTY_READABLE_KEYS_ENUM[this.readable], scope)
|
||||
this.handleResponse({
|
||||
scope,
|
||||
status: responseData.ocs?.meta?.status,
|
||||
})
|
||||
} catch (e) {
|
||||
this.handleResponse({
|
||||
errorMessage: t('settings', 'Unable to update federation scope of the primary {property}', { property: this.readableLowerCase }),
|
||||
error: e,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
async updateAdditionalScope(scope) {
|
||||
try {
|
||||
const responseData = await this.handleAdditionalScopeChange(this.additionalValue, scope)
|
||||
this.handleResponse({
|
||||
scope,
|
||||
status: responseData.ocs?.meta?.status,
|
||||
})
|
||||
} catch (e) {
|
||||
this.handleResponse({
|
||||
errorMessage: t('settings', 'Unable to update federation scope of additional {property}', { property: this.readableLowerCase }),
|
||||
error: e,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
handleResponse({ scope, status, errorMessage, error }) {
|
||||
if (status === 'ok') {
|
||||
this.initialScope = scope
|
||||
} else {
|
||||
this.$emit('update:scope', this.initialScope)
|
||||
handleError(error, errorMessage)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
@ -24,6 +24,7 @@
|
|||
* SYNC to be kept in sync with `lib/public/Accounts/IAccountManager.php`
|
||||
*/
|
||||
|
||||
import { mdiAccountGroup, mdiCellphone, mdiLock, mdiWeb } from '@mdi/js'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
|
||||
/** Enum of account properties */
|
||||
|
|
@ -167,28 +168,28 @@ export const SCOPE_PROPERTY_ENUM = Object.freeze({
|
|||
displayName: t('settings', 'Private'),
|
||||
tooltip: t('settings', 'Only visible to people matched via phone number integration through Talk on mobile'),
|
||||
tooltipDisabled: t('settings', 'Not available as this property is required for core functionality including file sharing and calendar invitations'),
|
||||
iconClass: 'icon-phone',
|
||||
icon: mdiCellphone,
|
||||
},
|
||||
[SCOPE_ENUM.LOCAL]: {
|
||||
name: SCOPE_ENUM.LOCAL,
|
||||
displayName: t('settings', 'Local'),
|
||||
tooltip: t('settings', 'Only visible to people on this instance and guests'),
|
||||
// tooltipDisabled is not required here as this scope is supported by all account properties
|
||||
iconClass: 'icon-password',
|
||||
icon: mdiLock,
|
||||
},
|
||||
[SCOPE_ENUM.FEDERATED]: {
|
||||
name: SCOPE_ENUM.FEDERATED,
|
||||
displayName: t('settings', 'Federated'),
|
||||
tooltip: t('settings', 'Only synchronize to trusted servers'),
|
||||
tooltipDisabled: t('settings', 'Not available as federation has been disabled for your account, contact your system administration if you have any questions'),
|
||||
iconClass: 'icon-contacts-dark',
|
||||
icon: mdiAccountGroup,
|
||||
},
|
||||
[SCOPE_ENUM.PUBLISHED]: {
|
||||
name: SCOPE_ENUM.PUBLISHED,
|
||||
displayName: t('settings', 'Published'),
|
||||
tooltip: t('settings', 'Synchronize to trusted servers and the global and public address book'),
|
||||
tooltipDisabled: t('settings', 'Not available as publishing account specific data to the lookup server is not allowed, contact your system administration if you have any questions'),
|
||||
iconClass: 'icon-link',
|
||||
icon: mdiWeb,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
391
cypress/e2e/settings/personal-info.cy.ts
Normal file
391
cypress/e2e/settings/personal-info.cy.ts
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
/**
|
||||
* @copyright Copyright (c) 2024 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 type { User } from '@nextcloud/cypress'
|
||||
import { handlePasswordConfirmation } from './usersUtils.ts'
|
||||
|
||||
let user: User
|
||||
|
||||
enum Visibility {
|
||||
Private = 'Private',
|
||||
Local = 'Local',
|
||||
Federated = 'Federated',
|
||||
Public = 'Published'
|
||||
}
|
||||
|
||||
const ALL_VISIBILITIES = [Visibility.Public, Visibility.Private, Visibility.Local, Visibility.Federated]
|
||||
|
||||
/**
|
||||
* Get the input connected to a specific label
|
||||
* @param label The content of the label
|
||||
*/
|
||||
const inputForLabel = (label: string) => cy.contains('label', label).then((el) => cy.get(`#${el.attr('for')}`))
|
||||
|
||||
/**
|
||||
* Get the property visibility button
|
||||
* @param property The property to which to look for the button
|
||||
*/
|
||||
const getVisibilityButton = (property: string) => cy.get(`button[aria-label*="Change scope level of ${property.toLowerCase()}"`)
|
||||
|
||||
/**
|
||||
* Validate a specifiy visibility is set for a property
|
||||
* @param property The property
|
||||
* @param active The active visibility
|
||||
*/
|
||||
const validateActiveVisibility = (property: string, active: Visibility) => {
|
||||
getVisibilityButton(property)
|
||||
.should('have.attr', 'aria-label')
|
||||
.and('match', new RegExp(`current scope is ${active}`, 'i'))
|
||||
getVisibilityButton(property)
|
||||
.click()
|
||||
cy.get('ul[role="dialog"')
|
||||
.contains('button', active)
|
||||
.should('have.attr', 'aria-pressed', 'true')
|
||||
|
||||
// close menu
|
||||
getVisibilityButton(property)
|
||||
.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a specific visibility for a property
|
||||
* @param property The property
|
||||
* @param active The visibility to set
|
||||
*/
|
||||
const setActiveVisibility = (property: string, active: Visibility) => {
|
||||
getVisibilityButton(property)
|
||||
.click()
|
||||
cy.get('ul[role="dialog"')
|
||||
.contains('button', active)
|
||||
.click({ force: true })
|
||||
handlePasswordConfirmation(user.password)
|
||||
|
||||
cy.wait('@submitSetting')
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check that setting all visibilities on a property is possible
|
||||
* @param property The property to test
|
||||
* @param defaultVisibility The default visibility of that property
|
||||
* @param allowedVisibility Visibility that is allowed and need to be checked
|
||||
*/
|
||||
const checkSettingsVisibility = (property: string, defaultVisibility: Visibility = Visibility.Local, allowedVisibility: Visibility[] = ALL_VISIBILITIES) => {
|
||||
getVisibilityButton(property)
|
||||
.scrollIntoView()
|
||||
|
||||
validateActiveVisibility(property, defaultVisibility)
|
||||
|
||||
allowedVisibility.forEach((active) => {
|
||||
setActiveVisibility(property, active)
|
||||
|
||||
cy.reload()
|
||||
getVisibilityButton(property).scrollIntoView()
|
||||
|
||||
validateActiveVisibility(property, active)
|
||||
})
|
||||
|
||||
// TODO: Fix this in vue library then enable this test again
|
||||
/* // Test that not allowed options are disabled
|
||||
ALL_VISIBILITIES.filter((v) => !allowedVisibility.includes(v)).forEach((disabled) => {
|
||||
getVisibilityButton(property)
|
||||
.click()
|
||||
cy.get('ul[role="dialog"')
|
||||
.contains('button', disabled)
|
||||
.should('exist')
|
||||
.and('have.attr', 'disabled', 'true')
|
||||
}) */
|
||||
}
|
||||
|
||||
const genericProperties = ['Location', 'X (formerly Twitter)', 'Fediverse']
|
||||
const nonfederatedProperties = ['Organisation', 'Role', 'Headline', 'About']
|
||||
|
||||
describe('Settings: Change personal information', { testIsolation: true }, () => {
|
||||
|
||||
before(() => {
|
||||
// ensure we can set locale and language
|
||||
cy.runOccCommand('config:system:delete force_language')
|
||||
cy.runOccCommand('config:system:delete force_locale')
|
||||
})
|
||||
|
||||
after(() => {
|
||||
cy.runOccCommand('config:system:set force_language --value en')
|
||||
cy.runOccCommand('config:system:set force_locale --value en_US')
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
cy.createRandomUser().then(($user) => {
|
||||
user = $user
|
||||
cy.modifyUser(user, 'language', 'en')
|
||||
cy.modifyUser(user, 'locale', 'en_US')
|
||||
cy.login($user)
|
||||
cy.visit('/settings/user')
|
||||
})
|
||||
cy.intercept('PUT', /ocs\/v2.php\/cloud\/users\//).as('submitSetting')
|
||||
})
|
||||
|
||||
it('Can dis- and enable the profile', () => {
|
||||
cy.visit(`/u/${user.userId}`)
|
||||
cy.contains('h2', user.userId).should('be.visible')
|
||||
|
||||
cy.visit('/settings/user')
|
||||
cy.contains('Enable profile').click()
|
||||
handlePasswordConfirmation(user.password)
|
||||
|
||||
cy.visit(`/u/${user.userId}`, { failOnStatusCode: false })
|
||||
cy.contains('h2', 'Profile not found').should('be.visible')
|
||||
|
||||
cy.visit('/settings/user')
|
||||
cy.contains('Enable profile').click()
|
||||
handlePasswordConfirmation(user.password)
|
||||
|
||||
cy.visit(`/u/${user.userId}`, { failOnStatusCode: false })
|
||||
cy.contains('h2', user.userId).should('be.visible')
|
||||
})
|
||||
|
||||
it('Can change language', () => {
|
||||
cy.intercept('GET', /settings\/user/).as('reload')
|
||||
inputForLabel('Language').scrollIntoView()
|
||||
inputForLabel('Language').type('Ned')
|
||||
cy.contains('li[role="option"]', 'Nederlands')
|
||||
.click()
|
||||
cy.wait('@reload')
|
||||
|
||||
// expect language changed
|
||||
inputForLabel('Taal').scrollIntoView()
|
||||
cy.contains('section', 'Help met vertalen')
|
||||
})
|
||||
|
||||
it('Can change locale', () => {
|
||||
cy.intercept('GET', /settings\/user/).as('reload')
|
||||
cy.clock(new Date(2024, 0, 10))
|
||||
|
||||
// Default is US
|
||||
cy.contains('section', '01/10/2024')
|
||||
|
||||
inputForLabel('Locale').scrollIntoView()
|
||||
inputForLabel('Locale').type('German')
|
||||
cy.contains('li[role="option"]', 'German (Germany')
|
||||
.click()
|
||||
cy.wait('@reload')
|
||||
|
||||
// expect locale changed
|
||||
inputForLabel('Locale').scrollIntoView()
|
||||
cy.contains('section', '10.01.2024')
|
||||
})
|
||||
|
||||
it('Can set primary email and change its visibility', () => {
|
||||
cy.contains('label', 'Email').scrollIntoView()
|
||||
// Check invalid input
|
||||
inputForLabel('Email').type('foo bar')
|
||||
inputForLabel('Email').then(($el) => expect(($el.get(0) as HTMLInputElement).checkValidity()).to.be.false)
|
||||
// handle valid input
|
||||
inputForLabel('Email').type('{selectAll}hello@example.com')
|
||||
handlePasswordConfirmation(user.password)
|
||||
|
||||
cy.wait('@submitSetting')
|
||||
cy.reload()
|
||||
inputForLabel('Email').should('have.value', 'hello@example.com')
|
||||
|
||||
checkSettingsVisibility(
|
||||
'Email',
|
||||
Visibility.Federated,
|
||||
// It is not possible to set it as private
|
||||
ALL_VISIBILITIES.filter((v) => v !== Visibility.Private),
|
||||
)
|
||||
|
||||
// check it is visible on the profile
|
||||
cy.visit(`/u/${user.userId}`)
|
||||
cy.contains('a', 'hello@example.com').should('be.visible').and('have.attr', 'href', 'mailto:hello@example.com')
|
||||
})
|
||||
|
||||
it('Can delete primary email', () => {
|
||||
cy.contains('label', 'Email').scrollIntoView()
|
||||
inputForLabel('Email').type('{selectAll}hello@example.com')
|
||||
handlePasswordConfirmation(user.password)
|
||||
cy.wait('@submitSetting')
|
||||
|
||||
// check after reload
|
||||
cy.reload()
|
||||
inputForLabel('Email').should('have.value', 'hello@example.com')
|
||||
|
||||
// delete email
|
||||
cy.get('button[aria-label="Remove primary email"]').click({ force: true })
|
||||
cy.wait('@submitSetting')
|
||||
|
||||
// check after reload
|
||||
cy.reload()
|
||||
inputForLabel('Email').should('have.value', '')
|
||||
})
|
||||
|
||||
it('Can set and delete additional emails', () => {
|
||||
cy.get('button[aria-label="Add additional email"]').should('be.disabled')
|
||||
// we need a primary email first
|
||||
cy.contains('label', 'Email').scrollIntoView()
|
||||
inputForLabel('Email').type('{selectAll}primary@example.com')
|
||||
handlePasswordConfirmation(user.password)
|
||||
cy.wait('@submitSetting')
|
||||
|
||||
// add new email
|
||||
cy.get('button[aria-label="Add additional email"]')
|
||||
.click()
|
||||
|
||||
// without any value we should not be able to add a second additional
|
||||
cy.get('button[aria-label="Add additional email"]').should('be.disabled')
|
||||
|
||||
// fill the first additional
|
||||
inputForLabel('Additional email address 1')
|
||||
.type('1@example.com')
|
||||
handlePasswordConfirmation(user.password)
|
||||
cy.wait('@submitSetting')
|
||||
|
||||
// add second additional email
|
||||
cy.get('button[aria-label="Add additional email"]')
|
||||
.click()
|
||||
|
||||
// fill the second additional
|
||||
inputForLabel('Additional email address 2')
|
||||
.type('2@example.com')
|
||||
handlePasswordConfirmation(user.password)
|
||||
cy.wait('@submitSetting')
|
||||
|
||||
// check the content is saved
|
||||
cy.reload()
|
||||
inputForLabel('Additional email address 1')
|
||||
.should('have.value', '1@example.com')
|
||||
inputForLabel('Additional email address 2')
|
||||
.should('have.value', '2@example.com')
|
||||
|
||||
// delete the first
|
||||
cy.get('button[aria-label="Options for additional email address 1"]')
|
||||
.click({ force: true })
|
||||
cy.contains('button[role="menuitem"]', 'Delete email')
|
||||
.click({ force: true })
|
||||
handlePasswordConfirmation(user.password)
|
||||
|
||||
cy.reload()
|
||||
inputForLabel('Additional email address 1')
|
||||
.should('have.value', '2@example.com')
|
||||
})
|
||||
|
||||
it('Can set Full name and change its visibility', () => {
|
||||
cy.contains('label', 'Full name').scrollIntoView()
|
||||
// handle valid input
|
||||
inputForLabel('Full name').type('{selectAll}Jane Doe')
|
||||
handlePasswordConfirmation(user.password)
|
||||
|
||||
cy.wait('@submitSetting')
|
||||
cy.reload()
|
||||
inputForLabel('Full name').should('have.value', 'Jane Doe')
|
||||
|
||||
checkSettingsVisibility(
|
||||
'Full name',
|
||||
Visibility.Federated,
|
||||
// It is not possible to set it as private
|
||||
ALL_VISIBILITIES.filter((v) => v !== Visibility.Private),
|
||||
)
|
||||
|
||||
// check it is visible on the profile
|
||||
cy.visit(`/u/${user.userId}`)
|
||||
cy.contains('h2', 'Jane Doe').should('be.visible')
|
||||
})
|
||||
|
||||
it('Can set Phone number and its visibility', () => {
|
||||
cy.contains('label', 'Phone number').scrollIntoView()
|
||||
// Check invalid input
|
||||
inputForLabel('Phone number').type('foo bar')
|
||||
inputForLabel('Phone number').should('have.attr', 'class').and('contain', '--error')
|
||||
// handle valid input
|
||||
inputForLabel('Phone number').type('{selectAll}+49 89 721010 99701')
|
||||
inputForLabel('Phone number').should('have.attr', 'class').and('not.contain', '--error')
|
||||
handlePasswordConfirmation(user.password)
|
||||
|
||||
cy.wait('@submitSetting')
|
||||
cy.reload()
|
||||
inputForLabel('Phone number').should('have.value', '+498972101099701')
|
||||
|
||||
checkSettingsVisibility('Phone number')
|
||||
|
||||
// check it is visible on the profile
|
||||
cy.visit(`/u/${user.userId}`)
|
||||
cy.get('a[href="tel:+498972101099701"]').should('be.visible')
|
||||
})
|
||||
|
||||
it('Can set Website and change its visibility', () => {
|
||||
cy.contains('label', 'Website').scrollIntoView()
|
||||
// Check invalid input
|
||||
inputForLabel('Website').type('foo bar')
|
||||
inputForLabel('Website').then(($el) => expect(($el.get(0) as HTMLInputElement).checkValidity()).to.be.false)
|
||||
// handle valid input
|
||||
inputForLabel('Website').type('{selectAll}http://example.com')
|
||||
handlePasswordConfirmation(user.password)
|
||||
|
||||
cy.wait('@submitSetting')
|
||||
cy.reload()
|
||||
inputForLabel('Website').should('have.value', 'http://example.com')
|
||||
|
||||
checkSettingsVisibility('Website')
|
||||
|
||||
// check it is visible on the profile
|
||||
cy.visit(`/u/${user.userId}`)
|
||||
cy.contains('http://example.com').should('be.visible')
|
||||
})
|
||||
|
||||
// Check generic properties that allow any visibility and any value
|
||||
genericProperties.forEach((property) => {
|
||||
it(`Can set ${property} and change its visibility`, () => {
|
||||
const uniqueValue = `${property.toUpperCase()} ${property.toLowerCase()}`
|
||||
cy.contains('label', property).scrollIntoView()
|
||||
inputForLabel(property).type(uniqueValue)
|
||||
handlePasswordConfirmation(user.password)
|
||||
|
||||
cy.wait('@submitSetting')
|
||||
cy.reload()
|
||||
inputForLabel(property).should('have.value', uniqueValue)
|
||||
|
||||
checkSettingsVisibility(property)
|
||||
|
||||
// check it is visible on the profile
|
||||
cy.visit(`/u/${user.userId}`)
|
||||
cy.contains(uniqueValue).should('be.visible')
|
||||
})
|
||||
})
|
||||
|
||||
// Check non federated properties - those where we need special configuration and only support local visibility
|
||||
nonfederatedProperties.forEach((property) => {
|
||||
it(`Can set ${property} and change its visibility`, () => {
|
||||
const uniqueValue = `${property.toUpperCase()} ${property.toLowerCase()}`
|
||||
cy.contains('label', property).scrollIntoView()
|
||||
inputForLabel(property).type(uniqueValue)
|
||||
handlePasswordConfirmation(user.password)
|
||||
|
||||
cy.wait('@submitSetting')
|
||||
cy.reload()
|
||||
inputForLabel(property).should('have.value', uniqueValue)
|
||||
|
||||
checkSettingsVisibility(property, Visibility.Local, [Visibility.Private, Visibility.Local])
|
||||
|
||||
// check it is visible on the profile
|
||||
cy.visit(`/u/${user.userId}`)
|
||||
cy.contains(uniqueValue).should('be.visible')
|
||||
})
|
||||
})
|
||||
})
|
||||
4
dist/comments-comments-app.js
vendored
4
dist/comments-comments-app.js
vendored
File diff suppressed because one or more lines are too long
2
dist/comments-comments-app.js.map
vendored
2
dist/comments-comments-app.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/core-common.js
vendored
4
dist/core-common.js
vendored
File diff suppressed because one or more lines are too long
2
dist/core-common.js.map
vendored
2
dist/core-common.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/core-unified-search.js
vendored
4
dist/core-unified-search.js
vendored
File diff suppressed because one or more lines are too long
2
dist/core-unified-search.js.map
vendored
2
dist/core-unified-search.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/core-unsupported-browser.js
vendored
4
dist/core-unsupported-browser.js
vendored
File diff suppressed because one or more lines are too long
2
dist/core-unsupported-browser.js.map
vendored
2
dist/core-unsupported-browser.js.map
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -21,7 +21,7 @@
|
|||
*/
|
||||
|
||||
/**
|
||||
* @copyright 2021 Christopher Ng <chrng8@gmail.com>
|
||||
* @copyright 2021, Christopher Ng <chrng8@gmail.com>
|
||||
*
|
||||
* @author Christopher Ng <chrng8@gmail.com>
|
||||
*
|
||||
|
|
@ -34,7 +34,7 @@
|
|||
*
|
||||
* 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
|
||||
* 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
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
4
dist/settings-vue-settings-personal-info.js
vendored
4
dist/settings-vue-settings-personal-info.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -8,28 +8,6 @@
|
|||
* Date: 2023-09-17T03:44:19.860Z
|
||||
*/
|
||||
|
||||
/**
|
||||
* @copyright 2021 Christopher Ng <chrng8@gmail.com>
|
||||
*
|
||||
* @author Christopher Ng <chrng8@gmail.com>
|
||||
*
|
||||
* @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 2021, Christopher Ng <chrng8@gmail.com>
|
||||
*
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue