Merge pull request #43488 from nextcloud/fix/use-nc-components-account-property-section

fix: Use nextcloud-vue components for personal info settings
pull/43592/head
Ferdinand Thiessen 4 months ago committed by GitHub
commit 8134559bba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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"
autocapitalize="none"
autocomplete="email"
:aria-label="inputPlaceholder"
:error="hasError || !!helperText"
:helper-text="helperText || undefined"
:label="inputPlaceholder"
:placeholder="inputPlaceholder"
:value="email"
:aria-describedby="helperText ? `${inputIdWithDefault}-helper-text` : undefined"
autocapitalize="none"
spellcheck="false"
@input="onEmailChange">
<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>
:success="isSuccess"
type="email"
:value.sync="emailAddress" />
<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,
hasError: false,
helperText: null,
initialEmail: this.email,
isSuccess: false,
localScope: this.scope,
saveAdditionalEmailScope,
helperText: null,
showCheckmarkIcon: false,
showErrorIcon: false,
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;
}
}
}
&__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);
}
margin-top: 6px;
}
}
.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)
},
methods: {
async onLocaleChange(e) {
const locale = this.constructLocale(e.target.value)
this.$emit('update:locale', locale)
if (validateLocale(locale)) {
await this.updateLocale(locale)
}
},
beforeDestroy() {
window.clearInterval(this.intervalId)
},
methods: {
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}`
},
},
methods: {
onPropertyChange(e) {
this.$emit('update:value', e.target.value)
this.debouncePropertyChange(e.target.value.trim())
inputValue: {
get() {
return this.value
},
set(value) {
this.$emit('update:value', value)
this.debouncePropertyChange(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),
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: {
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,
},
})

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

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

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

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

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…
Cancel
Save