diff options
author | Jake Nabasny <jake@nabasny.com> | 2023-11-28 11:01:52 -0500 |
---|---|---|
committer | Richard Steinmetz <richard@steinmetz.cloud> | 2024-05-30 12:01:13 +0200 |
commit | f863290572ff91d9a8a8d6cb2ad819964c3a1426 (patch) | |
tree | 389883fb9a18bdb2dbcd466bd0f46fe9ff7b82b8 /apps | |
parent | 57a7f09a722c384cb3940c19ed2d00d5b9ec925c (diff) | |
download | nextcloud-server-f863290572ff91d9a8a8d6cb2ad819964c3a1426.tar.gz nextcloud-server-f863290572ff91d9a8a8d6cb2ad819964c3a1426.zip |
feat(ldap): sync additional properties to profile and SAB
Synced from LDAP to profile:
- Date of birth
Synced from LDAP to SAB (via the profile):
- Biography
- Date of birth
Original code by Jake Nabasny (GitHub: @slapcat)
Co-authored-by: Jake Nabasny <jake@nabasny.com>
Co-authored-by: Richard Steinmetz <richard@steinmetz.cloud>
Signed-off-by: Richard Steinmetz <richard@steinmetz.cloud>
Diffstat (limited to 'apps')
22 files changed, 346 insertions, 9 deletions
diff --git a/apps/dav/lib/CardDAV/Converter.php b/apps/dav/lib/CardDAV/Converter.php index e962aaa4392..a0a25716ed8 100644 --- a/apps/dav/lib/CardDAV/Converter.php +++ b/apps/dav/lib/CardDAV/Converter.php @@ -7,14 +7,17 @@ */ namespace OCA\DAV\CardDAV; +use DateTimeImmutable; use Exception; use OCP\Accounts\IAccountManager; use OCP\IImage; use OCP\IURLGenerator; use OCP\IUser; use OCP\IUserManager; +use Psr\Log\LoggerInterface; use Sabre\VObject\Component\VCard; use Sabre\VObject\Property\Text; +use Sabre\VObject\Property\VCard\Date; class Converter { /** @var IURLGenerator */ @@ -23,8 +26,12 @@ class Converter { private $accountManager; private IUserManager $userManager; - public function __construct(IAccountManager $accountManager, - IUserManager $userManager, IURLGenerator $urlGenerator) { + public function __construct( + IAccountManager $accountManager, + IUserManager $userManager, + IURLGenerator $urlGenerator, + private LoggerInterface $logger, + ) { $this->accountManager = $accountManager; $this->userManager = $userManager; $this->urlGenerator = $urlGenerator; @@ -114,6 +121,24 @@ class Converter { case IAccountManager::PROPERTY_ROLE: $vCard->add(new Text($vCard, 'TITLE', $property->getValue(), ['X-NC-SCOPE' => $scope])); break; + case IAccountManager::PROPERTY_BIOGRAPHY: + $vCard->add(new Text($vCard, 'NOTE', $property->getValue(), ['X-NC-SCOPE' => $scope])); + break; + case IAccountManager::PROPERTY_BIRTHDATE: + try { + $birthdate = new DateTimeImmutable($property->getValue()); + } catch (Exception $e) { + // Invalid date -> just skip the property + $this->logger->info("Failed to parse user's birthdate for the SAB: " . $property->getValue(), [ + 'exception' => $e, + 'userId' => $user->getUID(), + ]); + break; + } + $dateProperty = new Date($vCard, 'BDAY', null, ['X-NC-SCOPE' => $scope]); + $dateProperty->setDateTime($birthdate); + $vCard->add($dateProperty); + break; } } diff --git a/apps/dav/tests/unit/CardDAV/ConverterTest.php b/apps/dav/tests/unit/CardDAV/ConverterTest.php index b2e21583eab..df6489d5053 100644 --- a/apps/dav/tests/unit/CardDAV/ConverterTest.php +++ b/apps/dav/tests/unit/CardDAV/ConverterTest.php @@ -18,6 +18,7 @@ use OCP\IURLGenerator; use OCP\IUser; use OCP\IUserManager; use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\LoggerInterface; use Test\TestCase; class ConverterTest extends TestCase { @@ -30,12 +31,16 @@ class ConverterTest extends TestCase { /** @var IURLGenerator */ private $urlGenerator; + /** @var LoggerInterface|\PHPUnit\Framework\MockObject\MockObject */ + private $logger; + protected function setUp(): void { parent::setUp(); $this->accountManager = $this->createMock(IAccountManager::class); $this->userManager = $this->createMock(IUserManager::class); $this->urlGenerator = $this->createMock(IURLGenerator::class); + $this->logger = $this->createMock(LoggerInterface::class); } /** @@ -87,7 +92,7 @@ class ConverterTest extends TestCase { $user = $this->getUserMock((string)$displayName, $eMailAddress, $cloudId); $accountManager = $this->getAccountManager($user); - $converter = new Converter($accountManager, $this->userManager, $this->urlGenerator); + $converter = new Converter($accountManager, $this->userManager, $this->urlGenerator, $this->logger); $vCard = $converter->createCardFromUser($user); if ($expectedVCard !== null) { $this->assertInstanceOf('Sabre\VObject\Component\VCard', $vCard); @@ -108,7 +113,7 @@ class ConverterTest extends TestCase { ->willReturn('Manager'); $accountManager = $this->getAccountManager($user); - $converter = new Converter($accountManager, $this->userManager, $this->urlGenerator); + $converter = new Converter($accountManager, $this->userManager, $this->urlGenerator, $this->logger); $vCard = $converter->createCardFromUser($user); $this->compareData( @@ -196,7 +201,7 @@ class ConverterTest extends TestCase { * @param $fullName */ public function testNameSplitter($expected, $fullName): void { - $converter = new Converter($this->accountManager, $this->userManager, $this->urlGenerator); + $converter = new Converter($this->accountManager, $this->userManager, $this->urlGenerator, $this->logger); $r = $converter->splitFullName($fullName); $r = implode(';', $r); $this->assertEquals($expected, $r); diff --git a/apps/provisioning_api/lib/Controller/UsersController.php b/apps/provisioning_api/lib/Controller/UsersController.php index 5770ca39bda..b530d7645e6 100644 --- a/apps/provisioning_api/lib/Controller/UsersController.php +++ b/apps/provisioning_api/lib/Controller/UsersController.php @@ -905,6 +905,7 @@ class UsersController extends AUserData { $permittedFields[] = IAccountManager::PROPERTY_HEADLINE; $permittedFields[] = IAccountManager::PROPERTY_BIOGRAPHY; $permittedFields[] = IAccountManager::PROPERTY_PROFILE_ENABLED; + $permittedFields[] = IAccountManager::PROPERTY_BIRTHDATE; $permittedFields[] = IAccountManager::PROPERTY_PHONE . self::SCOPE_SUFFIX; $permittedFields[] = IAccountManager::PROPERTY_ADDRESS . self::SCOPE_SUFFIX; $permittedFields[] = IAccountManager::PROPERTY_WEBSITE . self::SCOPE_SUFFIX; @@ -915,6 +916,7 @@ class UsersController extends AUserData { $permittedFields[] = IAccountManager::PROPERTY_HEADLINE . self::SCOPE_SUFFIX; $permittedFields[] = IAccountManager::PROPERTY_BIOGRAPHY . self::SCOPE_SUFFIX; $permittedFields[] = IAccountManager::PROPERTY_PROFILE_ENABLED . self::SCOPE_SUFFIX; + $permittedFields[] = IAccountManager::PROPERTY_BIRTHDATE . self::SCOPE_SUFFIX; $permittedFields[] = IAccountManager::PROPERTY_AVATAR . self::SCOPE_SUFFIX; @@ -1085,6 +1087,7 @@ class UsersController extends AUserData { case IAccountManager::PROPERTY_ROLE: case IAccountManager::PROPERTY_HEADLINE: case IAccountManager::PROPERTY_BIOGRAPHY: + case IAccountManager::PROPERTY_BIRTHDATE: $userAccount = $this->accountManager->getAccount($targetUser); try { $userProperty = $userAccount->getProperty($key); @@ -1131,6 +1134,7 @@ class UsersController extends AUserData { case IAccountManager::PROPERTY_HEADLINE . self::SCOPE_SUFFIX: case IAccountManager::PROPERTY_BIOGRAPHY . self::SCOPE_SUFFIX: case IAccountManager::PROPERTY_PROFILE_ENABLED . self::SCOPE_SUFFIX: + case IAccountManager::PROPERTY_BIRTHDATE . self::SCOPE_SUFFIX: case IAccountManager::PROPERTY_AVATAR . self::SCOPE_SUFFIX: $propertyName = substr($key, 0, strlen($key) - strlen(self::SCOPE_SUFFIX)); $userAccount = $this->accountManager->getAccount($targetUser); diff --git a/apps/settings/lib/Controller/UsersController.php b/apps/settings/lib/Controller/UsersController.php index 2cfe9d515bf..6188a00195d 100644 --- a/apps/settings/lib/Controller/UsersController.php +++ b/apps/settings/lib/Controller/UsersController.php @@ -326,6 +326,8 @@ class UsersController extends Controller { * @param string|null $twitterScope * @param string|null $fediverse * @param string|null $fediverseScope + * @param string|null $birthdate + * @param string|null $birthdateScope * * @return DataResponse */ @@ -343,7 +345,9 @@ class UsersController extends Controller { ?string $twitter = null, ?string $twitterScope = null, ?string $fediverse = null, - ?string $fediverseScope = null + ?string $fediverseScope = null, + ?string $birthdate = null, + ?string $birthdateScope = null, ) { $user = $this->userSession->getUser(); if (!$user instanceof IUser) { @@ -383,6 +387,7 @@ class UsersController extends Controller { IAccountManager::PROPERTY_PHONE => ['value' => $phone, 'scope' => $phoneScope], IAccountManager::PROPERTY_TWITTER => ['value' => $twitter, 'scope' => $twitterScope], IAccountManager::PROPERTY_FEDIVERSE => ['value' => $fediverse, 'scope' => $fediverseScope], + IAccountManager::PROPERTY_BIRTHDATE => ['value' => $birthdate, 'scope' => $birthdateScope], ]; $allowUserToChangeDisplayName = $this->config->getSystemValueBool('allow_user_to_change_display_name', true); foreach ($updatable as $property => $data) { @@ -424,6 +429,8 @@ class UsersController extends Controller { 'twitterScope' => $userAccount->getProperty(IAccountManager::PROPERTY_TWITTER)->getScope(), 'fediverse' => $userAccount->getProperty(IAccountManager::PROPERTY_FEDIVERSE)->getValue(), 'fediverseScope' => $userAccount->getProperty(IAccountManager::PROPERTY_FEDIVERSE)->getScope(), + 'birthdate' => $userAccount->getProperty(IAccountManager::PROPERTY_BIRTHDATE)->getValue(), + 'birthdateScope' => $userAccount->getProperty(IAccountManager::PROPERTY_BIRTHDATE)->getScope(), 'message' => $this->l10n->t('Settings saved'), ], ], diff --git a/apps/settings/lib/Settings/Personal/PersonalInfo.php b/apps/settings/lib/Settings/Personal/PersonalInfo.php index 8974d54d45e..5fc8b6957c8 100644 --- a/apps/settings/lib/Settings/Personal/PersonalInfo.php +++ b/apps/settings/lib/Settings/Personal/PersonalInfo.php @@ -167,6 +167,7 @@ class PersonalInfo implements ISettings { 'role' => $this->getProperty($account, IAccountManager::PROPERTY_ROLE), 'headline' => $this->getProperty($account, IAccountManager::PROPERTY_HEADLINE), 'biography' => $this->getProperty($account, IAccountManager::PROPERTY_BIOGRAPHY), + 'birthdate' => $this->getProperty($account, IAccountManager::PROPERTY_BIRTHDATE), ]; $accountParameters = [ diff --git a/apps/settings/src/components/PersonalInfo/BirthdaySection.vue b/apps/settings/src/components/PersonalInfo/BirthdaySection.vue new file mode 100644 index 00000000000..3b4a4d7543b --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/BirthdaySection.vue @@ -0,0 +1,137 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <section> + <HeaderBar :scope="birthdate.scope" + :input-id="inputId" + :readable="birthdate.readable" /> + + <template> + <NcDateTimePickerNative :id="inputId" + type="date" + label="" + :value="value" + @input="onInput" /> + </template> + + <p class="property__helper-text-message"> + {{ t('settings', 'Enter your date of birth') }} + </p> + </section> +</template> + +<script> +import HeaderBar from './shared/HeaderBar.vue' +import AccountPropertySection from './shared/AccountPropertySection.vue' +import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.js' +import { NcDateTimePickerNative } from '@nextcloud/vue' +import debounce from 'debounce' +import { savePrimaryAccountProperty } from '../../service/PersonalInfo/PersonalInfoService' +import { handleError } from '../../utils/handlers' +import AlertCircle from 'vue-material-design-icons/AlertCircleOutline.vue' +import { loadState } from '@nextcloud/initial-state' + +const { birthdate } = loadState('settings', 'personalInfoParameters', {}) + +export default { + name: 'BirthdaySection', + + components: { + AlertCircle, + AccountPropertySection, + NcDateTimePickerNative, + HeaderBar, + }, + + data() { + let initialValue = null + if (birthdate.value) { + initialValue = new Date(birthdate.value) + } + + return { + birthdate: { + ...birthdate, + readable: NAME_READABLE_ENUM[birthdate.name], + }, + initialValue, + } + }, + + computed: { + inputId() { + return `account-property-${birthdate.name}` + }, + value: { + get() { + return new Date(this.birthdate.value) + }, + /** @param {Date} value */ + set(value) { + const day = value.getDate().toString().padStart(2, '0') + const month = (value.getMonth() + 1).toString().padStart(2, '0') + const year = value.getFullYear() + this.birthdate.value = `${year}-${month}-${day}` + } + }, + }, + + methods: { + onInput(e) { + this.value = e + this.debouncePropertyChange(this.value) + }, + + debouncePropertyChange: debounce(async function(value) { + await this.updateProperty(value) + }, 500), + + async updateProperty(value) { + try { + const responseData = await savePrimaryAccountProperty( + this.birthdate.name, + value, + ) + this.handleResponse({ + value, + status: responseData.ocs?.meta?.status, + }) + } catch (error) { + this.handleResponse({ + errorMessage: t('settings', 'Unable to update date of birth'), + error, + }) + } + }, + + handleResponse({ value, status, errorMessage, error }) { + if (status === 'ok') { + this.initialValue = value + } else { + this.$emit('update:value', this.initialValue) + handleError(error, errorMessage) + } + }, + }, +} +</script> + +<style lang="scss" scoped> +section { + padding: 10px 10px; + + &::v-deep button:disabled { + cursor: default; + } + + .property__helper-text-message { + color: var(--color-text-maxcontrast); + padding: 4px 0; + display: flex; + align-items: center; + } +} +</style> diff --git a/apps/settings/src/constants/AccountPropertyConstants.js b/apps/settings/src/constants/AccountPropertyConstants.js index 2dcb6c98f9c..445e044ca72 100644 --- a/apps/settings/src/constants/AccountPropertyConstants.js +++ b/apps/settings/src/constants/AccountPropertyConstants.js @@ -44,6 +44,7 @@ export const ACCOUNT_PROPERTY_ENUM = Object.freeze({ ROLE: 'role', TWITTER: 'twitter', WEBSITE: 'website', + BIRTHDATE: 'birthdate', }) /** Enum of account properties to human readable account property names */ @@ -62,6 +63,7 @@ export const ACCOUNT_PROPERTY_READABLE_ENUM = Object.freeze({ TWITTER: t('settings', 'X (formerly Twitter)'), FEDIVERSE: t('settings', 'Fediverse (e.g. Mastodon)'), WEBSITE: t('settings', 'Website'), + BIRTHDATE: t('settings', 'Date of birth'), }) export const NAME_READABLE_ENUM = Object.freeze({ @@ -79,6 +81,7 @@ export const NAME_READABLE_ENUM = Object.freeze({ [ACCOUNT_PROPERTY_ENUM.TWITTER]: ACCOUNT_PROPERTY_READABLE_ENUM.TWITTER, [ACCOUNT_PROPERTY_ENUM.FEDIVERSE]: ACCOUNT_PROPERTY_READABLE_ENUM.FEDIVERSE, [ACCOUNT_PROPERTY_ENUM.WEBSITE]: ACCOUNT_PROPERTY_READABLE_ENUM.WEBSITE, + [ACCOUNT_PROPERTY_ENUM.BIRTHDATE]: ACCOUNT_PROPERTY_READABLE_ENUM.BIRTHDATE, }) /** Enum of profile specific sections to human readable names */ @@ -102,6 +105,7 @@ export const PROPERTY_READABLE_KEYS_ENUM = Object.freeze({ [ACCOUNT_PROPERTY_READABLE_ENUM.TWITTER]: ACCOUNT_PROPERTY_ENUM.TWITTER, [ACCOUNT_PROPERTY_READABLE_ENUM.FEDIVERSE]: ACCOUNT_PROPERTY_ENUM.FEDIVERSE, [ACCOUNT_PROPERTY_READABLE_ENUM.WEBSITE]: ACCOUNT_PROPERTY_ENUM.WEBSITE, + [ACCOUNT_PROPERTY_READABLE_ENUM.BIRTHDATE]: ACCOUNT_PROPERTY_ENUM.BIRTHDATE, }) /** @@ -144,6 +148,7 @@ export const PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM = Object.freeze({ [ACCOUNT_PROPERTY_READABLE_ENUM.TWITTER]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE], [ACCOUNT_PROPERTY_READABLE_ENUM.FEDIVERSE]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE], [ACCOUNT_PROPERTY_READABLE_ENUM.WEBSITE]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE], + [ACCOUNT_PROPERTY_READABLE_ENUM.BIRTHDATE]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE], }) /** List of readable account properties which aren't published to the lookup server */ @@ -152,6 +157,7 @@ export const UNPUBLISHED_READABLE_PROPERTIES = Object.freeze([ ACCOUNT_PROPERTY_READABLE_ENUM.HEADLINE, ACCOUNT_PROPERTY_READABLE_ENUM.ORGANISATION, ACCOUNT_PROPERTY_READABLE_ENUM.ROLE, + ACCOUNT_PROPERTY_READABLE_ENUM.BIRTHDATE, ]) /** Scope suffix */ diff --git a/apps/settings/src/main-personal-info.js b/apps/settings/src/main-personal-info.js index cf0031ce26c..c8acd50ad60 100644 --- a/apps/settings/src/main-personal-info.js +++ b/apps/settings/src/main-personal-info.js @@ -42,6 +42,7 @@ import RoleSection from './components/PersonalInfo/RoleSection.vue' import HeadlineSection from './components/PersonalInfo/HeadlineSection.vue' import BiographySection from './components/PersonalInfo/BiographySection.vue' import ProfileVisibilitySection from './components/PersonalInfo/ProfileVisibilitySection/ProfileVisibilitySection.vue' +import BirthdaySection from './components/PersonalInfo/BirthdaySection.vue' __webpack_nonce__ = btoa(getRequestToken()) @@ -64,6 +65,7 @@ const TwitterView = Vue.extend(TwitterSection) const FediverseView = Vue.extend(FediverseSection) const LanguageView = Vue.extend(LanguageSection) const LocaleView = Vue.extend(LocaleSection) +const BirthdayView = Vue.extend(BirthdaySection) new AvatarView().$mount('#vue-avatar-section') new DetailsView().$mount('#vue-details-section') @@ -76,6 +78,7 @@ new TwitterView().$mount('#vue-twitter-section') new FediverseView().$mount('#vue-fediverse-section') new LanguageView().$mount('#vue-language-section') new LocaleView().$mount('#vue-locale-section') +new BirthdayView().$mount('#vue-birthday-section') if (profileEnabledGlobally) { const ProfileView = Vue.extend(ProfileSection) diff --git a/apps/settings/templates/settings/personal/personal.info.php b/apps/settings/templates/settings/personal/personal.info.php index 4dae3921645..cff69e5b621 100644 --- a/apps/settings/templates/settings/personal/personal.info.php +++ b/apps/settings/templates/settings/personal/personal.info.php @@ -70,6 +70,9 @@ script('settings', [ <div class="personal-settings-setting-box"> <div id="vue-location-section"></div> </div> + <div class="personal-settings-setting-box"> + <div id="vue-birthday-section"></div> + </div> <div class="personal-settings-setting-box personal-settings-language-box"> <div id="vue-language-section"></div> </div> diff --git a/apps/settings/tests/Controller/UsersControllerTest.php b/apps/settings/tests/Controller/UsersControllerTest.php index d74f97b7013..521c7708921 100644 --- a/apps/settings/tests/Controller/UsersControllerTest.php +++ b/apps/settings/tests/Controller/UsersControllerTest.php @@ -240,7 +240,12 @@ class UsersControllerTest extends \Test\TestCase { ), IAccountManager::PROPERTY_FEDIVERSE => $this->buildPropertyMock( IAccountManager::PROPERTY_FEDIVERSE, - 'Default twitter', + 'Default fediverse', + IAccountManager::SCOPE_LOCAL, + ), + IAccountManager::PROPERTY_BIRTHDATE => $this->buildPropertyMock( + IAccountManager::PROPERTY_BIRTHDATE, + 'Default birthdate', IAccountManager::SCOPE_LOCAL, ), ]; diff --git a/apps/settings/tests/UserMigration/assets/account-complex.json b/apps/settings/tests/UserMigration/assets/account-complex.json index f16a74174e7..891f122f535 100644 --- a/apps/settings/tests/UserMigration/assets/account-complex.json +++ b/apps/settings/tests/UserMigration/assets/account-complex.json @@ -1 +1 @@ -{"displayname":{"name":"displayname","value":"Steve Smith","scope":"v2-local","verified":"0","verificationData":""},"address":{"name":"address","value":"123 Water St","scope":"v2-local","verified":"0","verificationData":""},"website":{"name":"website","value":"https:\/\/example.org","scope":"v2-local","verified":"0","verificationData":""},"email":{"name":"email","value":"steve@example.org","scope":"v2-federated","verified":"0","verificationData":""},"avatar":{"name":"avatar","value":"","scope":"v2-local","verified":"0","verificationData":""},"phone":{"name":"phone","value":"+12178515387","scope":"v2-private","verified":"0","verificationData":""},"twitter":{"name":"twitter","value":"steve","scope":"v2-federated","verified":"0","verificationData":""},"fediverse":{"name":"fediverse","value":"@steve@floss.social","scope":"v2-federated","verified":"0","verificationData":""},"organisation":{"name":"organisation","value":"Mytery Machine","scope":"v2-private","verified":"0","verificationData":""},"role":{"name":"role","value":"Manager","scope":"v2-private","verified":"0","verificationData":""},"headline":{"name":"headline","value":"I am Steve","scope":"v2-local","verified":"0","verificationData":""},"biography":{"name":"biography","value":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris porttitor ullamcorper dictum. Sed fermentum ut ligula scelerisque semper. Aliquam interdum convallis tellus eu dapibus. Integer in justo sollicitudin, hendrerit ligula sit amet, blandit sem.\n\nSuspendisse consectetur ultrices accumsan. Quisque sagittis bibendum lectus ut placerat. Mauris tincidunt ornare neque, et pulvinar tortor porttitor eu.","scope":"v2-local","verified":"0","verificationData":""},"profile_enabled":{"name":"profile_enabled","value":"1","scope":"v2-local","verified":"0","verificationData":""},"additional_mail":[{"name":"additional_mail","value":"steve@example.com","scope":"v2-published","verified":"0","verificationData":""},{"name":"additional_mail","value":"steve@earth.world","scope":"v2-local","verified":"0","verificationData":""}]}
\ No newline at end of file +{"displayname":{"name":"displayname","value":"Steve Smith","scope":"v2-local","verified":"0","verificationData":""},"address":{"name":"address","value":"123 Water St","scope":"v2-local","verified":"0","verificationData":""},"website":{"name":"website","value":"https:\/\/example.org","scope":"v2-local","verified":"0","verificationData":""},"email":{"name":"email","value":"steve@example.org","scope":"v2-federated","verified":"1","verificationData":""},"avatar":{"name":"avatar","value":"","scope":"v2-local","verified":"0","verificationData":""},"phone":{"name":"phone","value":"+12178515387","scope":"v2-private","verified":"0","verificationData":""},"twitter":{"name":"twitter","value":"steve","scope":"v2-federated","verified":"0","verificationData":""},"fediverse":{"name":"fediverse","value":"@steve@floss.social","scope":"v2-federated","verified":"0","verificationData":""},"organisation":{"name":"organisation","value":"Mytery Machine","scope":"v2-private","verified":"0","verificationData":""},"role":{"name":"role","value":"Manager","scope":"v2-private","verified":"0","verificationData":""},"headline":{"name":"headline","value":"I am Steve","scope":"v2-local","verified":"0","verificationData":""},"biography":{"name":"biography","value":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris porttitor ullamcorper dictum. Sed fermentum ut ligula scelerisque semper. Aliquam interdum convallis tellus eu dapibus. Integer in justo sollicitudin, hendrerit ligula sit amet, blandit sem.\n\nSuspendisse consectetur ultrices accumsan. Quisque sagittis bibendum lectus ut placerat. Mauris tincidunt ornare neque, et pulvinar tortor porttitor eu.","scope":"v2-local","verified":"0","verificationData":""},"birthdate":{"name":"birthdate","value":"","scope":"v2-local","verified":"0","verificationData":""},"profile_enabled":{"name":"profile_enabled","value":"1","scope":"v2-local","verified":"0","verificationData":""},"additional_mail":[{"name":"additional_mail","value":"steve@example.com","scope":"v2-published","verified":"0","verificationData":""},{"name":"additional_mail","value":"steve@earth.world","scope":"v2-local","verified":"0","verificationData":""}]}
\ No newline at end of file diff --git a/apps/settings/tests/UserMigration/assets/account.json b/apps/settings/tests/UserMigration/assets/account.json index 0dd5185a6ab..4bcbd8bc4f3 100644 --- a/apps/settings/tests/UserMigration/assets/account.json +++ b/apps/settings/tests/UserMigration/assets/account.json @@ -1 +1 @@ -{"displayname":{"name":"displayname","value":"Emma Jones","scope":"v2-federated","verified":"0","verificationData":""},"address":{"name":"address","value":"920 Grass St","scope":"v2-local","verified":"0","verificationData":""},"website":{"name":"website","value":"","scope":"v2-local","verified":"0","verificationData":""},"email":{"name":"email","value":"","scope":"v2-federated","verified":"0","verificationData":""},"avatar":{"name":"avatar","value":"","scope":"v2-federated","verified":"0","verificationData":""},"phone":{"name":"phone","value":"","scope":"v2-local","verified":"0","verificationData":""},"twitter":{"name":"twitter","value":"","scope":"v2-local","verified":"0","verificationData":""},"fediverse":{"name":"fediverse","value":"","scope":"v2-local","verified":"0","verificationData":""},"organisation":{"name":"organisation","value":"","scope":"v2-local","verified":"0","verificationData":""},"role":{"name":"role","value":"","scope":"v2-local","verified":"0","verificationData":""},"headline":{"name":"headline","value":"","scope":"v2-local","verified":"0","verificationData":""},"biography":{"name":"biography","value":"","scope":"v2-local","verified":"0","verificationData":""},"profile_enabled":{"name":"profile_enabled","value":"1","scope":"v2-local","verified":"0","verificationData":""},"additional_mail":[]}
\ No newline at end of file +{"displayname":{"name":"displayname","value":"Emma Jones","scope":"v2-federated","verified":"0","verificationData":""},"address":{"name":"address","value":"920 Grass St","scope":"v2-local","verified":"0","verificationData":""},"website":{"name":"website","value":"","scope":"v2-local","verified":"0","verificationData":""},"email":{"name":"email","value":"","scope":"v2-federated","verified":"1","verificationData":""},"avatar":{"name":"avatar","value":"","scope":"v2-federated","verified":"0","verificationData":""},"phone":{"name":"phone","value":"","scope":"v2-local","verified":"0","verificationData":""},"twitter":{"name":"twitter","value":"","scope":"v2-local","verified":"0","verificationData":""},"fediverse":{"name":"fediverse","value":"","scope":"v2-local","verified":"0","verificationData":""},"organisation":{"name":"organisation","value":"","scope":"v2-local","verified":"0","verificationData":""},"role":{"name":"role","value":"","scope":"v2-local","verified":"0","verificationData":""},"headline":{"name":"headline","value":"","scope":"v2-local","verified":"0","verificationData":""},"biography":{"name":"biography","value":"","scope":"v2-local","verified":"0","verificationData":""},"birthdate":{"name":"birthdate","value":"","scope":"v2-local","verified":"0","verificationData":""},"profile_enabled":{"name":"profile_enabled","value":"1","scope":"v2-local","verified":"0","verificationData":""},"additional_mail":[]}
\ No newline at end of file diff --git a/apps/user_ldap/composer/composer/autoload_classmap.php b/apps/user_ldap/composer/composer/autoload_classmap.php index a748a9b4428..48fe59a9a51 100644 --- a/apps/user_ldap/composer/composer/autoload_classmap.php +++ b/apps/user_ldap/composer/composer/autoload_classmap.php @@ -80,6 +80,7 @@ return array( 'OCA\\User_LDAP\\Notification\\Notifier' => $baseDir . '/../lib/Notification/Notifier.php', 'OCA\\User_LDAP\\PagedResults\\TLinkId' => $baseDir . '/../lib/PagedResults/TLinkId.php', 'OCA\\User_LDAP\\Proxy' => $baseDir . '/../lib/Proxy.php', + 'OCA\\User_LDAP\\Service\\BirthdateParserService' => $baseDir . '/../lib/Service/BirthdateParserService.php', 'OCA\\User_LDAP\\Service\\UpdateGroupsService' => $baseDir . '/../lib/Service/UpdateGroupsService.php', 'OCA\\User_LDAP\\Settings\\Admin' => $baseDir . '/../lib/Settings/Admin.php', 'OCA\\User_LDAP\\Settings\\Section' => $baseDir . '/../lib/Settings/Section.php', diff --git a/apps/user_ldap/composer/composer/autoload_static.php b/apps/user_ldap/composer/composer/autoload_static.php index 591a78b0f9e..dd5ad0322af 100644 --- a/apps/user_ldap/composer/composer/autoload_static.php +++ b/apps/user_ldap/composer/composer/autoload_static.php @@ -95,6 +95,7 @@ class ComposerStaticInitUser_LDAP 'OCA\\User_LDAP\\Notification\\Notifier' => __DIR__ . '/..' . '/../lib/Notification/Notifier.php', 'OCA\\User_LDAP\\PagedResults\\TLinkId' => __DIR__ . '/..' . '/../lib/PagedResults/TLinkId.php', 'OCA\\User_LDAP\\Proxy' => __DIR__ . '/..' . '/../lib/Proxy.php', + 'OCA\\User_LDAP\\Service\\BirthdateParserService' => __DIR__ . '/..' . '/../lib/Service/BirthdateParserService.php', 'OCA\\User_LDAP\\Service\\UpdateGroupsService' => __DIR__ . '/..' . '/../lib/Service/UpdateGroupsService.php', 'OCA\\User_LDAP\\Settings\\Admin' => __DIR__ . '/..' . '/../lib/Settings/Admin.php', 'OCA\\User_LDAP\\Settings\\Section' => __DIR__ . '/..' . '/../lib/Settings/Section.php', diff --git a/apps/user_ldap/js/wizard/wizardTabAdvanced.js b/apps/user_ldap/js/wizard/wizardTabAdvanced.js index 3b251897968..a33666fc1f5 100644 --- a/apps/user_ldap/js/wizard/wizardTabAdvanced.js +++ b/apps/user_ldap/js/wizard/wizardTabAdvanced.js @@ -167,6 +167,10 @@ OCA = OCA || {}; $element: $('#ldap_attr_biography'), setMethod: 'setBiographyAttribute' }, + ldap_attr_birthdate: { + $element: $('#ldap_attr_birthdate'), + setMethod: 'setBirthdateAttribute' + }, }; this.setManagedItems(items); }, @@ -499,6 +503,15 @@ OCA = OCA || {}; }, /** + * sets the attribute for the Nextcloud user profile birthday + * + * @param {string} attribute + */ + setBirthdateAttribute: function(attribute) { + this.setElementValue(this.managedItems.ldap_attr_birthdate.$element, attribute); + }, + + /** * deals with the result of the Test Connection test * * @param {WizardTabAdvanced} view diff --git a/apps/user_ldap/lib/Configuration.php b/apps/user_ldap/lib/Configuration.php index e0aa095de54..ba357dd33db 100644 --- a/apps/user_ldap/lib/Configuration.php +++ b/apps/user_ldap/lib/Configuration.php @@ -205,6 +205,8 @@ class Configuration { 'ldapAttributeHeadline' => null, 'ldapAttributeBiography' => null, 'ldapAdminGroup' => '', + 'ldapAttributeBirthDate' => null, + 'ldapAttributeAnniversaryDate' => null, ]; public function __construct(string $configPrefix, bool $autoRead = true) { @@ -562,6 +564,8 @@ class Configuration { 'ldap_attr_headline' => '', 'ldap_attr_biography' => '', 'ldap_admin_group' => '', + 'ldap_attr_birthdate' => '', + 'ldap_attr_anniversarydate' => '', ]; } @@ -639,6 +643,8 @@ class Configuration { 'ldap_attr_headline' => 'ldapAttributeHeadline', 'ldap_attr_biography' => 'ldapAttributeBiography', 'ldap_admin_group' => 'ldapAdminGroup', + 'ldap_attr_birthdate' => 'ldapAttributeBirthDate', + 'ldap_attr_anniversarydate' => 'ldapAttributeAnniversaryDate', ]; return $array; } diff --git a/apps/user_ldap/lib/Connection.php b/apps/user_ldap/lib/Connection.php index 8c21032cfe6..a9b2f830ec8 100644 --- a/apps/user_ldap/lib/Connection.php +++ b/apps/user_ldap/lib/Connection.php @@ -114,6 +114,7 @@ use Psr\Log\LoggerInterface; * @property string $ldapAttributeHeadline * @property string $ldapAttributeBiography * @property string $ldapAdminGroup + * @property string $ldapAttributeBirthDate */ class Connection extends LDAPUtility { private ?\LDAP\Connection $ldapConnectionRes = null; diff --git a/apps/user_ldap/lib/Service/BirthdateParserService.php b/apps/user_ldap/lib/Service/BirthdateParserService.php new file mode 100644 index 00000000000..8234161b3d8 --- /dev/null +++ b/apps/user_ldap/lib/Service/BirthdateParserService.php @@ -0,0 +1,44 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\User_LDAP\Service; + +use DateTimeImmutable; +use Exception; +use InvalidArgumentException; + +class BirthdateParserService { + /** + * Try to parse the birthdate from LDAP. + * Supports LDAP's generalized time syntax, YYYYMMDD and YYYY-MM-DD. + * + * @throws InvalidArgumentException If the format of then given date is unknown + */ + public function parseBirthdate(string $value): DateTimeImmutable { + // Minimum LDAP generalized date is "1994121610Z" with 11 chars + // While maximum other format is "1994-12-16" with 10 chars + if (strlen($value) > strlen('YYYY-MM-DD')) { + // Probably LDAP generalized time syntax + $value = substr($value, 0, 8); + } + + // Should be either YYYYMMDD or YYYY-MM-DD + if (!preg_match('/^(\d{8}|\d{4}-\d{2}-\d{2})$/', $value)) { + throw new InvalidArgumentException("Unknown date format: $value"); + } + + try { + return new DateTimeImmutable($value); + } catch (Exception $e) { + throw new InvalidArgumentException( + "Unknown date format: $value", + 0, + $e, + ); + } + } +} diff --git a/apps/user_ldap/lib/User/Manager.php b/apps/user_ldap/lib/User/Manager.php index 9d3ba333e89..c30ed4fbb3d 100644 --- a/apps/user_ldap/lib/User/Manager.php +++ b/apps/user_ldap/lib/User/Manager.php @@ -162,6 +162,7 @@ class Manager { $this->access->getConnection()->ldapAttributeRole, $this->access->getConnection()->ldapAttributeHeadline, $this->access->getConnection()->ldapAttributeBiography, + $this->access->getConnection()->ldapAttributeBirthDate, ]; $homeRule = (string)$this->access->getConnection()->homeFolderNamingRule; diff --git a/apps/user_ldap/lib/User/User.php b/apps/user_ldap/lib/User/User.php index c3e9895e043..2e7629cd33c 100644 --- a/apps/user_ldap/lib/User/User.php +++ b/apps/user_ldap/lib/User/User.php @@ -32,11 +32,13 @@ */ namespace OCA\User_LDAP\User; +use InvalidArgumentException; use OC\Accounts\AccountManager; use OCA\User_LDAP\Access; use OCA\User_LDAP\Connection; use OCA\User_LDAP\Exceptions\AttributeNotSet; use OCA\User_LDAP\FilesystemHelper; +use OCA\User_LDAP\Service\BirthdateParserService; use OCP\Accounts\IAccountManager; use OCP\Accounts\PropertyDoesNotExistException; use OCP\IAvatarManager; @@ -107,6 +109,8 @@ class User { */ protected $avatarImage; + protected BirthdateParserService $birthdateParser; + /** * DB config keys for user preferences */ @@ -140,6 +144,7 @@ class User { $this->avatarManager = $avatarManager; $this->userManager = $userManager; $this->notificationManager = $notificationManager; + $this->birthdateParser = new BirthdateParserService(); \OCP\Util::connectHook('OC_User', 'post_login', $this, 'handlePasswordExpiry'); } @@ -324,6 +329,22 @@ class User { } elseif (!empty($attr)) { // configured, but not defined $profileValues[\OCP\Accounts\IAccountManager::PROPERTY_BIOGRAPHY] = ""; } + //User Profile Field - birthday + $attr = strtolower($this->connection->ldapAttributeBirthDate); + if (!empty($attr) && !empty($ldapEntry[$attr][0])) { + $value = $ldapEntry[$attr][0]; + try { + $birthdate = $this->birthdateParser->parseBirthdate($value); + $profileValues[\OCP\Accounts\IAccountManager::PROPERTY_BIRTHDATE] + = $birthdate->format("Y-m-d"); + } catch (InvalidArgumentException $e) { + // Invalid date -> just skip the property + $this->logger->info("Failed to parse user's birthdate from LDAP: $value", [ + 'exception' => $e, + 'userId' => $username, + ]); + } + } // check for changed data and cache just for TTL checking $checksum = hash('sha256', json_encode($profileValues)); $this->connection->writeToCache($cacheKey, $checksum // write array to cache. is waste of cache space diff --git a/apps/user_ldap/templates/settings.php b/apps/user_ldap/templates/settings.php index ae4091288b5..1f5967566d6 100644 --- a/apps/user_ldap/templates/settings.php +++ b/apps/user_ldap/templates/settings.php @@ -132,6 +132,7 @@ style('user_ldap', 'settings'); <p><label for="ldap_attr_role"> <?php p($l->t('Role Field')); ?></label><input type="text" id="ldap_attr_role" name="ldap_attr_role" title="<?php p($l->t('User profile Role will be set from the specified attribute')); ?>" data-default="<?php p($_['ldap_attr_role_default']); ?>"></p> <p><label for="ldap_attr_headline"> <?php p($l->t('Headline Field')); ?></label><input type="text" id="ldap_attr_headline" name="ldap_attr_headline" title="<?php p($l->t('User profile Headline will be set from the specified attribute')); ?>" data-default="<?php p($_['ldap_attr_headline_default']); ?>"></p> <p><label for="ldap_attr_biography"> <?php p($l->t('Biography Field')); ?></label><input type="text" id="ldap_attr_biography" name="ldap_attr_biography" title="<?php p($l->t('User profile Biography will be set from the specified attribute')); ?>" data-default="<?php p($_['ldap_attr_biography_default']); ?>"></p> + <p><label for="ldap_attr_birthdate"> <?php p($l->t('Birthdate Field')); ?></label><input type="text" id="ldap_attr_birthdate" name="ldap_attr_birthdate" title="<?php p($l->t('User profile Date of birth will be set from the specified attribute')); ?>" data-default="<?php p($_['ldap_attr_birthdate_default']); ?>"></p> </div> </div> <?php print_unescaped($_['settingControls']); ?> diff --git a/apps/user_ldap/tests/Service/BirthdateParserServiceTest.php b/apps/user_ldap/tests/Service/BirthdateParserServiceTest.php new file mode 100644 index 00000000000..79450d6913e --- /dev/null +++ b/apps/user_ldap/tests/Service/BirthdateParserServiceTest.php @@ -0,0 +1,52 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\user_ldap\tests\Service; + +use DateTimeImmutable; +use OCA\User_LDAP\Service\BirthdateParserService; +use PHPUnit\Framework\TestCase; + +class BirthdateParserServiceTest extends TestCase { + private BirthdateParserService $service; + + protected function setUp(): void { + parent::setUp(); + + $this->service = new BirthdateParserService(); + } + + public function parseBirthdateDataProvider(): array { + return [ + ['2024-01-01', new DateTimeImmutable('2024-01-01'), false], + ['20240101', new DateTimeImmutable('2024-01-01'), false], + ['199412161032Z', new DateTimeImmutable('1994-12-16'), false], // LDAP generalized time + ['199412160532-0500', new DateTimeImmutable('1994-12-16'), false], // LDAP generalized time + ['2023-07-31T00:60:59.000Z', null, true], + ['01.01.2024', null, true], + ['01/01/2024', null, true], + ['01 01 2024', null, true], + ['foobar', null, true], + ]; + } + + /** + * @dataProvider parseBirthdateDataProvider + */ + public function testParseBirthdate( + string $value, + ?DateTimeImmutable $expected, + bool $shouldThrow, + ): void { + if ($shouldThrow) { + $this->expectException(\InvalidArgumentException::class); + } + + $actual = $this->service->parseBirthdate($value); + $this->assertEquals($expected, $actual); + } +} |