Merge pull request #54470 from nextcloud/feat/central-timezone-setting

feat: allow to set your local timezone in settings and provide it to clients
This commit is contained in:
Ferdinand Thiessen 2025-08-18 14:20:13 +02:00 committed by GitHub
commit 2fb1cfeb10
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 359 additions and 117 deletions

View file

@ -4,45 +4,36 @@
-->
<template>
<div>
<div class="time-zone">
<label :for="`vs${timeZonePickerId}__combobox`" class="time-zone__heading">
{{ $t('dav', 'Time zone:') }}
</label>
<span class="time-zone-text">
<NcTimezonePicker v-model="timezone" :uid="timeZonePickerId" />
</span>
</div>
<CalendarAvailability :slots.sync="slots"
:loading="loading"
:l10n-to="$t('dav', 'to')"
:l10n-delete-slot="$t('dav', 'Delete slot')"
:l10n-empty-day="$t('dav', 'No working hours set')"
:l10n-add-slot="$t('dav', 'Add slot')"
:l10n-week-day-list-label="$t('dav', 'Weekdays')"
:l10n-monday="$t('dav', 'Monday')"
:l10n-tuesday="$t('dav', 'Tuesday')"
:l10n-wednesday="$t('dav', 'Wednesday')"
:l10n-thursday="$t('dav', 'Thursday')"
:l10n-friday="$t('dav', 'Friday')"
:l10n-saturday="$t('dav', 'Saturday')"
:l10n-sunday="$t('dav', 'Sunday')"
:l10n-start-picker-label="(dayName) => $t('dav', 'Pick a start time for {dayName}', { dayName })"
:l10n-end-picker-label="(dayName) => $t('dav', 'Pick a end time for {dayName}', { dayName })" />
:l10n-to="t('dav', 'to')"
:l10n-delete-slot="t('dav', 'Delete slot')"
:l10n-empty-day="t('dav', 'No working hours set')"
:l10n-add-slot="t('dav', 'Add slot')"
:l10n-week-day-list-label="t('dav', 'Weekdays')"
:l10n-monday="t('dav', 'Monday')"
:l10n-tuesday="t('dav', 'Tuesday')"
:l10n-wednesday="t('dav', 'Wednesday')"
:l10n-thursday="t('dav', 'Thursday')"
:l10n-friday="t('dav', 'Friday')"
:l10n-saturday="t('dav', 'Saturday')"
:l10n-sunday="t('dav', 'Sunday')"
:l10n-start-picker-label="(dayName) => t('dav', 'Pick a start time for {dayName}', { dayName })"
:l10n-end-picker-label="(dayName) => t('dav', 'Pick a end time for {dayName}', { dayName })" />
<NcCheckboxRadioSwitch :checked.sync="automated">
{{ $t('dav', 'Automatically set user status to "Do not disturb" outside of availability to mute all notifications.') }}
<NcCheckboxRadioSwitch v-model="automated">
{{ t('dav', 'Automatically set user status to "Do not disturb" outside of availability to mute all notifications.') }}
</NcCheckboxRadioSwitch>
<NcButton :disabled="loading || saving"
type="primary"
variant="primary"
@click="save">
{{ $t('dav', 'Save') }}
{{ t('dav', 'Save') }}
</NcButton>
</div>
</template>
<script>
<script setup lang="ts">
import { CalendarAvailability } from '@nextcloud/calendar-availability-vue'
import { loadState } from '@nextcloud/initial-state'
import {
@ -60,77 +51,57 @@ import {
} from '../service/PreferenceService.js'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcTimezonePicker from '@nextcloud/vue/components/NcTimezonePicker'
import { getCapabilities } from '@nextcloud/capabilities'
import { onMounted, ref } from 'vue'
import logger from '../service/logger.js'
import { t } from '@nextcloud/l10n'
export default {
name: 'AvailabilityForm',
components: {
NcButton,
NcCheckboxRadioSwitch,
CalendarAvailability,
NcTimezonePicker,
},
data() {
// Try to determine the current timezone, and fall back to UTC otherwise
const defaultTimezoneId = (new Intl.DateTimeFormat())?.resolvedOptions()?.timeZone ?? 'UTC'
// @ts-expect-error capabilities is missing the capability to type it...
const timezone = getCapabilities().core.user?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone
return {
loading: true,
saving: false,
timezone: defaultTimezoneId,
slots: getEmptySlots(),
automated: loadState('dav', 'user_status_automation') === 'yes',
const loading = ref(true)
const saving = ref(false)
const slots = ref(getEmptySlots())
const automated = ref(loadState('dav', 'user_status_automation') === 'yes')
onMounted(async () => {
try {
const slotData = await findScheduleInboxAvailability()
if (!slotData) {
logger.debug('no availability is set')
} else {
slots.value = slotData.slots
logger.debug('availability loaded', { slots: slots.value })
}
},
computed: {
timeZonePickerId() {
return `tz-${(Math.random() + 1).toString(36).substring(7)}`
},
},
async mounted() {
try {
const slotData = await findScheduleInboxAvailability()
if (!slotData) {
console.info('no availability is set')
this.slots = getEmptySlots()
} else {
const { slots, timezoneId } = slotData
this.slots = slots
if (timezoneId) {
this.timezone = timezoneId
}
console.info('availability loaded', this.slots, this.timezoneId)
}
} catch (e) {
console.error('could not load existing availability', e)
} catch (error) {
logger.error('could not load existing availability', { error })
showError(t('dav', 'Failed to load availability'))
} finally {
loading.value = false
}
})
showError(t('dav', 'Failed to load availability'))
} finally {
this.loading = false
/**
* Save current slots on the server
*/
async function save() {
saving.value = true
try {
await saveScheduleInboxAvailability(slots.value, timezone)
if (automated.value) {
await enableUserStatusAutomation()
} else {
await disableUserStatusAutomation()
}
},
methods: {
async save() {
try {
this.saving = true
await saveScheduleInboxAvailability(this.slots, this.timezone)
if (this.automated) {
await enableUserStatusAutomation()
} else {
await disableUserStatusAutomation()
}
showSuccess(t('dav', 'Saved availability'))
} catch (e) {
console.error('could not save availability', e)
showSuccess(t('dav', 'Saved availability'))
} catch (e) {
console.error('could not save availability', e)
showError(t('dav', 'Failed to save availability'))
} finally {
this.saving = false
}
},
},
showError(t('dav', 'Failed to save availability'))
} finally {
saving.value = false
}
}
</script>
@ -165,11 +136,6 @@ export default {
width: 97px;
}
:deep(.multiselect) {
border: 1px solid var(--color-border-dark);
width: 120px;
}
.time-zone {
padding-block: 32px 12px;
padding-inline: 0 12px;

View file

@ -43,6 +43,7 @@ abstract class AUserDataOCSController extends OCSController {
public const USER_FIELD_DISPLAYNAME = 'display';
public const USER_FIELD_LANGUAGE = 'language';
public const USER_FIELD_LOCALE = 'locale';
public const USER_FIELD_TIMEZONE = 'timezone';
public const USER_FIELD_FIRST_DAY_OF_WEEK = 'first_day_of_week';
public const USER_FIELD_PASSWORD = 'password';
public const USER_FIELD_QUOTA = 'quota';
@ -187,6 +188,7 @@ abstract class AUserDataOCSController extends OCSController {
$data['groups'] = $gids;
$data[self::USER_FIELD_LANGUAGE] = $this->l10nFactory->getUserLanguage($targetUserObject);
$data[self::USER_FIELD_LOCALE] = $this->config->getUserValue($targetUserObject->getUID(), 'core', 'locale');
$data[self::USER_FIELD_TIMEZONE] = $this->config->getUserValue($targetUserObject->getUID(), 'core', 'timezone');
$data[self::USER_FIELD_NOTIFICATION_EMAIL] = $targetUserObject->getPrimaryEMailAddress();
$backend = $targetUserObject->getBackend();

View file

@ -954,6 +954,7 @@ class UsersController extends AUserDataOCSController {
$permittedFields[] = self::USER_FIELD_PASSWORD;
$permittedFields[] = self::USER_FIELD_NOTIFICATION_EMAIL;
$permittedFields[] = self::USER_FIELD_TIMEZONE;
if (
$this->config->getSystemValue('force_language', false) === false
|| $this->groupManager->isAdmin($currentLoggedInUser->getUID())
@ -1028,6 +1029,7 @@ class UsersController extends AUserDataOCSController {
$permittedFields[] = self::USER_FIELD_PASSWORD;
$permittedFields[] = self::USER_FIELD_LANGUAGE;
$permittedFields[] = self::USER_FIELD_LOCALE;
$permittedFields[] = self::USER_FIELD_TIMEZONE;
$permittedFields[] = self::USER_FIELD_FIRST_DAY_OF_WEEK;
$permittedFields[] = IAccountManager::PROPERTY_PHONE;
$permittedFields[] = IAccountManager::PROPERTY_ADDRESS;
@ -1122,6 +1124,12 @@ class UsersController extends AUserDataOCSController {
}
$this->config->setUserValue($targetUser->getUID(), 'core', 'locale', $value);
break;
case self::USER_FIELD_TIMEZONE:
if (!in_array($value, \DateTimeZone::listIdentifiers())) {
throw new OCSException($this->l10n->t('Invalid timezone'), 101);
}
$this->config->setUserValue($targetUser->getUID(), 'core', 'timezone', $value);
break;
case self::USER_FIELD_FIRST_DAY_OF_WEEK:
$intValue = (int)$value;
if ($intValue < -1 || $intValue > 6) {

View file

@ -65,6 +65,7 @@ namespace OCA\Provisioning_API;
* roleScope?: Provisioning_APIUserDetailsScope,
* storageLocation?: string,
* subadmin: list<string>,
* timezone: string,
* twitter: string,
* twitterScope?: Provisioning_APIUserDetailsScope,
* bluesky: string,

View file

@ -105,6 +105,7 @@
"quota",
"role",
"subadmin",
"timezone",
"twitter",
"bluesky",
"website"
@ -262,6 +263,9 @@
"type": "string"
}
},
"timezone": {
"type": "string"
},
"twitter": {
"type": "string"
},

View file

@ -152,6 +152,7 @@
"quota",
"role",
"subadmin",
"timezone",
"twitter",
"bluesky",
"website"
@ -309,6 +310,9 @@
"type": "string"
}
},
"timezone": {
"type": "string"
},
"twitter": {
"type": "string"
},

View file

@ -152,6 +152,7 @@
"quota",
"role",
"subadmin",
"timezone",
"twitter",
"bluesky",
"website"
@ -309,6 +310,9 @@
"type": "string"
}
},
"timezone": {
"type": "string"
},
"twitter": {
"type": "string"
},

View file

@ -1225,6 +1225,7 @@ class UsersControllerTest extends TestCase {
'groups' => ['group0', 'group1', 'group2'],
'language' => 'de',
'locale' => null,
'timezone' => null,
'backendCapabilities' => [
'setDisplayName' => true,
'setPassword' => true,
@ -1372,6 +1373,7 @@ class UsersControllerTest extends TestCase {
'groups' => [],
'language' => 'da',
'locale' => null,
'timezone' => null,
'backendCapabilities' => [
'setDisplayName' => true,
'setPassword' => true,
@ -1557,6 +1559,7 @@ class UsersControllerTest extends TestCase {
'groups' => [],
'language' => 'ru',
'locale' => null,
'timezone' => null,
'backendCapabilities' => [
'setDisplayName' => false,
'setPassword' => false,

View file

@ -110,6 +110,7 @@ class PersonalInfo implements ISettings {
'biography' => $this->getProperty($account, IAccountManager::PROPERTY_BIOGRAPHY),
'birthdate' => $this->getProperty($account, IAccountManager::PROPERTY_BIRTHDATE),
'firstDayOfWeek' => $this->config->getUserValue($uid, 'core', AUserDataOCSController::USER_FIELD_FIRST_DAY_OF_WEEK),
'timezone' => $this->config->getUserValue($uid, 'core', 'timezone', ''),
'pronouns' => $this->getProperty($account, IAccountManager::PROPERTY_PRONOUNS),
];

View file

@ -0,0 +1,43 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { ref, watch } from 'vue'
import NcTimezonePicker from '@nextcloud/vue/components/NcTimezonePicker'
import HeaderBar from './shared/HeaderBar.vue'
import { savePrimaryAccountProperty } from '../../service/PersonalInfo/PersonalInfoService.js'
const { timezone: currentTimezone } = loadState<{ timezone: string }>('settings', 'personalInfoParameters')
const inputId = 'account-property-timezone'
const timezone = ref(currentTimezone)
watch(timezone, () => {
savePrimaryAccountProperty('timezone', timezone.value)
})
</script>
<template>
<section class="timezone-section">
<HeaderBar :input-id="inputId"
:readable="t('settings', 'Timezone')" />
<NcTimezonePicker v-model="timezone"
class="timezone-section__picker"
:input-id="inputId" />
</section>
</template>
<style scoped lang="scss">
.timezone-section {
padding: 10px;
&__picker {
margin-top: 6px;
width: 100%;
}
}
</style>

View file

@ -108,6 +108,7 @@ export const ACCOUNT_SETTING_PROPERTY_ENUM = Object.freeze({
LANGUAGE: 'language',
LOCALE: 'locale',
FIRST_DAY_OF_WEEK: 'first_day_of_week',
TIMEZONE: 'timezone',
})
/** Enum of account setting properties to human readable setting properties */
@ -115,6 +116,7 @@ export const ACCOUNT_SETTING_PROPERTY_READABLE_ENUM = Object.freeze({
LANGUAGE: t('settings', 'Language'),
LOCALE: t('settings', 'Locale'),
FIRST_DAY_OF_WEEK: t('settings', 'First day of week'),
TIMEZONE: t('settings', 'timezone'),
})
/** Enum of scopes */

View file

@ -26,6 +26,7 @@ import ProfileSection from './components/PersonalInfo/ProfileSection/ProfileSect
import ProfileVisibilitySection from './components/PersonalInfo/ProfileVisibilitySection/ProfileVisibilitySection.vue'
import PronounsSection from './components/PersonalInfo/PronounsSection.vue'
import RoleSection from './components/PersonalInfo/RoleSection.vue'
import TimezoneSection from './components/PersonalInfo/TimezoneSection.vue'
import TwitterSection from './components/PersonalInfo/TwitterSection.vue'
import BlueskySection from './components/PersonalInfo/BlueskySection.vue'
import WebsiteSection from './components/PersonalInfo/WebsiteSection.vue'
@ -47,6 +48,7 @@ const DisplayNameView = Vue.extend(DisplayNameSection)
const EmailView = Vue.extend(EmailSection)
const FediverseView = Vue.extend(FediverseSection)
const FirstDayOfWeekView = Vue.extend(FirstDayOfWeekSection)
const TimezoneView = Vue.extend(TimezoneSection)
const LanguageView = Vue.extend(LanguageSection)
const LocaleView = Vue.extend(LocaleSection)
const LocationView = Vue.extend(LocationSection)
@ -69,6 +71,7 @@ new FediverseView().$mount('#vue-fediverse-section')
new LanguageView().$mount('#vue-language-section')
new LocaleView().$mount('#vue-locale-section')
new FirstDayOfWeekView().$mount('#vue-fdow-section')
new TimezoneView().$mount('#vue-timezone-section')
new BirthdayView().$mount('#vue-birthday-section')
new PronounsView().$mount('#vue-pronouns-section')

View file

@ -67,6 +67,9 @@ script('settings', [
<div class="personal-settings-setting-box">
<div id="vue-fdow-section"></div>
</div>
<div class="personal-settings-setting-box">
<div id="vue-timezone-section"></div>
</div>
<div class="personal-settings-setting-box">
<div id="vue-website-section"></div>
</div>

View file

@ -81,6 +81,8 @@ class Application extends App implements IBootstrap {
// config lexicon
$context->registerConfigLexicon(ConfigLexicon::class);
$context->registerCapability(Capabilities::class);
}
public function boot(IBootContext $context): void {

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Core\AppInfo;
use OCP\Capabilities\ICapability;
use OCP\Config\IUserConfig;
use OCP\IDateTimeZone;
use OCP\IGroupManager;
use OCP\IUserSession;
class Capabilities implements ICapability {
public function __construct(
private IUserSession $session,
private IUserConfig $userConfig,
private IGroupManager $groupManager,
) {
}
/**
* Return the core capabilities
*
* @return array{core: array{'user'?: array{language: string, locale: string, timezone: string} } }
*/
public function getCapabilities(): array {
$capabilities = [];
$user = $this->session->getUser();
if ($user !== null) {
$timezone = \OCP\Server::get(IDateTimeZone::class)->getTimeZone();
$capabilities['user'] = [
'language' => $this->userConfig->getValueString($user->getUID(), Application::APP_ID, ConfigLexicon::USER_LANGUAGE),
'locale' => $this->userConfig->getValueString($user->getUID(), Application::APP_ID, ConfigLexicon::USER_LOCALE),
'timezone' => $timezone->getName(),
];
}
return [
'core' => $capabilities,
];
}
}

View file

@ -26,6 +26,9 @@ class ConfigLexicon implements ILexicon {
public const SHARE_LINK_PASSWORD_ENFORCED = 'shareapi_enforce_links_password';
public const USER_LANGUAGE = 'lang';
public const USER_LOCALE = 'locale';
public const USER_TIMEZONE = 'timezone';
public const LASTCRON_TIMESTAMP = 'lastcron';
public function getStrictness(): Strictness {
@ -68,7 +71,9 @@ class ConfigLexicon implements ILexicon {
public function getUserConfigs(): array {
return [
new Entry(self::USER_LANGUAGE, ValueType::STRING, null, 'language'),
new Entry(self::USER_LANGUAGE, ValueType::STRING, definition: 'language'),
new Entry(self::USER_LOCALE, ValueType::STRING, definition: 'locale'),
new Entry(self::USER_TIMEZONE, ValueType::STRING, definition: 'timezone'),
];
}
}

View file

@ -51,6 +51,25 @@
},
"mod-rewrite-working": {
"type": "boolean"
},
"user": {
"type": "object",
"required": [
"language",
"locale",
"timezone"
],
"properties": {
"language": {
"type": "string"
},
"locale": {
"type": "string"
},
"timezone": {
"type": "string"
}
}
}
}
}

View file

@ -51,6 +51,25 @@
},
"mod-rewrite-working": {
"type": "boolean"
},
"user": {
"type": "object",
"required": [
"language",
"locale",
"timezone"
],
"properties": {
"language": {
"type": "string"
},
"locale": {
"type": "string"
},
"timezone": {
"type": "string"
}
}
}
}
}

View file

@ -117,6 +117,25 @@
},
"mod-rewrite-working": {
"type": "boolean"
},
"user": {
"type": "object",
"required": [
"language",
"locale",
"timezone"
],
"properties": {
"language": {
"type": "string"
},
"locale": {
"type": "string"
},
"timezone": {
"type": "string"
}
}
}
}
}

View file

@ -117,6 +117,25 @@
},
"mod-rewrite-working": {
"type": "boolean"
},
"user": {
"type": "object",
"required": [
"language",
"locale",
"timezone"
],
"properties": {
"language": {
"type": "string"
},
"locale": {
"type": "string"
},
"timezone": {
"type": "string"
}
}
}
}
}

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

View file

@ -1,3 +1,4 @@
SPDX-License-Identifier: MPL-2.0
SPDX-License-Identifier: MIT
SPDX-License-Identifier: ISC
SPDX-License-Identifier: GPL-3.0-or-later
@ -17,6 +18,7 @@ SPDX-FileCopyrightText: T. Jameson Little <t.jameson.little@gmail.com>
SPDX-FileCopyrightText: Roman Shtylman <shtylman@gmail.com>
SPDX-FileCopyrightText: Roeland Jago Douma
SPDX-FileCopyrightText: Rob Cresswell <robcresswell@pm.me>
SPDX-FileCopyrightText: Philipp Kewisch
SPDX-FileCopyrightText: Paul Vorbach <paul@vorba.ch> (http://paul.vorba.ch)
SPDX-FileCopyrightText: Paul Vorbach <paul@vorb.de> (http://vorb.de)
SPDX-FileCopyrightText: Nextcloud GmbH and Nextcloud contributors
@ -145,6 +147,9 @@ This file is generated from multiple sources. Included packages:
- focus-trap
- version: 7.6.5
- license: MIT
- ical.js
- version: 2.1.0
- license: MPL-2.0
- ieee754
- version: 1.2.1
- license: BSD-3-Clause

File diff suppressed because one or more lines are too long

View file

@ -1237,6 +1237,7 @@ return array(
'OC\\Contacts\\ContactsMenu\\Providers\\ProfileProvider' => $baseDir . '/lib/private/Contacts/ContactsMenu/Providers/ProfileProvider.php',
'OC\\ContextChat\\ContentManager' => $baseDir . '/lib/private/ContextChat/ContentManager.php',
'OC\\Core\\AppInfo\\Application' => $baseDir . '/core/AppInfo/Application.php',
'OC\\Core\\AppInfo\\Capabilities' => $baseDir . '/core/AppInfo/Capabilities.php',
'OC\\Core\\AppInfo\\ConfigLexicon' => $baseDir . '/core/AppInfo/ConfigLexicon.php',
'OC\\Core\\BackgroundJobs\\BackgroundCleanupUpdaterBackupsJob' => $baseDir . '/core/BackgroundJobs/BackgroundCleanupUpdaterBackupsJob.php',
'OC\\Core\\BackgroundJobs\\CheckForUserCertificates' => $baseDir . '/core/BackgroundJobs/CheckForUserCertificates.php',

View file

@ -1278,6 +1278,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\Contacts\\ContactsMenu\\Providers\\ProfileProvider' => __DIR__ . '/../../..' . '/lib/private/Contacts/ContactsMenu/Providers/ProfileProvider.php',
'OC\\ContextChat\\ContentManager' => __DIR__ . '/../../..' . '/lib/private/ContextChat/ContentManager.php',
'OC\\Core\\AppInfo\\Application' => __DIR__ . '/../../..' . '/core/AppInfo/Application.php',
'OC\\Core\\AppInfo\\Capabilities' => __DIR__ . '/../../..' . '/core/AppInfo/Capabilities.php',
'OC\\Core\\AppInfo\\ConfigLexicon' => __DIR__ . '/../../..' . '/core/AppInfo/ConfigLexicon.php',
'OC\\Core\\BackgroundJobs\\BackgroundCleanupUpdaterBackupsJob' => __DIR__ . '/../../..' . '/core/BackgroundJobs/BackgroundCleanupUpdaterBackupsJob.php',
'OC\\Core\\BackgroundJobs\\CheckForUserCertificates' => __DIR__ . '/../../..' . '/core/BackgroundJobs/CheckForUserCertificates.php',

View file

@ -8,6 +8,8 @@ declare(strict_types=1);
*/
namespace OC\Authentication\Login;
use OC\Core\AppInfo\Application;
use OC\Core\AppInfo\ConfigLexicon;
use OCP\IConfig;
use OCP\ISession;
@ -26,12 +28,10 @@ class SetUserTimezoneCommand extends ALoginCommand {
public function process(LoginData $loginData): LoginResult {
if ($loginData->getTimeZoneOffset() !== '' && $this->isValidTimezone($loginData->getTimeZone())) {
$this->config->setUserValue(
$loginData->getUser()->getUID(),
'core',
'timezone',
$loginData->getTimeZone()
);
$userId = $loginData->getUser()->getUID();
if ($this->config->getUserValue($userId, Application::APP_ID, ConfigLexicon::USER_TIMEZONE, '') === '') {
$this->config->setUserValue($userId, Application::APP_ID, ConfigLexicon::USER_TIMEZONE, $loginData->getTimeZone());
}
$this->session->set(
'timezone',
$loginData->getTimeZoneOffset()

View file

@ -155,6 +155,25 @@
},
"mod-rewrite-working": {
"type": "boolean"
},
"user": {
"type": "object",
"required": [
"language",
"locale",
"timezone"
],
"properties": {
"language": {
"type": "string"
},
"locale": {
"type": "string"
},
"timezone": {
"type": "string"
}
}
}
}
}
@ -3564,6 +3583,7 @@
"quota",
"role",
"subadmin",
"timezone",
"twitter",
"bluesky",
"website"
@ -3721,6 +3741,9 @@
"type": "string"
}
},
"timezone": {
"type": "string"
},
"twitter": {
"type": "string"
},

View file

@ -15,11 +15,10 @@ use OCP\ISession;
use PHPUnit\Framework\MockObject\MockObject;
class SetUserTimezoneCommandTest extends ALoginTestCommand {
/** @var IConfig|MockObject */
private $config;
/** @var ISession|MockObject */
private $session;
private IConfig&MockObject $config;
private ISession&MockObject $session;
protected function setUp(): void {
parent::setUp();
@ -50,6 +49,15 @@ class SetUserTimezoneCommandTest extends ALoginTestCommand {
$this->user->expects($this->once())
->method('getUID')
->willReturn($this->username);
$this->config->expects($this->once())
->method('getUserValue')
->with(
$this->username,
'core',
'timezone',
''
)
->willReturn('');
$this->config->expects($this->once())
->method('setUserValue')
->with(
@ -69,4 +77,32 @@ class SetUserTimezoneCommandTest extends ALoginTestCommand {
$this->assertTrue($result->isSuccess());
}
public function testProcessAlreadySet(): void {
$data = $this->getLoggedInLoginDataWithTimezone();
$this->user->expects($this->once())
->method('getUID')
->willReturn($this->username);
$this->config->expects($this->once())
->method('getUserValue')
->with(
$this->username,
'core',
'timezone',
'',
)
->willReturn('Europe/Berlin');
$this->config->expects($this->never())
->method('setUserValue');
$this->session->expects($this->once())
->method('set')
->with(
'timezone',
$this->timeZoneOffset
);
$result = $this->cmd->process($data);
$this->assertTrue($result->isSuccess());
}
}